Remove multipart upload and 10-minute video feature gates
Ship both video upload features unconditionally: - Multipart upload is now always attempted. The MultipartFallbackError path to the legacy single-request upload is unchanged, so a video service without multipart support still works. This makes the plain 'legacy' upload transport unreachable, so drop it from VideoUploadTransport and default the telemetry value to 'multipart'. - The 10-minute duration cap now applies to everyone. VIDEO_10_MINUTE_MAX_DURATION_MS is folded into VIDEO_MAX_DURATION_MS, and the composer's duration error message no longer branches on the gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mn8DoxP5wQk6NbPmapWs9A
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ export enum Features {
|
|||||||
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||||
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
|
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
|
||||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||||
VideoAllow10MinuteEnable = 'video:allow-10-minute:enable',
|
|
||||||
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
|
|
||||||
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
||||||
FollowSortEnable = 'follow_sort:enable',
|
FollowSortEnable = 'follow_sort:enable',
|
||||||
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
|
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function createVideoTelemetry({
|
|||||||
let phaseStartedAt = startedAt
|
let phaseStartedAt = startedAt
|
||||||
let jobId: string | undefined
|
let jobId: string | undefined
|
||||||
let uploadBytes: number | undefined
|
let uploadBytes: number | undefined
|
||||||
let uploadTransport: VideoUploadTransport = 'legacy'
|
let uploadTransport: VideoUploadTransport = 'multipart'
|
||||||
let txnEnded = false
|
let txnEnded = false
|
||||||
let abortBound = true
|
let abortBound = true
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
export type VideoCompressSkipReason =
|
export type VideoCompressSkipReason =
|
||||||
'gif' | 'below-byte-threshold' | 'no-webcodecs' | 'compress-error-fallback'
|
'gif' | 'below-byte-threshold' | 'no-webcodecs' | 'compress-error-fallback'
|
||||||
|
|
||||||
export type VideoUploadTransport = 'multipart' | 'legacy' | 'legacy-fallback'
|
export type VideoUploadTransport = 'multipart' | 'legacy-fallback'
|
||||||
|
|
||||||
export type CompressedVideo = {
|
export type CompressedVideo = {
|
||||||
uri: string
|
uri: string
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type CompressedVideo,
|
type CompressedVideo,
|
||||||
type VideoUploadTransport,
|
type VideoUploadTransport,
|
||||||
} from '#/lib/media/video/types'
|
} from '#/lib/media/video/types'
|
||||||
import {Features, features} from '#/analytics/features'
|
|
||||||
import {type app} from '#/lexicons'
|
import {type app} from '#/lexicons'
|
||||||
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
||||||
import {
|
import {
|
||||||
@@ -45,23 +44,19 @@ export async function uploadVideo({
|
|||||||
}
|
}
|
||||||
await getVideoUploadLimits(client, i18n)
|
await getVideoUploadLimits(client, i18n)
|
||||||
|
|
||||||
if (features.isOn(Features.VideoMultipartUploadEnable)) {
|
try {
|
||||||
try {
|
return await uploadVideoMultipart({
|
||||||
return await uploadVideoMultipart({
|
video,
|
||||||
video,
|
client,
|
||||||
client,
|
dispatchUrl,
|
||||||
dispatchUrl,
|
setProgress,
|
||||||
setProgress,
|
signal,
|
||||||
signal,
|
onStarted: () => onTransport?.('multipart'),
|
||||||
onStarted: () => onTransport?.('multipart'),
|
})
|
||||||
})
|
} catch (err) {
|
||||||
} catch (err) {
|
if (!(err instanceof MultipartFallbackError)) throw err
|
||||||
if (!(err instanceof MultipartFallbackError)) throw err
|
onTransport?.('legacy-fallback')
|
||||||
onTransport?.('legacy-fallback')
|
setProgress(0)
|
||||||
setProgress(0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onTransport?.('legacy')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type CompressedVideo,
|
type CompressedVideo,
|
||||||
type VideoUploadTransport,
|
type VideoUploadTransport,
|
||||||
} from '#/lib/media/video/types'
|
} from '#/lib/media/video/types'
|
||||||
import {Features, features} from '#/analytics/features'
|
|
||||||
import {type app} from '#/lexicons'
|
import {type app} from '#/lexicons'
|
||||||
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
||||||
import {
|
import {
|
||||||
@@ -44,23 +43,19 @@ export async function uploadVideo({
|
|||||||
}
|
}
|
||||||
await getVideoUploadLimits(client, i18n)
|
await getVideoUploadLimits(client, i18n)
|
||||||
|
|
||||||
if (features.isOn(Features.VideoMultipartUploadEnable)) {
|
try {
|
||||||
try {
|
return await uploadVideoMultipart({
|
||||||
return await uploadVideoMultipart({
|
video,
|
||||||
video,
|
client,
|
||||||
client,
|
dispatchUrl,
|
||||||
dispatchUrl,
|
setProgress,
|
||||||
setProgress,
|
signal,
|
||||||
signal,
|
onStarted: () => onTransport?.('multipart'),
|
||||||
onStarted: () => onTransport?.('multipart'),
|
})
|
||||||
})
|
} catch (err) {
|
||||||
} catch (err) {
|
if (!(err instanceof MultipartFallbackError)) throw err
|
||||||
if (!(err instanceof MultipartFallbackError)) throw err
|
onTransport?.('legacy-fallback')
|
||||||
onTransport?.('legacy-fallback')
|
setProgress(0)
|
||||||
setProgress(0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onTransport?.('legacy')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import {
|
|||||||
MAX_GRAPHEME_LENGTH,
|
MAX_GRAPHEME_LENGTH,
|
||||||
SUPPORTED_MIME_TYPES,
|
SUPPORTED_MIME_TYPES,
|
||||||
type SupportedMimeTypes,
|
type SupportedMimeTypes,
|
||||||
VIDEO_10_MINUTE_MAX_DURATION_MS,
|
|
||||||
VIDEO_MAX_DURATION_MS,
|
VIDEO_MAX_DURATION_MS,
|
||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
@@ -270,12 +269,6 @@ export const ComposePost = ({
|
|||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const allow10MinuteVideos = ax.features.enabled(
|
|
||||||
ax.features.VideoAllow10MinuteEnable,
|
|
||||||
)
|
|
||||||
const videoMaxDurationMs = allow10MinuteVideos
|
|
||||||
? VIDEO_10_MINUTE_MAX_DURATION_MS
|
|
||||||
: VIDEO_MAX_DURATION_MS
|
|
||||||
const client = useAppviewClient()
|
const client = useAppviewClient()
|
||||||
const chatClient = useChatClient()
|
const chatClient = useChatClient()
|
||||||
const pdsClient = usePdsClient()
|
const pdsClient = usePdsClient()
|
||||||
@@ -451,7 +444,7 @@ export const ComposePost = ({
|
|||||||
* Fail early on duration so we don't spend time compressing a video the
|
* Fail early on duration so we don't spend time compressing a video the
|
||||||
* server would reject anyway.
|
* server would reject anyway.
|
||||||
*/
|
*/
|
||||||
if (asset.duration != null && asset.duration > videoMaxDurationMs) {
|
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
|
||||||
composerDispatch({
|
composerDispatch({
|
||||||
type: 'update_post',
|
type: 'update_post',
|
||||||
postId: postId,
|
postId: postId,
|
||||||
@@ -459,9 +452,7 @@ export const ComposePost = ({
|
|||||||
type: 'embed_update_video',
|
type: 'embed_update_video',
|
||||||
videoAction: {
|
videoAction: {
|
||||||
type: 'to_error',
|
type: 'to_error',
|
||||||
error: allow10MinuteVideos
|
error: l`Videos must be 10 minutes or less.`,
|
||||||
? l`Videos must be 10 minutes or less.`
|
|
||||||
: l`Videos must be less than 3 minutes long.`,
|
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -497,8 +488,6 @@ export const ComposePost = ({
|
|||||||
currentDid,
|
currentDid,
|
||||||
composerDispatch,
|
composerDispatch,
|
||||||
ax.metric,
|
ax.metric,
|
||||||
videoMaxDurationMs,
|
|
||||||
allow10MinuteVideos,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -596,7 +585,7 @@ export const ComposePost = ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (asset.duration != null && asset.duration > videoMaxDurationMs) {
|
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
|
||||||
composerDispatch({
|
composerDispatch({
|
||||||
type: 'update_post',
|
type: 'update_post',
|
||||||
postId,
|
postId,
|
||||||
@@ -604,9 +593,7 @@ export const ComposePost = ({
|
|||||||
type: 'embed_update_video',
|
type: 'embed_update_video',
|
||||||
videoAction: {
|
videoAction: {
|
||||||
type: 'to_error',
|
type: 'to_error',
|
||||||
error: allow10MinuteVideos
|
error: l`Videos must be 10 minutes or less.`,
|
||||||
? l`Videos must be 10 minutes or less.`
|
|
||||||
: l`Videos must be less than 3 minutes long.`,
|
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -687,8 +674,6 @@ export const ComposePost = ({
|
|||||||
currentDid,
|
currentDid,
|
||||||
composerDispatch,
|
composerDispatch,
|
||||||
ax.metric,
|
ax.metric,
|
||||||
videoMaxDurationMs,
|
|
||||||
allow10MinuteVideos,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user