restore videos
This commit is contained in:
@@ -130,7 +130,11 @@ import {Text} from '#/components/Typography'
|
|||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||||
import {draftToComposerPosts, extractLocalRefs} from './drafts/state/api'
|
import {
|
||||||
|
draftToComposerPosts,
|
||||||
|
extractLocalRefs,
|
||||||
|
type RestoredVideo,
|
||||||
|
} from './drafts/state/api'
|
||||||
import {
|
import {
|
||||||
loadDraft,
|
loadDraft,
|
||||||
useCleanupPublishedDraftMutation,
|
useCleanupPublishedDraftMutation,
|
||||||
@@ -329,6 +333,92 @@ export const ComposePost = ({
|
|||||||
[composerDispatch],
|
[composerDispatch],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const restoreVideo = React.useCallback(
|
||||||
|
async (postId: string, videoInfo: RestoredVideo) => {
|
||||||
|
try {
|
||||||
|
logger.debug('restoring video from draft', {
|
||||||
|
postId,
|
||||||
|
videoUri: videoInfo.uri,
|
||||||
|
altText: videoInfo.altText,
|
||||||
|
})
|
||||||
|
|
||||||
|
let asset: ImagePickerAsset
|
||||||
|
|
||||||
|
if (IS_WEB) {
|
||||||
|
// Web: Convert blob URL to a File, then get video metadata (returns data URL)
|
||||||
|
const response = await fetch(videoInfo.uri)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const file = new File([blob], 'restored-video', {
|
||||||
|
type: videoInfo.mimeType,
|
||||||
|
})
|
||||||
|
asset = await getVideoMetadata(file)
|
||||||
|
} else {
|
||||||
|
// Native: Use file URI directly with minimal asset properties
|
||||||
|
// The native compressVideo only needs uri and mimeType
|
||||||
|
asset = {
|
||||||
|
uri: videoInfo.uri,
|
||||||
|
mimeType: videoInfo.mimeType,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start video processing using existing flow
|
||||||
|
const abortController = new AbortController()
|
||||||
|
composerDispatch({
|
||||||
|
type: 'update_post',
|
||||||
|
postId,
|
||||||
|
postAction: {
|
||||||
|
type: 'embed_add_video',
|
||||||
|
asset,
|
||||||
|
abortController,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Restore alt text immediately
|
||||||
|
if (videoInfo.altText) {
|
||||||
|
composerDispatch({
|
||||||
|
type: 'update_post',
|
||||||
|
postId,
|
||||||
|
postAction: {
|
||||||
|
type: 'embed_update_video',
|
||||||
|
videoAction: {
|
||||||
|
type: 'update_alt_text',
|
||||||
|
altText: videoInfo.altText,
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start video compression and upload
|
||||||
|
processVideo(
|
||||||
|
asset,
|
||||||
|
videoAction => {
|
||||||
|
composerDispatch({
|
||||||
|
type: 'update_post',
|
||||||
|
postId,
|
||||||
|
postAction: {
|
||||||
|
type: 'embed_update_video',
|
||||||
|
videoAction,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
agent,
|
||||||
|
currentDid,
|
||||||
|
abortController.signal,
|
||||||
|
_,
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to restore video from draft', {
|
||||||
|
postId,
|
||||||
|
error: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_, agent, currentDid, composerDispatch],
|
||||||
|
)
|
||||||
|
|
||||||
const handleSelectDraft = React.useCallback(
|
const handleSelectDraft = React.useCallback(
|
||||||
async (draftSummary: DraftSummary) => {
|
async (draftSummary: DraftSummary) => {
|
||||||
logger.debug('loading draft for editing', {
|
logger.debug('loading draft for editing', {
|
||||||
@@ -347,8 +437,11 @@ export const ComposePost = ({
|
|||||||
originalLocalRefCount: originalLocalRefs.size,
|
originalLocalRefCount: originalLocalRefs.size,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Convert server draft to composer posts
|
// Convert server draft to composer posts (videos returned separately)
|
||||||
const posts = draftToComposerPosts(draftSummary.draft, loadedMedia)
|
const {posts, restoredVideos} = draftToComposerPosts(
|
||||||
|
draftSummary.draft,
|
||||||
|
loadedMedia,
|
||||||
|
)
|
||||||
|
|
||||||
// Dispatch restore action (this also sets draftId in state)
|
// Dispatch restore action (this also sets draftId in state)
|
||||||
composerDispatch({
|
composerDispatch({
|
||||||
@@ -360,8 +453,15 @@ export const ComposePost = ({
|
|||||||
loadedMedia,
|
loadedMedia,
|
||||||
originalLocalRefs,
|
originalLocalRefs,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initiate video processing for any restored videos
|
||||||
|
// This is async but we don't await - videos process in the background
|
||||||
|
for (const [postIndex, videoInfo] of restoredVideos) {
|
||||||
|
const postId = posts[postIndex].id
|
||||||
|
restoreVideo(postId, videoInfo)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[composerDispatch],
|
[composerDispatch, restoreVideo],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|||||||
@@ -17,6 +17,31 @@ import {type DraftPostDisplay, type DraftSummary} from './schema'
|
|||||||
|
|
||||||
const TENOR_HOSTNAME = 'media.tenor.com'
|
const TENOR_HOSTNAME = 'media.tenor.com'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export type RestoredVideo = {
|
||||||
|
uri: string
|
||||||
|
altText: string
|
||||||
|
mimeType: string
|
||||||
|
localRefPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse mime type from video localRefPath.
|
||||||
|
* Format: `video:${mimeType}:${nanoid()}` (new) or `video:${nanoid()}` (legacy)
|
||||||
|
*/
|
||||||
|
function parseVideoMimeType(localRefPath: string): string {
|
||||||
|
const parts = localRefPath.split(':')
|
||||||
|
// New format: video:video/mp4:abc123 -> parts[1] is mime type
|
||||||
|
// Legacy format: video:abc123 -> no mime type, default to video/mp4
|
||||||
|
if (parts.length >= 3 && parts[1].includes('/')) {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
return 'video/mp4' // Default for legacy drafts
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -165,6 +190,7 @@ 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()}`
|
||||||
*/
|
*/
|
||||||
function serializeVideo(
|
function serializeVideo(
|
||||||
videoState: VideoState,
|
videoState: VideoState,
|
||||||
@@ -175,7 +201,9 @@ function serializeVideo(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const localRefPath = `video:${nanoid()}`
|
// Encode mime type in the path for restoration
|
||||||
|
const mimeType = videoState.video.mimeType || 'video/mp4'
|
||||||
|
const localRefPath = `video:${mimeType}:${nanoid()}`
|
||||||
localRefPaths.set(localRefPath, videoState.video.uri)
|
localRefPaths.set(localRefPath, videoState.video.uri)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -345,13 +373,19 @@ function parseGifFromUrl(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert server Draft back to composer-compatible format for restoration.
|
* Convert server Draft back to composer-compatible format for restoration.
|
||||||
* Returns partial state that can be merged with initial composer state.
|
* Returns posts and a map of videos that need to be restored by re-processing.
|
||||||
|
*
|
||||||
|
* Videos cannot be restored synchronously like images because they need to go through
|
||||||
|
* the compression and upload pipeline. The caller should handle the restoredVideos
|
||||||
|
* by initiating video processing for each entry.
|
||||||
*/
|
*/
|
||||||
export function draftToComposerPosts(
|
export function draftToComposerPosts(
|
||||||
draft: AppBskyDraftDefs.Draft,
|
draft: AppBskyDraftDefs.Draft,
|
||||||
loadedMedia: Map<string, string>,
|
loadedMedia: Map<string, string>,
|
||||||
): PostDraft[] {
|
): {posts: PostDraft[]; restoredVideos: Map<number, RestoredVideo>} {
|
||||||
return draft.posts.map((post, index) => {
|
const restoredVideos = new Map<number, RestoredVideo>()
|
||||||
|
|
||||||
|
const posts = draft.posts.map((post, index) => {
|
||||||
const richtext = new RichText({text: post.text || ''})
|
const richtext = new RichText({text: post.text || ''})
|
||||||
richtext.detectFacetsWithoutResolution()
|
richtext.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
@@ -428,6 +462,27 @@ export function draftToComposerPosts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect video for restoration (processed async by caller)
|
||||||
|
if (post.embedVideos && post.embedVideos.length > 0) {
|
||||||
|
const vid = post.embedVideos[0]
|
||||||
|
const videoUri = loadedMedia.get(vid.localRef.path)
|
||||||
|
if (videoUri) {
|
||||||
|
const mimeType = parseVideoMimeType(vid.localRef.path)
|
||||||
|
logger.debug('found video to restore', {
|
||||||
|
localRefPath: vid.localRef.path,
|
||||||
|
videoUri,
|
||||||
|
altText: vid.alt,
|
||||||
|
mimeType,
|
||||||
|
})
|
||||||
|
restoredVideos.set(index, {
|
||||||
|
uri: videoUri,
|
||||||
|
altText: vid.alt || '',
|
||||||
|
mimeType,
|
||||||
|
localRefPath: vid.localRef.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Restore quote embed
|
// Restore quote embed
|
||||||
if (post.embedRecords && post.embedRecords.length > 0) {
|
if (post.embedRecords && post.embedRecords.length > 0) {
|
||||||
const record = post.embedRecords[0]
|
const record = post.embedRecords[0]
|
||||||
@@ -461,6 +516,8 @@ export function draftToComposerPosts(
|
|||||||
embed,
|
embed,
|
||||||
} as PostDraft
|
} as PostDraft
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return {posts, restoredVideos}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user