Remove multipart upload and 10-minute video feature gates (#11528)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user