diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 158505d4..f99a03a2 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -119,6 +119,7 @@ import { extensionHost } from "@/lib/extensions"; import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; +import { type CaptionEditTarget, updateCaptionCuesForEditedTarget } from "./captionEditing"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; import ExtensionManager from "./ExtensionManager"; import { @@ -2831,6 +2832,14 @@ export default function VideoEditor() { setAutoCaptionSettings((prev) => ({ ...prev, enabled: false })); }, []); + const handleSaveAutoCaptionEdit = useCallback( + (target: CaptionEditTarget, text: string) => { + setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text)); + toast.success(t("settings.captions.editSaved", "Caption updated")); + }, + [t], + ); + const saveProject = useCallback( async (forceSaveAs: boolean, options?: SaveProjectOptions) => { clearPendingProjectAutosave(); @@ -5337,6 +5346,7 @@ export default function VideoEditor() { annotationRegions={annotationRegions} autoCaptions={autoCaptions} autoCaptionSettings={autoCaptionSettings} + onEditAutoCaption={handleSaveAutoCaptionEdit} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 488bc3a9..b2da2194 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -22,6 +22,7 @@ import { DEFAULT_WALLPAPER_RELATIVE_PATH, isVideoWallpaperSource, } from "@/lib/wallpapers"; +import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing"; import { buildActiveCaptionLayout } from "./captionLayout"; import { CAPTION_FONT_WEIGHT, @@ -169,10 +170,7 @@ import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focu import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; -import { - getWebcamMediaTargetTimeSeconds, - shouldSeekWebcamMedia, -} from "./videoPlayback/webcamSync"; +import { getWebcamMediaTargetTimeSeconds, shouldSeekWebcamMedia } from "./videoPlayback/webcamSync"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; import { applyZoomTransform, @@ -197,6 +195,11 @@ type PlaybackAnimationState = { y: number; }; +type CaptionEditSession = { + target: CaptionEditTarget; + draft: string; +}; + function createPlaybackAnimationState(): PlaybackAnimationState { return { scale: 1, @@ -361,6 +364,7 @@ interface VideoPlaybackProps { annotationRegions?: AnnotationRegion[]; autoCaptions?: CaptionCue[]; autoCaptionSettings?: AutoCaptionSettings; + onEditAutoCaption?: (target: CaptionEditTarget, text: string) => void; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string | null) => void; onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; @@ -444,6 +448,7 @@ const VideoPlayback = forwardRef( annotationRegions = [], autoCaptions = [], autoCaptionSettings, + onEditAutoCaption, selectedAnnotationId, onSelectAnnotation, onAnnotationPositionChange, @@ -525,6 +530,11 @@ const VideoPlayback = forwardRef( height: number; } | null>(null); const captionBoxRef = useRef(null); + const captionEditInputRef = useRef(null); + const captionEditSessionRef = useRef(null); + const [captionEditSession, setCaptionEditSession] = useState( + null, + ); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -728,6 +738,127 @@ const VideoPlayback = forwardRef( measureText: (text) => measurementContext.measureText(text).width, }); }, [autoCaptionSettings, autoCaptions, currentTime]); + const isCaptionEditing = captionEditSession !== null; + const captionEditDraft = captionEditSession?.draft ?? ""; + const captionEditTargetId = captionEditSession?.target.id ?? null; + const captionEditTextMetrics = useMemo(() => { + if (!captionEditSession || !autoCaptionSettings || typeof document === "undefined") { + return null; + } + + const overlayWidth = overlayRef.current?.clientWidth || 960; + const fontSize = getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayWidth, + autoCaptionSettings.maxWidth, + ); + const maxTextWidthPx = getCaptionTextMaxWidth( + overlayWidth, + autoCaptionSettings.maxWidth, + fontSize, + ); + const measurementCanvas = document.createElement("canvas"); + const measurementContext = measurementCanvas.getContext("2d"); + if (!measurementContext) { + return null; + } + + measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`; + const measuredWidth = Math.max( + ...captionEditSession.draft + .split(/\r?\n/) + .map((line) => measurementContext.measureText(line || " ").width), + ); + + return { + fontSize, + maxTextWidthPx, + widthPx: Math.ceil( + Math.min(maxTextWidthPx, Math.max(fontSize * 2, measuredWidth + 2)), + ), + }; + }, [autoCaptionSettings, captionEditSession]); + const captionEditSizeKey = captionEditSession + ? `${captionEditTextMetrics?.widthPx ?? 0}:${captionEditDraft}` + : ""; + + const beginCaptionEdit = useCallback(() => { + if (!activeCaptionLayout?.editTarget || !onEditAutoCaption) { + return; + } + + videoRef.current?.pause(); + onPlayStateChange(false); + const nextSession = { + target: activeCaptionLayout.editTarget, + draft: activeCaptionLayout.editTarget.text, + }; + captionEditSessionRef.current = nextSession; + setCaptionEditSession(nextSession); + }, [activeCaptionLayout, onEditAutoCaption, onPlayStateChange]); + + const commitCaptionEdit = useCallback(() => { + const session = captionEditSessionRef.current; + if (!session || !onEditAutoCaption) { + captionEditSessionRef.current = null; + setCaptionEditSession(null); + return; + } + + const normalizedDraft = normalizeCaptionEditText(session.draft); + captionEditSessionRef.current = null; + if (!normalizedDraft) { + setCaptionEditSession(null); + return; + } + + if (normalizedDraft !== normalizeCaptionEditText(session.target.text)) { + onEditAutoCaption(session.target, session.draft); + } + setCaptionEditSession(null); + }, [onEditAutoCaption]); + + const cancelCaptionEdit = useCallback(() => { + captionEditSessionRef.current = null; + setCaptionEditSession(null); + }, []); + + useEffect(() => { + if (!captionEditTargetId) { + return; + } + + const frame = requestAnimationFrame(() => { + const input = captionEditInputRef.current; + if (!input) { + return; + } + + input.focus(); + const cursorPosition = input.value.length; + input.setSelectionRange(cursorPosition, cursorPosition); + }); + + return () => cancelAnimationFrame(frame); + }, [captionEditTargetId]); + + useEffect(() => { + if (!captionEditSizeKey) { + return; + } + + const frame = requestAnimationFrame(() => { + const input = captionEditInputRef.current; + if (!input) { + return; + } + + input.style.height = "auto"; + input.style.height = `${input.scrollHeight}px`; + }); + + return () => cancelAnimationFrame(frame); + }, [captionEditSizeKey]); useEffect(() => { const captionBox = captionBoxRef.current; @@ -2951,9 +3082,10 @@ const VideoPlayback = forwardRef( ) : null} {activeCaptionLayout && autoCaptionSettings ? (
( >
{ + event.stopPropagation(); + if (!isCaptionEditing) { + beginCaptionEdit(); + } + }} + onPointerDown={(event) => { + event.stopPropagation(); + }} + onKeyDown={(event) => { + if (!onEditAutoCaption || isCaptionEditing) { + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + beginCaptionEdit(); + } + }} style={{ backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`, fontFamily: getDefaultCaptionFontFamily(), @@ -3004,42 +3168,138 @@ const VideoPlayback = forwardRef( ), )}px`, boxSizing: "border-box", + cursor: + onEditAutoCaption && !isCaptionEditing + ? "text" + : undefined, + pointerEvents: onEditAutoCaption ? "auto" : undefined, }} > - {activeCaptionLayout.visibleLines.map((line) => ( -
{ + const draft = event.target.value; + setCaptionEditSession((session) => { + const nextSession = session + ? { ...session, draft } + : session; + captionEditSessionRef.current = nextSession; + return nextSession; + }); }} - > - {line.words.map((word) => { - const visualState = getCaptionWordVisualState( - activeCaptionLayout.hasWordTimings, - word.state, - ); + onBlur={commitCaptionEdit} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelCaptionEdit(); + return; + } - return ( - - {`${word.leadingSpace ? " " : ""}${word.text}`} - - ); - })} -
- ))} + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + event.currentTarget.blur(); + } + }} + rows={Math.max( + 1, + activeCaptionLayout.visibleLines.length, + )} + aria-label="Edit current caption" + style={{ + display: "block", + width: `${ + captionEditTextMetrics?.widthPx ?? + Math.max( + 48, + activeCaptionLayout.visibleLines.reduce( + (width, line) => + Math.max(width, line.width), + 0, + ), + ) + }px`, + maxWidth: `${ + captionEditTextMetrics?.maxTextWidthPx ?? + getCaptionTextMaxWidth( + overlayRef.current?.clientWidth || 960, + autoCaptionSettings.maxWidth, + getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current?.clientWidth || + 960, + autoCaptionSettings.maxWidth, + ), + ) + }px`, + minHeight: `${ + Math.max( + 1, + activeCaptionLayout.visibleLines.length, + ) * + ( + captionEditTextMetrics?.fontSize ?? + getCaptionScaledFontSize( + autoCaptionSettings.fontSize, + overlayRef.current + ?.clientWidth || 960, + autoCaptionSettings.maxWidth, + ) + ) * + CAPTION_LINE_HEIGHT + }px`, + resize: "none", + border: "0", + outline: "0", + padding: "0", + margin: "0", + overflow: "hidden", + background: "transparent", + color: autoCaptionSettings.textColor, + font: "inherit", + lineHeight: "inherit", + textAlign: "center", + }} + /> + ) : ( + activeCaptionLayout.visibleLines.map((line) => ( +
+ {line.words.map((word) => { + const visualState = + getCaptionWordVisualState( + activeCaptionLayout.hasWordTimings, + word.state, + ); + + return ( + + {`${word.leadingSpace ? " " : ""}${word.text}`} + + ); + })} +
+ )) + )}