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) {
|
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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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(
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user