fix(captions): restore inline caption editing

This commit is contained in:
webadderall
2026-06-19 14:33:24 +10:00
parent afc1fc1cf2
commit 9f6cb6ec86
5 changed files with 301 additions and 12 deletions
@@ -118,6 +118,7 @@ import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/a
import { extensionHost } from "@/lib/extensions";
import { useVideoEditorAudio } from "./audio/useVideoEditorAudio";
import { resolveAutoCaptionSourcePath } from "./autoCaptionSource";
import { type CaptionEditTarget, updateCaptionCuesForEditedTarget } from "./captionEditing";
import { CropControl } from "./CropControl";
import { ExportSettingsMenu } from "./ExportSettingsMenu";
import ExtensionManager from "./ExtensionManager";
@@ -2871,6 +2872,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();
@@ -5407,6 +5416,7 @@ export default function VideoEditor() {
annotationRegions={annotationRegions}
autoCaptions={autoCaptions}
autoCaptionSettings={autoCaptionSettings}
onEditAutoCaption={handleSaveAutoCaptionEdit}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
+250 -9
View File
@@ -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,
@@ -200,6 +201,11 @@ type PlaybackAnimationState = {
y: number;
};
type CaptionEditSession = {
target: CaptionEditTarget;
draft: string;
};
type SceneTransformState = {
scale: number;
x: number;
@@ -377,6 +383,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;
@@ -460,6 +467,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
annotationRegions = [],
autoCaptions = [],
autoCaptionSettings,
onEditAutoCaption,
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
@@ -554,6 +562,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
height: number;
} | null>(null);
const captionBoxRef = useRef<HTMLDivElement | null>(null);
const captionEditInputRef = useRef<HTMLTextAreaElement | null>(null);
const captionEditSessionRef = useRef<CaptionEditSession | null>(null);
const [captionEditSession, setCaptionEditSession] = useState<CaptionEditSession | null>(
null,
);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
@@ -757,6 +770,127 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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;
@@ -2983,13 +3117,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
</div>
</div>
) : null}
{activeCaptionLayout && autoCaptionSettings ? (
<div
className="pointer-events-none absolute inset-x-0 flex justify-center"
style={{
bottom: `${autoCaptionSettings.bottomOffset}%`,
}}
>
{activeCaptionLayout && autoCaptionSettings ? (
<div
className="absolute inset-x-0 flex justify-center"
style={{
bottom: `${autoCaptionSettings.bottomOffset}%`,
pointerEvents: onEditAutoCaption ? "auto" : "none",
}}
>
<div
style={{
maxWidth: `${autoCaptionSettings.maxWidth}%`,
@@ -3001,6 +3136,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
>
<div
ref={captionBoxRef}
role={
onEditAutoCaption && !isCaptionEditing ? "button" : undefined
}
tabIndex={onEditAutoCaption && !isCaptionEditing ? 0 : undefined}
aria-label={
onEditAutoCaption && !isCaptionEditing ? "Edit current caption" : undefined
}
onClick={(event) => {
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(),
@@ -3038,9 +3199,88 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
),
)}px`,
boxSizing: "border-box",
cursor:
onEditAutoCaption && !isCaptionEditing ? "text" : undefined,
pointerEvents: onEditAutoCaption ? "auto" : undefined,
}}
>
{activeCaptionLayout.visibleLines.map((line) => (
{captionEditSession ? (
<textarea
ref={captionEditInputRef}
value={captionEditSession.draft}
onChange={(event) => {
const draft = event.target.value;
setCaptionEditSession((session) => {
const nextSession = session ? { ...session, draft } : session;
captionEditSessionRef.current = nextSession;
return nextSession;
});
}}
onBlur={commitCaptionEdit}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelCaptionEdit();
return;
}
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) => (
<div
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
style={{
@@ -3073,7 +3313,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
);
})}
</div>
))}
))
)}
</div>
</div>
</div>
@@ -114,4 +114,31 @@ describe("captionEditing", () => {
expect(updateCaptionCuesForEditedTarget(cues, visibleTarget, " \n\t ")).toBe(cues);
});
it("keeps sound-effect style captions editable when word entries are blank", () => {
const cues: CaptionCue[] = [
{
id: "sound-effect",
startMs: 1_000,
endMs: 2_000,
text: "clears throat",
words: [{ text: "", startMs: 1_000, endMs: 2_000 }],
},
];
const layout = buildActiveCaptionLayout({
cues,
timeMs: 1_500,
settings: DEFAULT_AUTO_CAPTION_SETTINGS,
maxWidthPx: 500,
measureText: (text) => text.length * 10,
});
expect(layout?.editTarget.text).toBe("clears throat");
const updated = updateCaptionCuesForEditedTarget(cues, layout!.editTarget, "coughs");
expect(updated[0].text).toBe("coughs");
expect(updated[0].words).toEqual([{ text: "coughs", startMs: 1_000, endMs: 2_000 }]);
});
});
@@ -56,9 +56,16 @@ function buildCaptionWordsForEditedText(
}
function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
const validSourceWords = Array.isArray(cue.words)
? cue.words.filter(
(word): word is CaptionCueWord =>
Boolean(word && typeof word.text === "string") &&
normalizeCaptionEditText(word.text).length > 0,
)
: [];
const sourceWords =
Array.isArray(cue.words) && cue.words.length > 0
? cue.words
validSourceWords.length > 0
? validSourceWords
: buildCaptionWordsForEditedText(cue.text, cue.startMs, cue.endMs);
return sourceWords
+5 -1
View File
@@ -95,7 +95,7 @@ function splitCaptionWordsFromText(text: string) {
function splitCaptionWords(cue: CaptionCue) {
if (Array.isArray(cue.words) && cue.words.length > 0) {
return cue.words
const words = cue.words
.filter((word): word is CaptionCueWord =>
Boolean(word && typeof word.text === "string"),
)
@@ -109,6 +109,10 @@ function splitCaptionWords(cue: CaptionCue) {
endMs: word.endMs,
}))
.filter((word) => word.text.length > 0);
if (words.length > 0) {
return words;
}
}
return splitCaptionWordsFromText(cue.text).map((word) => ({