Fail video duration check before compression

Non-picker entries into the composer (share extension, web paste, draft
restore) skipped the duration check that SelectMediaButton runs, so a
too-long video would compress first and only get rejected afterward.

- Probe duration in selectVideo when missing (share-extension deeplinks
  ship only uri|width|height)
- Reject before processVideo when duration exceeds VIDEO_MAX_DURATION_MS,
  surfacing the error in the composer inline error state
- Apply the same gate to restoreVideo
- Normalize native getVideoMetadata duration to milliseconds to match
  expo-image-picker's convention (was seconds, harmless until now)
This commit is contained in:
vineyardbovines
2026-06-26 15:21:22 -04:00
parent 66250e45cc
commit 12bf7ba3c1
3 changed files with 79 additions and 14 deletions
-3
View File
@@ -1972,9 +1972,6 @@
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
}, },
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": { "@typescript-eslint/no-misused-promises": {
"count": 4 "count": 4
}, },
+74 -10
View File
@@ -70,6 +70,7 @@ import {
MAX_GRAPHEME_LENGTH, MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES, SUPPORTED_MIME_TYPES,
type SupportedMimeTypes, type SupportedMimeTypes,
VIDEO_MAX_DURATION_MS,
} from '#/lib/constants' } from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -386,7 +387,27 @@ export const ComposePost = ({
) )
const selectVideo = useCallback( const selectVideo = useCallback(
(postId: string, asset: ImagePickerAsset) => { async (postId: string, asset: ImagePickerAsset) => {
/*
* Share-extension deeplinks deliver a video URI without duration, so
* probe before we decide whether to compress. The picker and web paste
* paths already populate duration upstream.
*/
if (asset.duration == null && IS_NATIVE) {
try {
const probed = await getVideoMetadata(asset.uri)
asset = {
...asset,
mimeType: probed.mimeType ?? asset.mimeType,
width: probed.width ?? asset.width,
height: probed.height ?? asset.height,
duration: probed.duration,
}
} catch (e) {
logger.warn('selectVideo: duration probe failed', {safeMessage: e})
}
}
const abortController = new AbortController() const abortController = new AbortController()
const telemetry = createVideoTelemetry({ const telemetry = createVideoTelemetry({
asset, asset,
@@ -404,6 +425,27 @@ export const ComposePost = ({
telemetry, telemetry,
}, },
}) })
/*
* Fail early on duration so we don't spend time compressing a video the
* server would reject anyway.
*/
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
composerDispatch({
type: 'update_post',
postId: postId,
postAction: {
type: 'embed_update_video',
videoAction: {
type: 'to_error',
error: l`Videos must be less than 3 minutes long.`,
signal: abortController.signal,
},
},
})
return
}
void processVideo( void processVideo(
asset, asset,
videoAction => { videoAction => {
@@ -423,12 +465,12 @@ export const ComposePost = ({
telemetry, telemetry,
) )
}, },
[i18n, agent, currentDid, composerDispatch, ax.metric], [l, i18n, agent, currentDid, composerDispatch, ax.metric],
) )
const onInitVideo = useNonReactiveCallback(() => { const onInitVideo = useNonReactiveCallback(() => {
if (initVideoUri) { if (initVideoUri) {
selectVideo(activePost.id, initVideoUri) void selectVideo(activePost.id, initVideoUri)
} }
}) })
@@ -520,6 +562,22 @@ export const ComposePost = ({
}, },
}) })
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
composerDispatch({
type: 'update_post',
postId,
postAction: {
type: 'embed_update_video',
videoAction: {
type: 'to_error',
error: l`Videos must be less than 3 minutes long.`,
signal: abortController.signal,
},
},
})
return
}
// Restore alt text immediately // Restore alt text immediately
if (videoInfo.altText) { if (videoInfo.altText) {
composerDispatch({ composerDispatch({
@@ -584,7 +642,7 @@ export const ComposePost = ({
}) })
} }
}, },
[i18n, agent, currentDid, composerDispatch, ax.metric], [l, i18n, agent, currentDid, composerDispatch, ax.metric],
) )
const handleSelectDraft = useCallback( const handleSelectDraft = useCallback(
@@ -641,7 +699,7 @@ export const ComposePost = ({
// This is async but we don't await - videos process in the background // This is async but we don't await - videos process in the background
for (const [postIndex, videoInfo] of restoredVideos) { for (const [postIndex, videoInfo] of restoredVideos) {
const postId = posts[postIndex].id const postId = posts[postIndex].id
restoreVideo(postId, videoInfo) void restoreVideo(postId, videoInfo)
} }
}, },
[composerDispatch, restoreVideo, ax], [composerDispatch, restoreVideo, ax],
@@ -1527,7 +1585,10 @@ let ComposerPost = memo(function ComposerPost({
canRemovePost: boolean canRemovePost: boolean
canRemoveQuote: boolean canRemoveQuote: boolean
onClearVideo: (postId: string) => void onClearVideo: (postId: string) => void
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void onSelectVideo: (
postId: string,
asset: ImagePickerAsset,
) => void | Promise<void>
onError: (error: string) => void onError: (error: string) => void
onPublish: (richtext: RichText) => void onPublish: (richtext: RichText) => void
}) { }) {
@@ -1587,7 +1648,7 @@ let ComposerPost = memo(function ComposerPost({
const file = await fetch(uri) const file = await fetch(uri)
.then(res => res.blob()) .then(res => res.blob())
.then(blob => new File([blob], name, {type: mimeType})) .then(blob => new File([blob], name, {type: mimeType}))
onSelectVideo(post.id, await getVideoMetadata(file)) void onSelectVideo(post.id, await getVideoMetadata(file))
} else { } else {
const res = await pasteImage(uri) const res = await pasteImage(uri)
onImageAdd([res]) onImageAdd([res])
@@ -2049,7 +2110,10 @@ function ComposerFooter({
dispatch: (action: PostAction) => void dispatch: (action: PostAction) => void
showAddButton: boolean showAddButton: boolean
onError: (error: string) => void onError: (error: string) => void
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void onSelectVideo: (
postId: string,
asset: ImagePickerAsset,
) => void | Promise<void>
onAddPost: () => void onAddPost: () => void
currentLanguages: string[] currentLanguages: string[]
onSelectLanguage?: (language: string) => void onSelectLanguage?: (language: string) => void
@@ -2130,9 +2194,9 @@ function ComposerFooter({
onImageAdd(selectedImages) onImageAdd(selectedImages)
} else if (type === 'video') { } else if (type === 'video') {
onSelectVideo(post.id, assets[0]) void onSelectVideo(post.id, assets[0])
} else if (type === 'gif') { } else if (type === 'gif') {
onSelectVideo(post.id, assets[0]) void onSelectVideo(post.id, assets[0])
} }
} }
+5 -1
View File
@@ -16,7 +16,11 @@ export async function getVideoMetadata(
mimeType: extToMime(metadata.extension), mimeType: extToMime(metadata.extension),
width: metadata.width, width: metadata.width,
height: metadata.height, height: metadata.height,
duration: metadata.duration, /*
* react-native-compressor reports seconds; the rest of the app treats
* `ImagePickerAsset.duration` as milliseconds (matching expo-image-picker).
*/
duration: metadata.duration * 1000,
} }
} }