Remove multipart upload and 10-minute video feature gates (#11528)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-26 00:38:33 +03:00
committed by GitHub
parent 5118748cb1
commit a9c502c8cf
13 changed files with 40 additions and 356 deletions
-16
View File
@@ -77,22 +77,6 @@ export function getFeatures() {
export function getFeatureDescription(feature: Features, i18n: I18n) {
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:
return {
key: feature,
-2
View File
@@ -18,8 +18,6 @@ export enum Features {
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable',
VideoAllow10MinuteEnable = 'video:allow-10-minute:enable',
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable',
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
+1 -6
View File
@@ -5,10 +5,7 @@
import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {
type VideoCompressSkipReason,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
import {type NotificationType} from '#/state/queries/notifications/types'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -1473,7 +1470,6 @@ export type Events = {
bytes: number
elapsedMs: number
throughputBytesPerSec: number
transport: VideoUploadTransport
}
'video:upload:uploadFailed': {
uploadId: string
@@ -1481,7 +1477,6 @@ export type Events = {
bytes: number
errorClass: string
elapsedMs: number
transport: VideoUploadTransport
}
'video:upload:processingStarted': {
uploadId: string
+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_DID = 'did:web:video.bsky.app'
export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
export const VIDEO_10_MINUTE_MAX_DURATION_MS = 10 * 60 * 1000
export const VIDEO_MAX_DURATION_MS = 10 * 60 * 1000 // 10 minutes in milliseconds
/**
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
* ISO megabytes.
-7
View File
@@ -5,13 +5,6 @@ export class VideoTooLargeError extends Error {
}
}
export class ServerError extends Error {
constructor(message: string) {
super(message)
this.name = 'ServerError'
}
}
export class UploadLimitError extends Error {
constructor(message: string) {
super(message)
+13 -24
View File
@@ -26,15 +26,12 @@ import {createUploadPart} from './uploadPart'
import {uploadParts} from './uploadParts'
import {delay, isRetryableMultipartError, retryDelayMs} from './utils'
export class MultipartFallbackError extends Error {}
export async function uploadVideoMultipart({
video,
client,
dispatchUrl,
setProgress,
signal,
onStarted,
}: {
video: CompressedVideo
client: Client
@@ -42,25 +39,12 @@ export async function uploadVideoMultipart({
dispatchUrl: string | URL
setProgress: (progress: number) => void
signal: AbortSignal
onStarted?: () => void
}): Promise<app.bsky.video.defs.JobStatus> {
throwIfAborted(signal)
const tokenProvider = createTokenProvider(client, dispatchUrl, signal)
const token = await tokenProvider.get()
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
let session
try {
session = await startUpload({token, video, name, signal})
} catch (err) {
if (signal.aborted) throw new AbortError()
// A server without multipart support, or one with the kill switch active,
// leaves no reservation behind. The legacy path remains authoritative.
throw new MultipartFallbackError(
err instanceof Error ? err.message : 'Multipart upload unavailable',
)
}
onStarted?.()
const session = await startUpload({token, video, name, signal})
const {jobId} = session
const abortOnCancel = () => {
void tokenProvider
@@ -89,7 +73,7 @@ export async function uploadVideoMultipart({
})
} catch (err) {
if (signal.aborted) throw new AbortError()
return await abortThenFallbackOrResolve(
return await abortThenRethrowOrResolve(
jobId,
await tokenProvider.get(),
err,
@@ -166,14 +150,14 @@ async function finishAndRecover({
}
} catch (err) {
throwIfAborted(signal)
return await abortThenFallbackOrResolve(jobId, token, err)
return await abortThenRethrowOrResolve(jobId, token, err)
}
createdFailures++
if (createdFailures < MULTIPART_FINISH_ATTEMPTS) {
await delay(500 * 2 ** (createdFailures - 1), signal)
continue
}
return await abortThenFallbackOrResolve(jobId, token, finishError)
return await abortThenRethrowOrResolve(jobId, token, finishError)
case 'finishing':
// The service may have assembled the upload even though the finish
// request failed. Poll and retry instead of starting a second upload.
@@ -224,16 +208,21 @@ async function getUploadStatusWithRetry(
throw lastError
}
async function abortThenFallbackOrResolve(
/**
* Releases the reservation for an upload we can no longer finish, then surfaces
* the failure that got us here. The abort can race a service-side completion,
* so a `completed` result is resolved as a success instead.
*/
async function abortThenRethrowOrResolve(
jobId: string,
token: string,
cause: unknown,
): Promise<app.bsky.video.defs.JobStatus> {
const result = await abortUploadWithRetry(jobId, token)
if (result.state === 'aborted') {
throw new MultipartFallbackError(
cause instanceof Error ? cause.message : 'Multipart upload failed',
)
throw cause instanceof Error
? cause
: new MultipartUploadError('Multipart upload failed')
}
if (result.state === 'completed' && result.completedJobId) {
const status = await getUploadStatus(jobId, token)
-10
View File
@@ -5,7 +5,6 @@ import {nanoid} from 'nanoid/non-secure'
import {
type ProbedMetadata,
type VideoCompressSkipReason,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics'
@@ -46,7 +45,6 @@ export type VideoTelemetry = {
compressCompleted: (video: {size: number; mimeType: string}) => void
compressFailed: (e: unknown) => void
uploadStarted: (bytes: number) => void
uploadTransport: (transport: VideoUploadTransport) => void
uploadCompleted: (jobId: string) => void
uploadFailed: (e: unknown) => void
processingStarted: (jobId: string) => void
@@ -72,7 +70,6 @@ export function createVideoTelemetry({
let phaseStartedAt = startedAt
let jobId: string | undefined
let uploadBytes: number | undefined
let uploadTransport: VideoUploadTransport = 'legacy'
let txnEnded = false
let abortBound = true
@@ -229,11 +226,6 @@ export function createVideoTelemetry({
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
},
uploadTransport(transport) {
uploadTransport = transport
phaseSpan?.setAttribute('video.upload.transport', transport)
},
uploadCompleted(id) {
jobId = id
const elapsedMs = Date.now() - phaseStartedAt
@@ -246,7 +238,6 @@ export function createVideoTelemetry({
elapsedMs,
throughputBytesPerSec:
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
transport: uploadTransport,
})
endPhaseSpan()
phase = undefined
@@ -259,7 +250,6 @@ export function createVideoTelemetry({
bytes: uploadBytes ?? 0,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
transport: uploadTransport,
})
endTxn('error')
detachAbort()
-2
View File
@@ -5,8 +5,6 @@
export type VideoCompressSkipReason =
'gif' | 'below-byte-threshold' | 'no-webcodecs' | 'compress-error-fallback'
export type VideoUploadTransport = 'multipart' | 'legacy' | 'legacy-fallback'
export type CompressedVideo = {
uri: string
mimeType: string
+7 -86
View File
@@ -1,116 +1,37 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy'
import {type Client} from '@atproto/lex'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {
type CompressedVideo,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {
getServiceAuthToken,
getVideoUploadLimits,
serviceAuthExp,
} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
import {type CompressedVideo} from '#/lib/media/video/types'
import {uploadVideoMultipart} from './multipart/upload'
import {getVideoUploadLimits} from './upload.shared'
export async function uploadVideo({
video,
client,
dispatchUrl,
did,
setProgress,
signal,
i18n,
onTransport,
}: {
video: CompressedVideo
client: Client
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
dispatchUrl: string | URL
did: string
setProgress: (progress: number) => void
signal: AbortSignal
i18n: I18n
onTransport?: (transport: VideoUploadTransport) => void
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) {
try {
return await uploadVideoMultipart({
video,
client,
dispatchUrl,
setProgress,
signal,
onStarted: () => onTransport?.('multipart'),
})
} catch (err) {
if (!(err instanceof MultipartFallbackError)) throw err
onTransport?.('legacy-fallback')
setProgress(0)
}
} else {
onTransport?.('legacy')
}
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
if (signal.aborted) {
throw new AbortError()
}
const token = await getServiceAuthToken({
return await uploadVideoMultipart({
video,
client,
dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp: serviceAuthExp(),
setProgress,
signal,
})
const uploadTask = createUploadTask(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
Authorization: `Bearer ${token}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
},
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
)
if (signal.aborted) {
throw new AbortError()
}
const res = await uploadTask.uploadAsync()
if (!res?.body) {
throw new Error('No response')
}
const responseBody = JSON.parse(res.body) as app.bsky.video.defs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(
responseBody.error || i18n._(msg`Failed to upload video`),
)
}
if (signal.aborted) {
throw new AbortError()
}
return responseBody
}
-129
View File
@@ -1,129 +0,0 @@
import {type Client} from '@atproto/lex'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {
type CompressedVideo,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {
getServiceAuthToken,
getVideoUploadLimits,
serviceAuthExp,
} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
client,
dispatchUrl,
did,
setProgress,
signal,
i18n,
onTransport,
}: {
video: CompressedVideo
client: Client
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
dispatchUrl: string | URL
did: string
setProgress: (progress: number) => void
signal: AbortSignal
i18n: I18n
onTransport?: (transport: VideoUploadTransport) => void
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) {
try {
return await uploadVideoMultipart({
video,
client,
dispatchUrl,
setProgress,
signal,
onStarted: () => onTransport?.('multipart'),
})
} catch (err) {
if (!(err instanceof MultipartFallbackError)) throw err
onTransport?.('legacy-fallback')
setProgress(0)
}
} else {
onTransport?.('legacy')
}
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
let bytes = video.bytes
if (!bytes) {
if (signal.aborted) {
throw new AbortError()
}
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
if (signal.aborted) {
throw new AbortError()
}
const token = await getServiceAuthToken({
client,
dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp: serviceAuthExp(),
})
if (signal.aborted) {
throw new AbortError()
}
const xhr = new XMLHttpRequest()
const res = await new Promise<app.bsky.video.defs.JobStatus>(
(resolve, reject) => {
xhr.upload.addEventListener('progress', e => {
const progress = e.loaded / e.total
setProgress(progress)
})
xhr.onloadend = () => {
if (signal.aborted) {
reject(new AbortError())
} else if (xhr.readyState === 4) {
const uploadRes = JSON.parse(
xhr.responseText,
) as app.bsky.video.defs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(i18n._(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject(new ServerError(i18n._(msg`Failed to upload video`)))
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes)
},
)
if (!res.jobId) {
throw new ServerError(res.error || i18n._(msg`Failed to upload video`))
}
if (signal.aborted) {
throw new AbortError()
}
return res
}
+6 -40
View File
@@ -62,7 +62,6 @@ import {
MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES,
type SupportedMimeTypes,
VIDEO_10_MINUTE_MAX_DURATION_MS,
VIDEO_MAX_DURATION_MS,
} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -270,17 +269,10 @@ export const ComposePost = ({
const {currentAccount} = useSession()
const t = useTheme()
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 chatClient = useChatClient()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
const currentDid = currentAccount!.did
/*
* The host the video service-auth token is minted for. This is the same value
* that seeds the session's PDS routing, so the audience always matches the host
@@ -451,7 +443,7 @@ export const ComposePost = ({
* Fail early on duration so we don't spend time compressing a video the
* server would reject anyway.
*/
if (asset.duration != null && asset.duration > videoMaxDurationMs) {
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
composerDispatch({
type: 'update_post',
postId: postId,
@@ -459,9 +451,7 @@ export const ComposePost = ({
type: 'embed_update_video',
videoAction: {
type: 'to_error',
error: allow10MinuteVideos
? l`Videos must be 10 minutes or less.`
: l`Videos must be less than 3 minutes long.`,
error: l`Videos must be 10 minutes or less.`,
signal: abortController.signal,
},
},
@@ -483,23 +473,12 @@ export const ComposePost = ({
},
pdsClient,
currentDispatchUrl,
currentDid,
abortController.signal,
i18n,
telemetry,
)
},
[
l,
i18n,
pdsClient,
currentDispatchUrl,
currentDid,
composerDispatch,
ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
],
[l, i18n, pdsClient, currentDispatchUrl, composerDispatch, ax.metric],
)
const onInitVideo = useNonReactiveCallback(() => {
@@ -596,7 +575,7 @@ export const ComposePost = ({
},
})
if (asset.duration != null && asset.duration > videoMaxDurationMs) {
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
composerDispatch({
type: 'update_post',
postId,
@@ -604,9 +583,7 @@ export const ComposePost = ({
type: 'embed_update_video',
videoAction: {
type: 'to_error',
error: allow10MinuteVideos
? l`Videos must be 10 minutes or less.`
: l`Videos must be less than 3 minutes long.`,
error: l`Videos must be 10 minutes or less.`,
signal: abortController.signal,
},
},
@@ -667,7 +644,6 @@ export const ComposePost = ({
},
pdsClient,
currentDispatchUrl,
currentDid,
abortController.signal,
i18n,
telemetry,
@@ -679,17 +655,7 @@ export const ComposePost = ({
})
}
},
[
l,
i18n,
pdsClient,
currentDispatchUrl,
currentDid,
composerDispatch,
ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
],
[l, i18n, pdsClient, currentDispatchUrl, composerDispatch, ax.metric],
)
const handleSelectDraft = useCallback(
+9 -23
View File
@@ -6,7 +6,6 @@ import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {
VIDEO_10_MINUTE_MAX_DURATION_MS,
VIDEO_MAX_DURATION_MS,
VIDEO_MAX_SIZE,
VIDEO_MAX_SIZE_MB,
@@ -23,7 +22,6 @@ import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner2_Rounded as ImageIcon} from '#/components/icons/Image'
import * as toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif'
import {hasWebCodecs} from './videos/metadata'
@@ -400,13 +398,6 @@ export function SelectMediaButton({
autoOpen,
}: SelectMediaButtonProps) {
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 {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper()
@@ -426,7 +417,7 @@ export function SelectMediaButton({
} = await processImagePickerAssets(rawAssets, {
selectionCountRemaining,
allowedAssetTypes,
videoMaxDurationMs,
videoMaxDurationMs: VIDEO_MAX_DURATION_MS,
})
/*
@@ -451,9 +442,9 @@ export function SelectMediaButton({
[SelectedAssetError.MaxVideos]: _(
msg`You can only select one video at a time.`,
),
[SelectedAssetError.VideoTooLong]: allow10MinuteVideos
? _(msg`Videos must be 10 minutes or less.`)
: _(msg`Videos must be less than 3 minutes long.`),
[SelectedAssetError.VideoTooLong]: _(
msg`Videos must be 10 minutes or less.`,
),
[SelectedAssetError.MaxGIFs]: _(
msg`You can only select one GIF at a time.`,
),
@@ -473,14 +464,7 @@ export function SelectMediaButton({
errors,
})
},
[
_,
onSelectAssets,
selectionCountRemaining,
allowedAssetTypes,
videoMaxDurationMs,
allow10MinuteVideos,
],
[_, onSelectAssets, selectionCountRemaining, allowedAssetTypes],
)
const onPressSelectMedia = useCallback(async () => {
@@ -503,7 +487,10 @@ export function SelectMediaButton({
}
const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining, videoMaxDurationMs}),
openUnifiedPicker({
selectionCountRemaining,
videoMaxDurationMs: VIDEO_MAX_DURATION_MS,
}),
)
if (canceled) return
@@ -516,7 +503,6 @@ export function SelectMediaButton({
sheetWrapper,
processSelectedAssets,
selectionCountRemaining,
videoMaxDurationMs,
])
useEffect(() => {
+3 -9
View File
@@ -6,11 +6,8 @@ import {msg} from '@lingui/core/macro'
import {AbortError} from '#/lib/async/cancelable'
import {VIDEO_MAX_SIZE_MB} from '#/lib/constants'
import {compressVideo} from '#/lib/media/video/compress'
import {
ServerError,
UploadLimitError,
VideoTooLargeError,
} from '#/lib/media/video/errors'
import {UploadLimitError, VideoTooLargeError} from '#/lib/media/video/errors'
import {MultipartUploadError} from '#/lib/media/video/multipart/api'
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type CompressedVideo} from '#/lib/media/video/types'
import {uploadVideo} from '#/lib/media/video/upload'
@@ -294,7 +291,6 @@ export async function processVideo(
dispatch: (action: VideoAction) => void,
client: Client,
dispatchUrl: string | URL,
did: string,
signal: AbortSignal,
i18n: I18n,
telemetry: VideoTelemetry,
@@ -344,10 +340,8 @@ export async function processVideo(
video,
client,
dispatchUrl,
did,
signal,
i18n,
onTransport: telemetry.uploadTransport,
setProgress: p => {
dispatch({type: 'update_progress', progress: p, signal})
},
@@ -513,7 +507,7 @@ function getUploadErrorMessage(e: unknown, i18n: I18n): string | null {
if (e instanceof AbortError) {
return null
}
if (e instanceof ServerError || e instanceof UploadLimitError) {
if (e instanceof MultipartUploadError || e instanceof UploadLimitError) {
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':