save/restore captions

This commit is contained in:
Samuel Newman
2026-01-27 21:52:02 +02:00
parent e9ff3ae941
commit c00d92ede8
3 changed files with 55 additions and 13 deletions
+23
View File
@@ -340,6 +340,7 @@ export const ComposePost = ({
postId, postId,
videoUri: videoInfo.uri, videoUri: videoInfo.uri,
altText: videoInfo.altText, altText: videoInfo.altText,
captionCount: videoInfo.captions.length,
}) })
let asset: ImagePickerAsset let asset: ImagePickerAsset
@@ -391,6 +392,28 @@ export const ComposePost = ({
}) })
} }
// Restore captions (web only - captions use File objects)
if (IS_WEB && videoInfo.captions.length > 0) {
const captionTracks = videoInfo.captions.map(c => ({
lang: c.lang,
file: new File([c.content], `caption-${c.lang}.vtt`, {
type: 'text/vtt',
}),
}))
composerDispatch({
type: 'update_post',
postId,
postAction: {
type: 'embed_update_video',
videoAction: {
type: 'update_captions',
updater: () => captionTracks,
signal: abortController.signal,
},
},
})
}
// Start video compression and upload // Start video compression and upload
processVideo( processVideo(
asset, asset,
+31 -12
View File
@@ -19,13 +19,14 @@ const TENOR_HOSTNAME = 'media.tenor.com'
/** /**
* Video data from a draft that needs to be restored by re-processing. * Video data from a draft that needs to be restored by re-processing.
* Contains the local file URI, alt text, and mime type to restore. * Contains the local file URI, alt text, mime type, and captions to restore.
*/ */
export type RestoredVideo = { export type RestoredVideo = {
uri: string uri: string
altText: string altText: string
mimeType: string mimeType: string
localRefPath: string localRefPath: string
captions: Array<{lang: string; content: string}>
} }
/** /**
@@ -46,15 +47,17 @@ function parseVideoMimeType(localRefPath: string): string {
* Convert ComposerState to server Draft format for saving. * Convert ComposerState to server Draft format for saving.
* Returns both the draft and a map of localRef paths to their source paths. * Returns both the draft and a map of localRef paths to their source paths.
*/ */
export function composerStateToDraft(state: ComposerState): { export async function composerStateToDraft(state: ComposerState): Promise<{
draft: AppBskyDraftDefs.Draft draft: AppBskyDraftDefs.Draft
localRefPaths: Map<string, string> localRefPaths: Map<string, string>
} { }> {
const localRefPaths = new Map<string, string>() const localRefPaths = new Map<string, string>()
const posts: AppBskyDraftDefs.DraftPost[] = state.thread.posts.map(post => { const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all(
return postDraftToServerPost(post, localRefPaths) state.thread.posts.map(post => {
}) return postDraftToServerPost(post, localRefPaths)
}),
)
// Convert threadgate settings to server format // Convert threadgate settings to server format
const threadgateAllow: AppBskyDraftDefs.Draft['threadgateAllow'] = [] const threadgateAllow: AppBskyDraftDefs.Draft['threadgateAllow'] = []
@@ -92,10 +95,10 @@ export function composerStateToDraft(state: ComposerState): {
/** /**
* Convert a single PostDraft to server DraftPost format. * Convert a single PostDraft to server DraftPost format.
*/ */
function postDraftToServerPost( async function postDraftToServerPost(
post: PostDraft, post: PostDraft,
localRefPaths: Map<string, string>, localRefPaths: Map<string, string>,
): AppBskyDraftDefs.DraftPost { ): Promise<AppBskyDraftDefs.DraftPost> {
const draftPost: AppBskyDraftDefs.DraftPost = { const draftPost: AppBskyDraftDefs.DraftPost = {
$type: 'app.bsky.draft.defs#draftPost', $type: 'app.bsky.draft.defs#draftPost',
text: post.richtext.text, text: post.richtext.text,
@@ -117,7 +120,7 @@ function postDraftToServerPost(
localRefPaths, localRefPaths,
) )
} else if (post.embed.media.type === 'video') { } else if (post.embed.media.type === 'video') {
const video = serializeVideo(post.embed.media.video, localRefPaths) const video = await serializeVideo(post.embed.media.video, localRefPaths)
if (video) { if (video) {
draftPost.embedVideos = [video] draftPost.embedVideos = [video]
} }
@@ -192,10 +195,10 @@ function serializeImages(
* Serialize video to server format with localRef path. * Serialize video to server format with localRef path.
* The localRef path encodes the mime type: `video:${mimeType}:${nanoid()}` * The localRef path encodes the mime type: `video:${mimeType}:${nanoid()}`
*/ */
function serializeVideo( async function serializeVideo(
videoState: VideoState, videoState: VideoState,
localRefPaths: Map<string, string>, localRefPaths: Map<string, string>,
): AppBskyDraftDefs.DraftEmbedVideo | undefined { ): Promise<AppBskyDraftDefs.DraftEmbedVideo | undefined> {
// Only save videos that have been compressed (have a video file) // Only save videos that have been compressed (have a video file)
if (!videoState.video) { if (!videoState.video) {
return undefined return undefined
@@ -206,6 +209,19 @@ function serializeVideo(
const localRefPath = `video:${mimeType}:${nanoid()}` const localRefPath = `video:${mimeType}:${nanoid()}`
localRefPaths.set(localRefPath, videoState.video.uri) localRefPaths.set(localRefPath, videoState.video.uri)
// Read caption file contents as text
const captions: AppBskyDraftDefs.DraftEmbedCaption[] = []
for (const caption of videoState.captions) {
if (caption.lang) {
const content = await caption.file.text()
captions.push({
$type: 'app.bsky.draft.defs#draftEmbedCaption',
lang: caption.lang,
content,
})
}
}
return { return {
$type: 'app.bsky.draft.defs#draftEmbedVideo', $type: 'app.bsky.draft.defs#draftEmbedVideo',
localRef: { localRef: {
@@ -213,7 +229,7 @@ function serializeVideo(
path: localRefPath, path: localRefPath,
}, },
alt: videoState.altText || undefined, alt: videoState.altText || undefined,
// TODO: Add captions if needed captions: captions.length > 0 ? captions : undefined,
} }
} }
@@ -473,12 +489,15 @@ export function draftToComposerPosts(
videoUri, videoUri,
altText: vid.alt, altText: vid.alt,
mimeType, mimeType,
captionCount: vid.captions?.length ?? 0,
}) })
restoredVideos.set(index, { restoredVideos.set(index, {
uri: videoUri, uri: videoUri,
altText: vid.alt || '', altText: vid.alt || '',
mimeType, mimeType,
localRefPath: vid.localRef.path, localRefPath: vid.localRef.path,
captions:
vid.captions?.map(c => ({lang: c.lang, content: c.content})) ?? [],
}) })
} }
} }
@@ -105,7 +105,7 @@ export function useSaveDraftMutation() {
originalLocalRefs: Set<string> | undefined originalLocalRefs: Set<string> | undefined
}> => { }> => {
// Convert composer state to server draft format // Convert composer state to server draft format
const {draft, localRefPaths} = composerStateToDraft(composerState) const {draft, localRefPaths} = await composerStateToDraft(composerState)
logger.debug('saving draft', { logger.debug('saving draft', {
existingDraftId, existingDraftId,