From c70ec1ce1aff6072934add1f543576d5200c1b02 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 30 Aug 2024 18:45:49 +0100 Subject: [PATCH] [Video] Captions and alt text (#5009) * video settings modal in composer * show done button on web * rm download options * fix logic for showing settings button * add language picker (wip) * subtitle list with language select * send captions & alt text with video when posting * style "ensure you have selected a language" text * include aspect ratio with video * filter out captions where the lang is not set * rm log * fix label and add hint * minor scrubber fix --- src/alf/atoms.ts | 9 + src/lib/api/index.ts | 28 +- src/lib/moderation/useLabelInfo.ts | 6 +- src/lib/strings/helpers.ts | 18 ++ src/state/queries/video/video.ts | 19 +- src/view/com/composer/Composer.tsx | 50 +++- .../com/composer/videos/SubtitleDialog.tsx | 265 ++++++++++++++++++ .../videos/SubtitleFilePicker.native.tsx | 3 + .../composer/videos/SubtitleFilePicker.tsx | 63 +++++ src/view/com/composer/videos/VideoPreview.tsx | 13 +- .../com/composer/videos/VideoPreview.web.tsx | 46 ++- .../videos/VideoTranscodeBackdrop.web.tsx | 8 +- .../videos/VideoTranscodeProgress.tsx | 3 + .../VideoEmbedInner/VideoWebControls.tsx | 2 +- 14 files changed, 503 insertions(+), 30 deletions(-) create mode 100644 src/view/com/composer/videos/SubtitleDialog.tsx create mode 100644 src/view/com/composer/videos/SubtitleFilePicker.native.tsx create mode 100644 src/view/com/composer/videos/SubtitleFilePicker.tsx diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index e918e370..429a0607 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -853,6 +853,7 @@ export const atoms = { mr_auto: { marginRight: 'auto', }, + /* * Pointer events & user select */ @@ -871,6 +872,7 @@ export const atoms = { user_select_all: { userSelect: 'all', }, + /* * Text decoration */ @@ -880,4 +882,11 @@ export const atoms = { strike_through: { textDecorationLine: 'line-through', }, + + /* + * Display + */ + hidden: { + display: 'none', + }, } as const diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index fa2e4ba6..f6537e3d 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,4 +1,5 @@ import { + AppBskyEmbedDefs, AppBskyEmbedExternal, AppBskyEmbedImages, AppBskyEmbedRecord, @@ -45,7 +46,12 @@ interface PostOpts { uri: string cid: string } - video?: BlobRef + video?: { + blobRef: BlobRef + altText: string + captions: {lang: string; file: File}[] + aspectRatio?: AppBskyEmbedDefs.AspectRatio + } extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] @@ -128,19 +134,35 @@ export async function post(agent: BskyAgent, opts: PostOpts) { // add video embed if present if (opts.video) { + const captions = await Promise.all( + opts.video.captions + .filter(caption => caption.lang !== '') + .map(async caption => { + const {data} = await agent.uploadBlob(caption.file, { + encoding: 'text/vtt', + }) + return {lang: caption.lang, file: data.blob} + }), + ) if (opts.quote) { embed = { $type: 'app.bsky.embed.recordWithMedia', record: embed, media: { $type: 'app.bsky.embed.video', - video: opts.video, + video: opts.video.blobRef, + alt: opts.video.altText || undefined, + captions: captions.length === 0 ? undefined : captions, + aspectRatio: opts.video.aspectRatio, } as AppBskyEmbedVideo.Main, } as AppBskyEmbedRecordWithMedia.Main } else { embed = { $type: 'app.bsky.embed.video', - video: opts.video, + video: opts.video.blobRef, + alt: opts.video.altText || undefined, + captions: captions.length === 0 ? undefined : captions, + aspectRatio: opts.video.aspectRatio, } as AppBskyEmbedVideo.Main } } diff --git a/src/lib/moderation/useLabelInfo.ts b/src/lib/moderation/useLabelInfo.ts index b1cffe1e..0ff7e124 100644 --- a/src/lib/moderation/useLabelInfo.ts +++ b/src/lib/moderation/useLabelInfo.ts @@ -1,9 +1,9 @@ import { - ComAtprotoLabelDefs, AppBskyLabelerDefs, - LABELS, - interpretLabelValueDefinition, + ComAtprotoLabelDefs, InterpretedLabelValueDefinition, + interpretLabelValueDefinition, + LABELS, } from '@atproto/api' import {useLingui} from '@lingui/react' import * as bcp47Match from 'bcp-47-match' diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts index b4ce64fa..acd55da2 100644 --- a/src/lib/strings/helpers.ts +++ b/src/lib/strings/helpers.ts @@ -1,3 +1,6 @@ +import {useCallback, useMemo} from 'react' +import Graphemer from 'graphemer' + export function enforceLen( str: string, len: number, @@ -23,6 +26,21 @@ export function enforceLen( return str } +export function useEnforceMaxGraphemeCount() { + const splitter = useMemo(() => new Graphemer(), []) + + return useCallback( + (text: string, maxCount: number) => { + if (splitter.countGraphemes(text) > maxCount) { + return splitter.splitGraphemes(text).slice(0, maxCount).join('') + } else { + return text + } + }, + [splitter], + ) +} + // https://stackoverflow.com/a/52171480 export function toHashCode(str: string, seed = 0): number { let h1 = 0xdeadbeef ^ seed, diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index 035dc508..f787a6af 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useCallback} from 'react' import {ImagePickerAsset} from 'expo-image-picker' import {AppBskyVideoDefs, BlobRef} from '@atproto/api' import {msg} from '@lingui/macro' @@ -20,6 +20,7 @@ type Action = | {type: 'SetError'; error: string | undefined} | {type: 'Reset'} | {type: 'SetAsset'; asset: ImagePickerAsset} + | {type: 'SetDimensions'; width: number; height: number} | {type: 'SetVideo'; video: CompressedVideo} | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} | {type: 'SetBlobRef'; blobRef: BlobRef} @@ -58,6 +59,13 @@ function reducer(queryClient: QueryClient) { } } else if (action.type === 'SetAsset') { updatedState = {...state, asset: action.asset} + } else if (action.type === 'SetDimensions') { + updatedState = { + ...state, + asset: state.asset + ? {...state.asset, width: action.width, height: action.height} + : undefined, + } } else if (action.type === 'SetVideo') { updatedState = {...state, video: action.video} } else if (action.type === 'SetJobStatus') { @@ -178,11 +186,20 @@ export function useUploadVideo({ dispatch({type: 'Reset'}) } + const updateVideoDimensions = useCallback((width: number, height: number) => { + dispatch({ + type: 'SetDimensions', + width, + height, + }) + }, []) + return { state, dispatch, selectVideo, clearVideo, + updateVideoDimensions, } } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 7c11f0a9..f0b4ae75 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -108,6 +108,7 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' import {SelectVideoBtn} from './videos/SelectVideoBtn' +import {SubtitleDialogBtn} from './videos/SubtitleDialog' import {VideoPreview} from './videos/VideoPreview' import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' @@ -172,10 +173,14 @@ export const ComposePost = observer(function ComposePost({ initQuote, ) + const [videoAltText, setVideoAltText] = useState('') + const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) + const { selectVideo, clearVideo, state: videoUploadState, + updateVideoDimensions, } = useUploadVideo({ setStatus: setProcessingState, onSuccess: () => { @@ -347,7 +352,19 @@ export const ComposePost = observer(function ComposePost({ postgate, onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), - video: videoUploadState.blobRef, + video: videoUploadState.blobRef + ? { + blobRef: videoUploadState.blobRef, + altText: videoAltText, + captions: captions, + aspectRatio: videoUploadState.asset + ? { + width: videoUploadState.asset?.width, + height: videoUploadState.asset?.height, + } + : undefined, + } + : undefined, }) ).uri try { @@ -694,16 +711,29 @@ export const ComposePost = observer(function ComposePost({ )} ) : null} - {videoUploadState.status === 'compressing' && - videoUploadState.asset ? ( - + ) : videoUploadState.video ? ( + + ) : null)} + {(videoUploadState.asset || videoUploadState.video) && ( + - ) : videoUploadState.video ? ( - - ) : null} + )} diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx new file mode 100644 index 00000000..90a29b25 --- /dev/null +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -0,0 +1,265 @@ +import React, {useCallback} from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import RNPickerSelect from 'react-native-picker-select' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {MAX_ALT_TEXT} from '#/lib/constants' +import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers' +import {LANGUAGES} from '#/locale/languages' +import {isWeb} from '#/platform/detection' +import {useLanguagePrefs} from '#/state/preferences' +import {atoms as a, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC' +import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {Text} from '#/components/Typography' +import {SubtitleFilePicker} from './SubtitleFilePicker' + +interface Props { + altText: string + captions: {lang: string; file: File}[] + setAltText: (altText: string) => void + setCaptions: React.Dispatch< + React.SetStateAction<{lang: string; file: File}[]> + > +} + +export function SubtitleDialogBtn(props: Props) { + const control = Dialog.useDialogControl() + const {_} = useLingui() + + return ( + + + + + + + + ) +} + +function SubtitleDialogInner({ + altText, + setAltText, + captions, + setCaptions, +}: Props) { + const control = Dialog.useDialogContext() + const {_} = useLingui() + const t = useTheme() + const enforceLen = useEnforceMaxGraphemeCount() + const {primaryLanguage} = useLanguagePrefs() + + const handleSelectFile = useCallback( + (file: File) => { + setCaptions(subs => [ + ...subs, + { + lang: subs.some(s => s.lang === primaryLanguage) + ? '' + : primaryLanguage, + file, + }, + ]) + }, + [setCaptions, primaryLanguage], + ) + + const subtitleMissingLanguage = captions.some(sub => sub.lang === '') + + return ( + + + + Alt text + + + setAltText(enforceLen(evt, MAX_ALT_TEXT))} + maxLength={MAX_ALT_TEXT * 10} + multiline + numberOfLines={3} + onKeyPress={({nativeEvent}) => { + if (nativeEvent.key === 'Escape') { + control.close() + } + }} + /> + + + {isWeb && ( + <> + + + Captions (.vtt) + + = 4} + /> + + {captions.map((subtitle, i) => ( + + langCode(lang) === subtitle.lang || + !captions.some(s => s.lang === langCode(lang)), + )} + style={[i % 2 === 0 && t.atoms.bg_contrast_25]} + /> + ))} + + + )} + + {subtitleMissingLanguage && ( + + Ensure you have selected a language for each subtitle file. + + )} + + + + + + + + ) +} + +function SubtitleFileRow({ + language, + file, + otherLanguages, + setCaptions, + style, +}: { + language: string + file: File + otherLanguages: {code2: string; code3: string; name: string}[] + setCaptions: React.Dispatch< + React.SetStateAction<{lang: string; file: File}[]> + > + style: StyleProp +}) { + const {_} = useLingui() + const t = useTheme() + + const handleValueChange = useCallback( + (lang: string) => { + if (lang) { + setCaptions(subs => + subs.map(s => (s.lang === language ? {lang, file: s.file} : s)), + ) + } + }, + [setCaptions, language], + ) + + return ( + + + + {language === '' ? ( + + ) : ( + + )} + + {file.name} + + ({ + label: `${lang.name} (${langCode(lang)})`, + value: langCode(lang), + }))} + style={{viewContainer: {maxWidth: 200, flex: 1}}} + /> + + + + + + ) +} + +function langCode(lang: {code2: string; code3: string}) { + return lang.code2 || lang.code3 +} diff --git a/src/view/com/composer/videos/SubtitleFilePicker.native.tsx b/src/view/com/composer/videos/SubtitleFilePicker.native.tsx new file mode 100644 index 00000000..f2b9a7b0 --- /dev/null +++ b/src/view/com/composer/videos/SubtitleFilePicker.native.tsx @@ -0,0 +1,3 @@ +export function SubtitleFilePicker() { + throw new Error('SubtitleFilePicker is a web-only component') +} diff --git a/src/view/com/composer/videos/SubtitleFilePicker.tsx b/src/view/com/composer/videos/SubtitleFilePicker.tsx new file mode 100644 index 00000000..9e0fe0ae --- /dev/null +++ b/src/view/com/composer/videos/SubtitleFilePicker.tsx @@ -0,0 +1,63 @@ +import React, {useRef} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as Toast from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC' + +export function SubtitleFilePicker({ + onSelectFile, + disabled, +}: { + onSelectFile: (file: File) => void + disabled?: boolean +}) { + const {_} = useLingui() + const ref = useRef(null) + + const handleClick = () => { + ref.current?.click() + } + + const handlePick = (evt: React.ChangeEvent) => { + const selectedFile = evt.target.files?.[0] + if (selectedFile) { + if (selectedFile.type === 'text/vtt') { + onSelectFile(selectedFile) + } else { + Toast.show(_(msg`Only WebVTT (.vtt) files are supported`)) + } + } + } + + return ( + + + + + + + ) +} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 6956c8c4..7e43dcd6 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -1,32 +1,41 @@ /* eslint-disable @typescript-eslint/no-shadow */ import React from 'react' import {View} from 'react-native' +import {ImagePickerAsset} from 'expo-image-picker' import {useVideoPlayer, VideoView} from 'expo-video' import {CompressedVideo} from '#/lib/media/video/compress' import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' export function VideoPreview({ + asset, video, clear, }: { + asset: ImagePickerAsset video: CompressedVideo + setDimensions: (width: number, height: number) => void clear: () => void }) { + const t = useTheme() const player = useVideoPlayer(video.uri, player => { player.loop = true player.muted = true player.play() }) + const aspectRatio = asset.width / asset.height + return ( void clear: () => void }) { + const t = useTheme() + const ref = useRef(null) + + useEffect(() => { + if (!ref.current) return + + const abortController = new AbortController() + const {signal} = abortController + ref.current.addEventListener( + 'loadedmetadata', + function () { + setDimensions(this.videoWidth, this.videoHeight) + }, + {signal}, + ) + + return () => { + abortController.abort() + } + }, [setDimensions]) + + const aspectRatio = asset.width / asset.height + return ( - ) } diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx index 9b580fdf..d4090d85 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx @@ -1,7 +1,3 @@ -import React from 'react' - -export function VideoTranscodeBackdrop({uri}: {uri: string}) { - return ( -