From 5666b867b7ebebf105ed26a1211a8d9a1a7b561c Mon Sep 17 00:00:00 2001 From: scanash.com Date: Mon, 1 Dec 2025 00:19:59 -0900 Subject: [PATCH] Fix redrafting for images and video --- .../PostControls/PostMenu/PostMenuItems.tsx | 59 ++++++++++++++++++- src/lib/api/index.ts | 24 ++++++-- src/state/gallery.ts | 7 ++- src/state/shell/composer/index.tsx | 5 +- src/view/com/composer/Composer.tsx | 23 ++++++-- src/view/com/composer/state/composer.ts | 22 ++++++- src/view/com/composer/state/video.ts | 36 +++++++++++ .../com/composer/videos/VideoEmbedRedraft.tsx | 57 ++++++++++++++++++ src/view/shell/Composer.web.tsx | 1 + 9 files changed, 216 insertions(+), 18 deletions(-) create mode 100644 src/view/com/composer/videos/VideoEmbedRedraft.tsx diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 2404df1fa..28f62d034 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -17,6 +17,7 @@ import { type AppBskyFeedThreadgate, AtUri, type RichText as RichTextAPI, + type BlobRef, } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -230,25 +231,39 @@ let PostMenuItems = ({ width: number height: number altText?: string + blobRef?: AppBskyEmbedImages.Image['image'] }[] = [] + const recordEmbed = record.embed + let recordImages: AppBskyEmbedImages.Image[] = [] + if (recordEmbed?.$type === 'app.bsky.embed.images') { + recordImages = (recordEmbed as AppBskyEmbedImages.Main).images + } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { + const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media + if (media.$type === 'app.bsky.embed.images') { + recordImages = (media as AppBskyEmbedImages.Main).images + } + } + if (post.embed?.$type === 'app.bsky.embed.images#view') { const embed = post.embed as AppBskyEmbedImages.View - imageUris = embed.images.map(img => ({ + imageUris = embed.images.map((img, i) => ({ uri: img.fullsize, width: img.aspectRatio?.width ?? 1000, height: img.aspectRatio?.height ?? 1000, altText: img.alt, + blobRef: recordImages[i]?.image, })) } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { const embed = post.embed as AppBskyEmbedRecordWithMedia.View if (embed.media.$type === 'app.bsky.embed.images#view') { const images = embed.media as AppBskyEmbedImages.View - imageUris = images.images.map(img => ({ + imageUris = images.images.map((img, i) => ({ uri: img.fullsize, width: img.aspectRatio?.width ?? 1000, height: img.aspectRatio?.height ?? 1000, altText: img.alt, + blobRef: recordImages[i]?.image, })) } } @@ -297,9 +312,47 @@ let PostMenuItems = ({ } } + let videoUri: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string} | undefined + let recordVideo: AppBskyEmbedVideo.Main | undefined + + if (recordEmbed?.$type === 'app.bsky.embed.video') { + recordVideo = recordEmbed as AppBskyEmbedVideo.Main + } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { + const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media + if (media.$type === 'app.bsky.embed.video') { + recordVideo = media as AppBskyEmbedVideo.Main + } + } + + if (post.embed?.$type === 'app.bsky.embed.video#view') { + const embed = post.embed as AppBskyEmbedVideo.View + if (recordVideo) { + videoUri = { + uri: embed.playlist || '', + width: embed.aspectRatio?.width ?? 1000, + height: embed.aspectRatio?.height ?? 1000, + blobRef: recordVideo.video, + altText: embed.alt || '', + } + } + } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { + const embed = post.embed as AppBskyEmbedRecordWithMedia.View + if (embed.media.$type === 'app.bsky.embed.video#view' && recordVideo) { + const video = embed.media as AppBskyEmbedVideo.View + videoUri = { + uri: video.playlist || '', + width: video.aspectRatio?.width ?? 1000, + height: video.aspectRatio?.height ?? 1000, + blobRef: recordVideo.video, + altText: video.alt || '', + } + } + } + openComposer({ text: record.text, imageUris, + videoUri, onPost: () => { onDeletePost() }, @@ -606,7 +659,7 @@ let PostMenuItems = ({ control={redraftPromptControl} title={_(msg`Redraft this skeet?`)} description={_( - msg`This will delete the original skeet and open the composer with its content. (WARNING: DOESN'T WORK ON SKEETS WITH MEDIA ALREADY ATTACHED. Probably no threads support either.)`, + msg`This will delete the original skeet and open the composer with its content.`, )} onConfirm={onConfirmRedraft} confirmButtonCta={_(msg`Redraft`)} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 842f30eaa..2eda1a54e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -324,6 +324,17 @@ async function resolveMedia( onStateChange?.(t`Uploading images...`) const images: AppBskyEmbedImages.Image[] = await Promise.all( imagesDraft.map(async (image, i) => { + if (image.blobRef) { + logger.debug(`Reusing existing blob for image #${i}`) + return { + image: image.blobRef, + alt: image.alt, + aspectRatio: { + width: image.source.width, + height: image.source.height, + }, + } + } logger.debug(`Compressing image #${i}`) const {path, width, height, mime} = await compressImage(image) logger.debug(`Uploading image #${i}`) @@ -356,9 +367,14 @@ async function resolveMedia( }), ) - // lexicon numbers must be floats - const width = Math.round(videoDraft.asset.width) - const height = Math.round(videoDraft.asset.height) + const width = Math.round( + videoDraft.asset?.width || + ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.width : 1000) + ) + const height = Math.round( + videoDraft.asset?.height || + ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.height : 1000) + ) // aspect ratio values must be >0 - better to leave as unset otherwise // posting will fail if aspect ratio is set to 0 @@ -366,7 +382,7 @@ async function resolveMedia( if (!aspectRatio) { logger.error( - `Invalid aspect ratio - got { width: ${videoDraft.asset.width}, height: ${videoDraft.asset.height} }`, + `Invalid aspect ratio - got { width: ${width}, height: ${height} }`, ) } diff --git a/src/state/gallery.ts b/src/state/gallery.ts index 2370df27d..d9a210509 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -1,3 +1,4 @@ +import {type BlobRef} from '@atproto/api' import { cacheDirectory, deleteAsync, @@ -37,6 +38,7 @@ export type ImageSource = ImageMeta & { type ComposerImageBase = { alt: string source: ImageSource + blobRef?: BlobRef } type ComposerImageWithoutTransformation = ComposerImageBase & { transformed?: undefined @@ -81,12 +83,13 @@ export type InitialImage = { width: number height: number altText?: string + blobRef?: BlobRef } export function createInitialImages( uris: InitialImage[] = [], ): ComposerImageWithoutTransformation[] { - return uris.map(({uri, width, height, altText = ''}) => { + return uris.map(({uri, width, height, altText = '', blobRef}) => { return { alt: altText, source: { @@ -96,6 +99,7 @@ export function createInitialImages( height: height, mime: 'image/jpeg', }, + blobRef, } }) } @@ -197,7 +201,6 @@ export function resetImageManipulation( export async function compressImage(img: ComposerImage): Promise { const source = img.transformed || img.source - const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) let minQualityPercentage = 0 diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index 1d0973add..e62f01447 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -3,6 +3,7 @@ import { type AppBskyActorDefs, type AppBskyFeedDefs, type AppBskyUnspeccedGetPostThreadV2, + type BlobRef, type ModerationDecision, } from '@atproto/api' import {msg} from '@lingui/macro' @@ -41,8 +42,8 @@ export interface ComposerOpts { mention?: string // handle of user to mention openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void text?: string - imageUris?: {uri: string; width: number; height: number; altText?: string}[] - videoUri?: {uri: string; width: number; height: number} + imageUris?: {uri: string; width: number; height: number; altText?: string; blobRef?: BlobRef}[] + videoUri?: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string} } type StateContext = ComposerOpts | undefined diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 69f5d935c..4f056706e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -119,6 +119,7 @@ import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' +import {VideoEmbedRedraft} from '#/view/com/composer/videos/VideoEmbedRedraft' import {Text} from '#/view/com/util/text/Text' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme, web} from '#/alf' @@ -126,6 +127,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {Play_Stroke2_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' import * as Prompt from '#/components/Prompt' @@ -238,14 +240,14 @@ export const ComposePost = ({ const [composerState, composerDispatch] = useReducer( composerReducer, - { + createComposerState({ initImageUris, initQuoteUri: initQuote?.uri, initText, initMention, initInteractionSettings: preferences?.postInteractionSettings, - }, - createComposerState, + initVideoUri, + }), ) const thread = composerState.thread @@ -297,7 +299,7 @@ export const ComposePost = ({ ) const onInitVideo = useNonReactiveCallback(() => { - if (initVideoUri) { + if (initVideoUri && !initVideoUri.blobRef) { selectVideo(activePost.id, initVideoUri) } }) @@ -1172,6 +1174,7 @@ function ComposerEmbeds({ canRemoveQuote: boolean isActivePost: boolean }) { + const theme = useTheme() const video = embed.media?.type === 'video' ? embed.media.video : null return ( <> @@ -1226,6 +1229,16 @@ function ComposerEmbeds({ clear={clearVideo} /> ) : null)} + {!video.asset && video.status === 'done' && 'playlistUri' in video && ( + + + + )} @@ -1239,7 +1252,7 @@ function ComposerEmbeds({ }) } captions={video.captions} - setCaptions={updater => { + setCaptions={(updater: (captions: any[]) => any[]) => { dispatch({ type: 'embed_update_video', videoAction: { diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index f5e0e87ae..dfe132d57 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -18,7 +18,10 @@ import { postUriToRelativePath, toBskyAppUrl, } from '#/lib/strings/url-helpers' -import {type ComposerImage, createInitialImages} from '#/state/gallery' +import { + type ComposerImage, + createInitialImages, +} from '#/state/gallery' import {createPostgateRecord} from '#/state/queries/postgate/util' import {type Gif} from '#/state/queries/tenor' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' @@ -30,6 +33,8 @@ import { } from '#/view/com/composer/text-input/text-input-util' import { createVideoState, + createRedraftVideoState, + type RedraftState, type VideoAction, videoReducer, type VideoState, @@ -491,6 +496,7 @@ export function createComposerState({ initImageUris, initQuoteUri, initInteractionSettings, + initVideoUri, }: { initText: string | undefined initMention: string | undefined @@ -499,13 +505,25 @@ export function createComposerState({ initInteractionSettings: | BskyPreferences['postInteractionSettings'] | undefined + initVideoUri?: ComposerOpts['videoUri'] }): ComposerState { - let media: ImagesMedia | undefined + let media: ImagesMedia | VideoMedia | undefined if (initImageUris?.length) { media = { type: 'images', images: createInitialImages(initImageUris), } + } else if (initVideoUri?.blobRef) { + media = { + type: 'video', + video: createRedraftVideoState({ + blobRef: initVideoUri.blobRef, + width: initVideoUri.width, + height: initVideoUri.height, + altText: initVideoUri.altText || '', + playlistUri: initVideoUri.uri, + }), + } } let quote: Link | undefined if (initQuoteUri) { diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 011bf42c6..ea6e8bfaa 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -130,12 +130,27 @@ type DoneState = { captions: CaptionsTrack[] } +export type RedraftState = { + status: 'done' + progress: 100 + abortController: AbortController + asset: null + video?: undefined + jobId?: undefined + pendingPublish: {blobRef: BlobRef} + altText: string + captions: CaptionsTrack[] + redraftDimensions: {width: number; height: number} + playlistUri: string +} + export type VideoState = | ErrorState | CompressingState | UploadingState | ProcessingState | DoneState + | RedraftState export function createVideoState( asset: ImagePickerAsset, @@ -151,6 +166,27 @@ export function createVideoState( } } +export function createRedraftVideoState(opts: { + blobRef: BlobRef + width: number + height: number + altText?: string + playlistUri: string +}): RedraftState { + const noopController = new AbortController() + return { + status: 'done', + progress: 100, + abortController: noopController, + asset: null, + pendingPublish: {blobRef: opts.blobRef}, + altText: opts.altText || '', + captions: [], + redraftDimensions: {width: opts.width, height: opts.height}, + playlistUri: opts.playlistUri, + } +} + export function videoReducer( state: VideoState, action: VideoAction, diff --git a/src/view/com/composer/videos/VideoEmbedRedraft.tsx b/src/view/com/composer/videos/VideoEmbedRedraft.tsx new file mode 100644 index 000000000..5565985d0 --- /dev/null +++ b/src/view/com/composer/videos/VideoEmbedRedraft.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import {Platform, View} from 'react-native' +import {type BlobRef} from '@atproto/api' +import {BlueskyVideoView} from '@haileyok/bluesky-video' + +import {atoms as a} from '#/alf' +import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn' +import {VideoEmbedInnerWeb} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' + +interface Props { + blobRef: BlobRef + playlistUri: string + aspectRatio: {width: number; height: number} + onRemove: () => void +} + +export function VideoEmbedRedraft({blobRef, playlistUri, aspectRatio, onRemove}: Props) { + const cidString = blobRef.ref.toString() + const aspectRatioValue = aspectRatio.width / aspectRatio.height || 16 / 9 + const thumbnailUrl = playlistUri.replace('playlist.m3u8', 'thumbnail.jpg') + + const mockEmbed = { + $type: 'app.bsky.embed.video#view' as const, + video: blobRef, + playlist: playlistUri, + thumbnail: thumbnailUrl, + aspectRatio, + alt: '', + captions: [], + cid: cidString, + } + + return ( + + {Platform.OS === 'web' ? ( + {}} + onScreen={true} + lastKnownTime={{current: undefined}} + /> + ) : ( + + )} + + + ) +} diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index a27e89168..4f1baee59 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -110,6 +110,7 @@ function Inner({state}: {state: ComposerOpts}) { openEmojiPicker={onOpenPicker} text={state.text} imageUris={state.imageUris} + videoUri={state.videoUri} /> -- 2.50.1 (Apple Git-155)