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) { 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
@@ -18,8 +18,6 @@ export enum Features {
PostGalleryEmbedEnable = 'post_gallery_embed:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable',
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable', PostThreadKnownLikersEnable = 'post_thread:known_likers: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 -6
View File
@@ -5,10 +5,7 @@
import {type Platform} from 'react-native' import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import { import {type VideoCompressSkipReason} from '#/lib/media/video/types'
type VideoCompressSkipReason,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {type NotificationType} from '#/state/queries/notifications/types' import {type NotificationType} from '#/state/queries/notifications/types'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -1473,7 +1470,6 @@ export type Events = {
bytes: number bytes: number
elapsedMs: number elapsedMs: number
throughputBytesPerSec: number throughputBytesPerSec: number
transport: VideoUploadTransport
} }
'video:upload:uploadFailed': { 'video:upload:uploadFailed': {
uploadId: string uploadId: string
@@ -1481,7 +1477,6 @@ export type Events = {
bytes: number bytes: number
errorClass: string errorClass: string
elapsedMs: number elapsedMs: number
transport: VideoUploadTransport
} }
'video:upload:processingStarted': { 'video:upload:processingStarted': {
uploadId: string 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 = '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.
-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 { export class UploadLimitError extends Error {
constructor(message: string) { constructor(message: string) {
super(message) super(message)
+13 -24
View File
@@ -26,15 +26,12 @@ import {createUploadPart} from './uploadPart'
import {uploadParts} from './uploadParts' import {uploadParts} from './uploadParts'
import {delay, isRetryableMultipartError, retryDelayMs} from './utils' import {delay, isRetryableMultipartError, retryDelayMs} from './utils'
export class MultipartFallbackError extends Error {}
export async function uploadVideoMultipart({ export async function uploadVideoMultipart({
video, video,
client, client,
dispatchUrl, dispatchUrl,
setProgress, setProgress,
signal, signal,
onStarted,
}: { }: {
video: CompressedVideo video: CompressedVideo
client: Client client: Client
@@ -42,25 +39,12 @@ export async function uploadVideoMultipart({
dispatchUrl: string | URL dispatchUrl: string | URL
setProgress: (progress: number) => void setProgress: (progress: number) => void
signal: AbortSignal signal: AbortSignal
onStarted?: () => void
}): Promise<app.bsky.video.defs.JobStatus> { }): Promise<app.bsky.video.defs.JobStatus> {
throwIfAborted(signal) throwIfAborted(signal)
const tokenProvider = createTokenProvider(client, dispatchUrl, signal) const tokenProvider = createTokenProvider(client, dispatchUrl, signal)
const token = await tokenProvider.get() const token = await tokenProvider.get()
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}` const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
let session const session = await startUpload({token, video, name, signal})
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 {jobId} = session const {jobId} = session
const abortOnCancel = () => { const abortOnCancel = () => {
void tokenProvider void tokenProvider
@@ -89,7 +73,7 @@ export async function uploadVideoMultipart({
}) })
} catch (err) { } catch (err) {
if (signal.aborted) throw new AbortError() if (signal.aborted) throw new AbortError()
return await abortThenFallbackOrResolve( return await abortThenRethrowOrResolve(
jobId, jobId,
await tokenProvider.get(), await tokenProvider.get(),
err, err,
@@ -166,14 +150,14 @@ async function finishAndRecover({
} }
} catch (err) { } catch (err) {
throwIfAborted(signal) throwIfAborted(signal)
return await abortThenFallbackOrResolve(jobId, token, err) return await abortThenRethrowOrResolve(jobId, token, err)
} }
createdFailures++ createdFailures++
if (createdFailures < MULTIPART_FINISH_ATTEMPTS) { if (createdFailures < MULTIPART_FINISH_ATTEMPTS) {
await delay(500 * 2 ** (createdFailures - 1), signal) await delay(500 * 2 ** (createdFailures - 1), signal)
continue continue
} }
return await abortThenFallbackOrResolve(jobId, token, finishError) return await abortThenRethrowOrResolve(jobId, token, finishError)
case 'finishing': case 'finishing':
// The service may have assembled the upload even though the finish // The service may have assembled the upload even though the finish
// request failed. Poll and retry instead of starting a second upload. // request failed. Poll and retry instead of starting a second upload.
@@ -224,16 +208,21 @@ async function getUploadStatusWithRetry(
throw lastError 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, jobId: string,
token: string, token: string,
cause: unknown, cause: unknown,
): Promise<app.bsky.video.defs.JobStatus> { ): Promise<app.bsky.video.defs.JobStatus> {
const result = await abortUploadWithRetry(jobId, token) const result = await abortUploadWithRetry(jobId, token)
if (result.state === 'aborted') { if (result.state === 'aborted') {
throw new MultipartFallbackError( throw cause instanceof Error
cause instanceof Error ? cause.message : 'Multipart upload failed', ? cause
) : new MultipartUploadError('Multipart upload failed')
} }
if (result.state === 'completed' && result.completedJobId) { if (result.state === 'completed' && result.completedJobId) {
const status = await getUploadStatus(jobId, token) const status = await getUploadStatus(jobId, token)
-10
View File
@@ -5,7 +5,6 @@ import {nanoid} from 'nanoid/non-secure'
import { import {
type ProbedMetadata, type ProbedMetadata,
type VideoCompressSkipReason, type VideoCompressSkipReason,
type VideoUploadTransport,
} from '#/lib/media/video/types' } from '#/lib/media/video/types'
import {Sentry} from '#/logger/sentry/lib' import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics' import {type Metrics} from '#/analytics/metrics'
@@ -46,7 +45,6 @@ export type VideoTelemetry = {
compressCompleted: (video: {size: number; mimeType: string}) => void compressCompleted: (video: {size: number; mimeType: string}) => void
compressFailed: (e: unknown) => void compressFailed: (e: unknown) => void
uploadStarted: (bytes: number) => void uploadStarted: (bytes: number) => void
uploadTransport: (transport: VideoUploadTransport) => void
uploadCompleted: (jobId: string) => void uploadCompleted: (jobId: string) => void
uploadFailed: (e: unknown) => void uploadFailed: (e: unknown) => void
processingStarted: (jobId: string) => void processingStarted: (jobId: string) => void
@@ -72,7 +70,6 @@ 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 txnEnded = false let txnEnded = false
let abortBound = true let abortBound = true
@@ -229,11 +226,6 @@ export function createVideoTelemetry({
metric('video:upload:uploadStarted', {uploadId, engine, bytes}) metric('video:upload:uploadStarted', {uploadId, engine, bytes})
}, },
uploadTransport(transport) {
uploadTransport = transport
phaseSpan?.setAttribute('video.upload.transport', transport)
},
uploadCompleted(id) { uploadCompleted(id) {
jobId = id jobId = id
const elapsedMs = Date.now() - phaseStartedAt const elapsedMs = Date.now() - phaseStartedAt
@@ -246,7 +238,6 @@ export function createVideoTelemetry({
elapsedMs, elapsedMs,
throughputBytesPerSec: throughputBytesPerSec:
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0, elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
transport: uploadTransport,
}) })
endPhaseSpan() endPhaseSpan()
phase = undefined phase = undefined
@@ -259,7 +250,6 @@ export function createVideoTelemetry({
bytes: uploadBytes ?? 0, bytes: uploadBytes ?? 0,
errorClass: errorClass(e), errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt, elapsedMs: Date.now() - phaseStartedAt,
transport: uploadTransport,
}) })
endTxn('error') endTxn('error')
detachAbort() detachAbort()
-2
View File
@@ -5,8 +5,6 @@
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 CompressedVideo = { export type CompressedVideo = {
uri: string uri: string
mimeType: 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 Client} from '@atproto/lex'
import {type I18n} from '@lingui/core' 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 {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors' import {type CompressedVideo} from '#/lib/media/video/types'
import { import {uploadVideoMultipart} from './multipart/upload'
type CompressedVideo, import {getVideoUploadLimits} from './upload.shared'
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({ export async function uploadVideo({
video, video,
client, client,
dispatchUrl, dispatchUrl,
did,
setProgress, setProgress,
signal, signal,
i18n, i18n,
onTransport,
}: { }: {
video: CompressedVideo video: CompressedVideo
client: Client client: Client
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */ /** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
dispatchUrl: string | URL dispatchUrl: string | URL
did: string
setProgress: (progress: number) => void setProgress: (progress: number) => void
signal: AbortSignal signal: AbortSignal
i18n: I18n i18n: I18n
onTransport?: (transport: VideoUploadTransport) => void
}) { }) {
if (signal.aborted) { if (signal.aborted) {
throw new AbortError() throw new AbortError()
} }
await getVideoUploadLimits(client, i18n) await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) { return await uploadVideoMultipart({
try { video,
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({
client, client,
dispatchUrl, dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob', setProgress,
exp: serviceAuthExp(), 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, 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,17 +269,10 @@ 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()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentDid = currentAccount!.did
/* /*
* The host the video service-auth token is minted for. This is the same value * 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 * 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 * 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 +451,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,
}, },
}, },
@@ -483,23 +473,12 @@ export const ComposePost = ({
}, },
pdsClient, pdsClient,
currentDispatchUrl, currentDispatchUrl,
currentDid,
abortController.signal, abortController.signal,
i18n, i18n,
telemetry, telemetry,
) )
}, },
[ [l, i18n, pdsClient, currentDispatchUrl, composerDispatch, ax.metric],
l,
i18n,
pdsClient,
currentDispatchUrl,
currentDid,
composerDispatch,
ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
],
) )
const onInitVideo = useNonReactiveCallback(() => { 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({ composerDispatch({
type: 'update_post', type: 'update_post',
postId, postId,
@@ -604,9 +583,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,
}, },
}, },
@@ -667,7 +644,6 @@ export const ComposePost = ({
}, },
pdsClient, pdsClient,
currentDispatchUrl, currentDispatchUrl,
currentDid,
abortController.signal, abortController.signal,
i18n, i18n,
telemetry, telemetry,
@@ -679,17 +655,7 @@ export const ComposePost = ({
}) })
} }
}, },
[ [l, i18n, pdsClient, currentDispatchUrl, composerDispatch, ax.metric],
l,
i18n,
pdsClient,
currentDispatchUrl,
currentDid,
composerDispatch,
ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
],
) )
const handleSelectDraft = useCallback( const handleSelectDraft = useCallback(
+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(() => {
+3 -9
View File
@@ -6,11 +6,8 @@ import {msg} from '@lingui/core/macro'
import {AbortError} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {VIDEO_MAX_SIZE_MB} from '#/lib/constants' import {VIDEO_MAX_SIZE_MB} from '#/lib/constants'
import {compressVideo} from '#/lib/media/video/compress' import {compressVideo} from '#/lib/media/video/compress'
import { import {UploadLimitError, VideoTooLargeError} from '#/lib/media/video/errors'
ServerError, import {MultipartUploadError} from '#/lib/media/video/multipart/api'
UploadLimitError,
VideoTooLargeError,
} from '#/lib/media/video/errors'
import {type VideoTelemetry} from '#/lib/media/video/telemetry' import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type CompressedVideo} from '#/lib/media/video/types' import {type CompressedVideo} from '#/lib/media/video/types'
import {uploadVideo} from '#/lib/media/video/upload' import {uploadVideo} from '#/lib/media/video/upload'
@@ -294,7 +291,6 @@ export async function processVideo(
dispatch: (action: VideoAction) => void, dispatch: (action: VideoAction) => void,
client: Client, client: Client,
dispatchUrl: string | URL, dispatchUrl: string | URL,
did: string,
signal: AbortSignal, signal: AbortSignal,
i18n: I18n, i18n: I18n,
telemetry: VideoTelemetry, telemetry: VideoTelemetry,
@@ -344,10 +340,8 @@ export async function processVideo(
video, video,
client, client,
dispatchUrl, dispatchUrl,
did,
signal, signal,
i18n, i18n,
onTransport: telemetry.uploadTransport,
setProgress: p => { setProgress: p => {
dispatch({type: 'update_progress', progress: p, signal}) dispatch({type: 'update_progress', progress: p, signal})
}, },
@@ -513,7 +507,7 @@ function getUploadErrorMessage(e: unknown, i18n: I18n): string | null {
if (e instanceof AbortError) { if (e instanceof AbortError) {
return null 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 // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) { switch (e.message) {
case 'User is not allowed to upload videos': case 'User is not allowed to upload videos':