Remove multipart upload and 10-minute video feature gates

Ship both video upload features unconditionally:

- Multipart upload is now always attempted. The MultipartFallbackError
  path to the legacy single-request upload is unchanged, so a video
  service without multipart support still works. This makes the plain
  'legacy' upload transport unreachable, so drop it from
  VideoUploadTransport and default the telemetry value to 'multipart'.
- The 10-minute duration cap now applies to everyone.
  VIDEO_10_MINUTE_MAX_DURATION_MS is folded into VIDEO_MAX_DURATION_MS,
  and the composer's duration error message no longer branches on the
  gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mn8DoxP5wQk6NbPmapWs9A
This commit is contained in:
Claude
2026-08-21 19:45:43 +00:00
committed by Samuel Newman
parent 56bff45546
commit 4bb0e1971a
9 changed files with 42 additions and 100 deletions
-16
View File
@@ -77,22 +77,6 @@ export function getFeatures() {
export function getFeatureDescription(feature: Features, i18n: I18n) { export function getFeatureDescription(feature: Features, i18n: I18n) {
switch (feature) { switch (feature) {
case Features.VideoAllow10MinuteEnable:
return {
key: feature,
name: i18n._(
msg({
message: 'Longer videos',
comment: 'Name for a feature flag (longer videos)',
}),
),
description: i18n._(
msg({
message: 'Enable 10-minute video uploads.',
comment: 'Description of a feature flag (10-minute video uploads)',
}),
),
}
case Features.CanonicalPostNumberingEnable: case Features.CanonicalPostNumberingEnable:
return { return {
key: feature, key: feature,
-2
View File
@@ -19,8 +19,6 @@ export enum Features {
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable', PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable',
VideoAllow10MinuteEnable = 'video:allow-10-minute:enable',
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable', FollowSortEnable = 'follow_sort:enable',
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable', OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
+1 -2
View File
@@ -194,8 +194,7 @@ export const MAX_LABELERS = 20
export const VIDEO_SERVICE = 'https://video.bsky.app' export const VIDEO_SERVICE = 'https://video.bsky.app'
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app' export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds export const VIDEO_MAX_DURATION_MS = 10 * 60 * 1000 // 10 minutes in milliseconds
export const VIDEO_10_MINUTE_MAX_DURATION_MS = 10 * 60 * 1000
/** /**
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses * Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
* ISO megabytes. * ISO megabytes.
+1 -1
View File
@@ -72,7 +72,7 @@ export function createVideoTelemetry({
let phaseStartedAt = startedAt let phaseStartedAt = startedAt
let jobId: string | undefined let jobId: string | undefined
let uploadBytes: number | undefined let uploadBytes: number | undefined
let uploadTransport: VideoUploadTransport = 'legacy' let uploadTransport: VideoUploadTransport = 'multipart'
let txnEnded = false let txnEnded = false
let abortBound = true let abortBound = true
+1 -1
View File
@@ -5,7 +5,7 @@
export type VideoCompressSkipReason = export type VideoCompressSkipReason =
'gif' | 'below-byte-threshold' | 'no-webcodecs' | 'compress-error-fallback' 'gif' | 'below-byte-threshold' | 'no-webcodecs' | 'compress-error-fallback'
export type VideoUploadTransport = 'multipart' | 'legacy' | 'legacy-fallback' export type VideoUploadTransport = 'multipart' | 'legacy-fallback'
export type CompressedVideo = { export type CompressedVideo = {
uri: string uri: string
+13 -18
View File
@@ -10,7 +10,6 @@ import {
type CompressedVideo, type CompressedVideo,
type VideoUploadTransport, type VideoUploadTransport,
} from '#/lib/media/video/types' } from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
import {type app} from '#/lexicons' import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload' import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import { import {
@@ -45,23 +44,19 @@ export async function uploadVideo({
} }
await getVideoUploadLimits(client, i18n) await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) { try {
try { return await uploadVideoMultipart({
return await uploadVideoMultipart({ video,
video, client,
client, dispatchUrl,
dispatchUrl, setProgress,
setProgress, signal,
signal, onStarted: () => onTransport?.('multipart'),
onStarted: () => onTransport?.('multipart'), })
}) } catch (err) {
} catch (err) { if (!(err instanceof MultipartFallbackError)) throw err
if (!(err instanceof MultipartFallbackError)) throw err onTransport?.('legacy-fallback')
onTransport?.('legacy-fallback') setProgress(0)
setProgress(0)
}
} else {
onTransport?.('legacy')
} }
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
+13 -18
View File
@@ -9,7 +9,6 @@ import {
type CompressedVideo, type CompressedVideo,
type VideoUploadTransport, type VideoUploadTransport,
} from '#/lib/media/video/types' } from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
import {type app} from '#/lexicons' import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload' import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import { import {
@@ -44,23 +43,19 @@ export async function uploadVideo({
} }
await getVideoUploadLimits(client, i18n) await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) { try {
try { return await uploadVideoMultipart({
return await uploadVideoMultipart({ video,
video, client,
client, dispatchUrl,
dispatchUrl, setProgress,
setProgress, signal,
signal, onStarted: () => onTransport?.('multipart'),
onStarted: () => onTransport?.('multipart'), })
}) } catch (err) {
} catch (err) { if (!(err instanceof MultipartFallbackError)) throw err
if (!(err instanceof MultipartFallbackError)) throw err onTransport?.('legacy-fallback')
onTransport?.('legacy-fallback') setProgress(0)
setProgress(0)
}
} else {
onTransport?.('legacy')
} }
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
+4 -19
View File
@@ -62,7 +62,6 @@ import {
MAX_GRAPHEME_LENGTH, MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES, SUPPORTED_MIME_TYPES,
type SupportedMimeTypes, type SupportedMimeTypes,
VIDEO_10_MINUTE_MAX_DURATION_MS,
VIDEO_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS,
} from '#/lib/constants' } from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -270,12 +269,6 @@ export const ComposePost = ({
const {currentAccount} = useSession() const {currentAccount} = useSession()
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const allow10MinuteVideos = ax.features.enabled(
ax.features.VideoAllow10MinuteEnable,
)
const videoMaxDurationMs = allow10MinuteVideos
? VIDEO_10_MINUTE_MAX_DURATION_MS
: VIDEO_MAX_DURATION_MS
const client = useAppviewClient() const client = useAppviewClient()
const chatClient = useChatClient() const chatClient = useChatClient()
const pdsClient = usePdsClient() const pdsClient = usePdsClient()
@@ -451,7 +444,7 @@ export const ComposePost = ({
* Fail early on duration so we don't spend time compressing a video the * Fail early on duration so we don't spend time compressing a video the
* server would reject anyway. * server would reject anyway.
*/ */
if (asset.duration != null && asset.duration > videoMaxDurationMs) { if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
composerDispatch({ composerDispatch({
type: 'update_post', type: 'update_post',
postId: postId, postId: postId,
@@ -459,9 +452,7 @@ export const ComposePost = ({
type: 'embed_update_video', type: 'embed_update_video',
videoAction: { videoAction: {
type: 'to_error', type: 'to_error',
error: allow10MinuteVideos error: l`Videos must be 10 minutes or less.`,
? l`Videos must be 10 minutes or less.`
: l`Videos must be less than 3 minutes long.`,
signal: abortController.signal, signal: abortController.signal,
}, },
}, },
@@ -497,8 +488,6 @@ export const ComposePost = ({
currentDid, currentDid,
composerDispatch, composerDispatch,
ax.metric, ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
], ],
) )
@@ -596,7 +585,7 @@ export const ComposePost = ({
}, },
}) })
if (asset.duration != null && asset.duration > videoMaxDurationMs) { if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
composerDispatch({ composerDispatch({
type: 'update_post', type: 'update_post',
postId, postId,
@@ -604,9 +593,7 @@ export const ComposePost = ({
type: 'embed_update_video', type: 'embed_update_video',
videoAction: { videoAction: {
type: 'to_error', type: 'to_error',
error: allow10MinuteVideos error: l`Videos must be 10 minutes or less.`,
? l`Videos must be 10 minutes or less.`
: l`Videos must be less than 3 minutes long.`,
signal: abortController.signal, signal: abortController.signal,
}, },
}, },
@@ -687,8 +674,6 @@ export const ComposePost = ({
currentDid, currentDid,
composerDispatch, composerDispatch,
ax.metric, ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
], ],
) )
+9 -23
View File
@@ -6,7 +6,6 @@ import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import { import {
VIDEO_10_MINUTE_MAX_DURATION_MS,
VIDEO_MAX_DURATION_MS, VIDEO_MAX_DURATION_MS,
VIDEO_MAX_SIZE, VIDEO_MAX_SIZE,
VIDEO_MAX_SIZE_MB, VIDEO_MAX_SIZE_MB,
@@ -23,7 +22,6 @@ import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner2_Rounded as ImageIcon} from '#/components/icons/Image' import {Image_Stroke2_Corner2_Rounded as ImageIcon} from '#/components/icons/Image'
import * as toast from '#/components/Toast' import * as toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif' import {isAnimatedGif} from './videos/isAnimatedGif'
import {hasWebCodecs} from './videos/metadata' import {hasWebCodecs} from './videos/metadata'
@@ -400,13 +398,6 @@ export function SelectMediaButton({
autoOpen, autoOpen,
}: SelectMediaButtonProps) { }: SelectMediaButtonProps) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const allow10MinuteVideos = ax.features.enabled(
ax.features.VideoAllow10MinuteEnable,
)
const videoMaxDurationMs = allow10MinuteVideos
? VIDEO_10_MINUTE_MAX_DURATION_MS
: VIDEO_MAX_DURATION_MS
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper() const sheetWrapper = useSheetWrapper()
@@ -426,7 +417,7 @@ export function SelectMediaButton({
} = await processImagePickerAssets(rawAssets, { } = await processImagePickerAssets(rawAssets, {
selectionCountRemaining, selectionCountRemaining,
allowedAssetTypes, allowedAssetTypes,
videoMaxDurationMs, videoMaxDurationMs: VIDEO_MAX_DURATION_MS,
}) })
/* /*
@@ -451,9 +442,9 @@ export function SelectMediaButton({
[SelectedAssetError.MaxVideos]: _( [SelectedAssetError.MaxVideos]: _(
msg`You can only select one video at a time.`, msg`You can only select one video at a time.`,
), ),
[SelectedAssetError.VideoTooLong]: allow10MinuteVideos [SelectedAssetError.VideoTooLong]: _(
? _(msg`Videos must be 10 minutes or less.`) msg`Videos must be 10 minutes or less.`,
: _(msg`Videos must be less than 3 minutes long.`), ),
[SelectedAssetError.MaxGIFs]: _( [SelectedAssetError.MaxGIFs]: _(
msg`You can only select one GIF at a time.`, msg`You can only select one GIF at a time.`,
), ),
@@ -473,14 +464,7 @@ export function SelectMediaButton({
errors, errors,
}) })
}, },
[ [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes],
_,
onSelectAssets,
selectionCountRemaining,
allowedAssetTypes,
videoMaxDurationMs,
allow10MinuteVideos,
],
) )
const onPressSelectMedia = useCallback(async () => { const onPressSelectMedia = useCallback(async () => {
@@ -503,7 +487,10 @@ export function SelectMediaButton({
} }
const {assets, canceled} = await sheetWrapper( const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining, videoMaxDurationMs}), openUnifiedPicker({
selectionCountRemaining,
videoMaxDurationMs: VIDEO_MAX_DURATION_MS,
}),
) )
if (canceled) return if (canceled) return
@@ -516,7 +503,6 @@ export function SelectMediaButton({
sheetWrapper, sheetWrapper,
processSelectedAssets, processSelectedAssets,
selectionCountRemaining, selectionCountRemaining,
videoMaxDurationMs,
]) ])
useEffect(() => { useEffect(() => {