Allow feature-gated 10-minute video uploads (#11388)

This commit is contained in:
Spence Pope
2026-08-05 10:15:06 -04:00
committed by GitHub
parent 5db6b4e2fd
commit 0ccc029716
5 changed files with 66 additions and 13 deletions
+1
View File
@@ -19,6 +19,7 @@ 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', 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',
+1
View File
@@ -194,6 +194,7 @@ 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 = 3 * 60 * 1000 // 3 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.
+3 -1
View File
@@ -47,8 +47,10 @@ export async function openPicker(opts?: ImagePickerOptions) {
export async function openUnifiedPicker({ export async function openUnifiedPicker({
selectionCountRemaining, selectionCountRemaining,
videoMaxDurationMs = VIDEO_MAX_DURATION_MS,
}: { }: {
selectionCountRemaining: number selectionCountRemaining: number
videoMaxDurationMs?: number
}) { }) {
return await launchImageLibraryAsync({ return await launchImageLibraryAsync({
exif: false, exif: false,
@@ -61,6 +63,6 @@ export async function openUnifiedPicker({
preferredAssetRepresentationMode: preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Automatic, UIImagePickerPreferredAssetRepresentationMode.Automatic,
videoExportPreset: VideoExportPreset.Passthrough, videoExportPreset: VideoExportPreset.Passthrough,
videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000, videoMaxDuration: videoMaxDurationMs / 1000,
}) })
} }
+35 -6
View File
@@ -70,6 +70,7 @@ 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'
@@ -267,6 +268,12 @@ 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 agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentDid = currentAccount!.did const currentDid = currentAccount!.did
@@ -434,7 +441,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 > VIDEO_MAX_DURATION_MS) { if (asset.duration != null && asset.duration > videoMaxDurationMs) {
composerDispatch({ composerDispatch({
type: 'update_post', type: 'update_post',
postId: postId, postId: postId,
@@ -442,7 +449,9 @@ export const ComposePost = ({
type: 'embed_update_video', type: 'embed_update_video',
videoAction: { videoAction: {
type: 'to_error', type: 'to_error',
error: l`Videos must be less than 3 minutes long.`, error: allow10MinuteVideos
? l`Videos must be 10 minutes or less.`
: l`Videos must be less than 3 minutes long.`,
signal: abortController.signal, signal: abortController.signal,
}, },
}, },
@@ -469,7 +478,16 @@ export const ComposePost = ({
telemetry, telemetry,
) )
}, },
[l, i18n, agent, currentDid, composerDispatch, ax.metric], [
l,
i18n,
agent,
currentDid,
composerDispatch,
ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
],
) )
const onInitVideo = useNonReactiveCallback(() => { const onInitVideo = useNonReactiveCallback(() => {
@@ -566,7 +584,7 @@ export const ComposePost = ({
}, },
}) })
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) { if (asset.duration != null && asset.duration > videoMaxDurationMs) {
composerDispatch({ composerDispatch({
type: 'update_post', type: 'update_post',
postId, postId,
@@ -574,7 +592,9 @@ export const ComposePost = ({
type: 'embed_update_video', type: 'embed_update_video',
videoAction: { videoAction: {
type: 'to_error', type: 'to_error',
error: l`Videos must be less than 3 minutes long.`, error: allow10MinuteVideos
? l`Videos must be 10 minutes or less.`
: l`Videos must be less than 3 minutes long.`,
signal: abortController.signal, signal: abortController.signal,
}, },
}, },
@@ -646,7 +666,16 @@ export const ComposePost = ({
}) })
} }
}, },
[l, i18n, agent, currentDid, composerDispatch, ax.metric], [
l,
i18n,
agent,
currentDid,
composerDispatch,
ax.metric,
videoMaxDurationMs,
allow10MinuteVideos,
],
) )
const handleSelectDraft = useCallback( const handleSelectDraft = useCallback(
+26 -6
View File
@@ -6,6 +6,7 @@ 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,
@@ -22,6 +23,7 @@ import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import {Image_Stroke2_Corner0_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'
@@ -236,9 +238,11 @@ async function processImagePickerAssets(
{ {
selectionCountRemaining, selectionCountRemaining,
allowedAssetTypes, allowedAssetTypes,
videoMaxDurationMs,
}: { }: {
selectionCountRemaining: number selectionCountRemaining: number
allowedAssetTypes: AssetType | undefined allowedAssetTypes: AssetType | undefined
videoMaxDurationMs: number
}, },
) { ) {
/* /*
@@ -362,7 +366,7 @@ async function processImagePickerAssets(
supportedAssets[0].duration = supportedAssets[0].duration * 1000 supportedAssets[0].duration = supportedAssets[0].duration * 1000
} }
if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { if (supportedAssets[0].duration > videoMaxDurationMs) {
errors.add(SelectedAssetError.VideoTooLong) errors.add(SelectedAssetError.VideoTooLong)
supportedAssets = [] supportedAssets = []
} }
@@ -393,6 +397,13 @@ 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()
@@ -412,6 +423,7 @@ export function SelectMediaButton({
} = await processImagePickerAssets(rawAssets, { } = await processImagePickerAssets(rawAssets, {
selectionCountRemaining, selectionCountRemaining,
allowedAssetTypes, allowedAssetTypes,
videoMaxDurationMs,
}) })
/* /*
@@ -436,9 +448,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]: _( [SelectedAssetError.VideoTooLong]: allow10MinuteVideos
msg`Videos must be less than 3 minutes long.`, ? _(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.`,
), ),
@@ -458,7 +470,14 @@ export function SelectMediaButton({
errors, errors,
}) })
}, },
[_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], [
_,
onSelectAssets,
selectionCountRemaining,
allowedAssetTypes,
videoMaxDurationMs,
allow10MinuteVideos,
],
) )
const onPressSelectMedia = useCallback(async () => { const onPressSelectMedia = useCallback(async () => {
@@ -481,7 +500,7 @@ export function SelectMediaButton({
} }
const {assets, canceled} = await sheetWrapper( const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining}), openUnifiedPicker({selectionCountRemaining, videoMaxDurationMs}),
) )
if (canceled) return if (canceled) return
@@ -494,6 +513,7 @@ export function SelectMediaButton({
sheetWrapper, sheetWrapper,
processSelectedAssets, processSelectedAssets,
selectionCountRemaining, selectionCountRemaining,
videoMaxDurationMs,
]) ])
useEffect(() => { useEffect(() => {