From 6f41330c07d252cdd4704b2deaf413d111fa90a9 Mon Sep 17 00:00:00 2001 From: Joe Hachem Date: Thu, 25 Jun 2026 07:37:16 +0300 Subject: [PATCH 1/6] feat(captions): add timeline track and side-panel editor for captions --- .../video-editor/CaptionListPanel.tsx | 226 +++++++++++ src/components/video-editor/SettingsPanel.tsx | 104 ++++- src/components/video-editor/VideoEditor.tsx | 114 +++++- src/components/video-editor/VideoPlayback.tsx | 373 ++++++++++-------- src/components/video-editor/captionEditing.ts | 6 +- src/components/video-editor/captionLayout.ts | 25 +- .../video-editor/captionOps.test.ts | 152 +++++++ src/components/video-editor/captionOps.ts | 189 +++++++++ src/components/video-editor/captionStyle.ts | 33 +- src/components/video-editor/timeline/Item.tsx | 7 +- .../timeline/ItemGlass.module.css | 39 +- .../video-editor/timeline/TimelineEditor.tsx | 13 + .../components/viewport/TimelineCanvas.tsx | 53 ++- .../video-editor/timeline/core/constants.ts | 1 + .../timeline/core/timelineTypes.ts | 4 +- .../timeline/hooks/useTimelineDndBindings.ts | 43 +- .../hooks/useTimelineEditorRuntime.ts | 7 + .../timeline/model/timelineModel.ts | 28 +- src/components/video-editor/types.ts | 1 + src/i18n/locales/en/settings.json | 2 + src/i18n/locales/es/settings.json | 2 + src/i18n/locales/fr/settings.json | 2 + src/i18n/locales/it/settings.json | 2 + src/i18n/locales/ko/settings.json | 2 + src/i18n/locales/nl/settings.json | 2 + src/i18n/locales/pt-BR/settings.json | 2 + src/i18n/locales/ru/settings.json | 2 + src/i18n/locales/zh-CN/settings.json | 2 + src/i18n/locales/zh-TW/settings.json | 2 + 29 files changed, 1204 insertions(+), 234 deletions(-) create mode 100644 src/components/video-editor/CaptionListPanel.tsx create mode 100644 src/components/video-editor/captionOps.test.ts create mode 100644 src/components/video-editor/captionOps.ts diff --git a/src/components/video-editor/CaptionListPanel.tsx b/src/components/video-editor/CaptionListPanel.tsx new file mode 100644 index 00000000..7ae2503c --- /dev/null +++ b/src/components/video-editor/CaptionListPanel.tsx @@ -0,0 +1,226 @@ +import { ArrowsMerge, Scissors, Trash } from "@phosphor-icons/react"; +import { useCallback, useEffect, useState } from "react"; +import type { CaptionRetimeSpan } from "./captionOps"; +import type { CaptionCue } from "./types"; + +interface CaptionListPanelProps { + cues: CaptionCue[]; + selectedCaptionId: string | null; + currentTimeMs: number; + onBeginCaptionEdit: (id: string) => void; + onCaptionTextEdit: (id: string, text: string) => void; + onCaptionRetime: (id: string, span: CaptionRetimeSpan) => void; + onCaptionSplit: (id: string, atMs: number) => void; + onCaptionMerge: (idA: string, idB: string) => void; + onCaptionDelete: (id: string) => void; +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function formatTimecode(ms: number): string { + const safeMs = Math.max(0, Math.round(ms)); + const minutes = Math.floor(safeMs / 60_000); + const seconds = Math.floor((safeMs % 60_000) / 1_000); + const millis = safeMs % 1_000; + return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; +} + +function parseTimecode(value: string): number | null { + const match = value.trim().match(/^(?:(\d+):)?(\d{1,2})(?:\.(\d{1,3}))?$/); + if (!match) { + return null; + } + const minutes = match[1] ? Number.parseInt(match[1], 10) : 0; + const seconds = Number.parseInt(match[2], 10); + const millis = match[3] ? Number.parseInt(match[3].padEnd(3, "0"), 10) : 0; + return (minutes * 60 + seconds) * 1_000 + millis; +} + +interface CaptionEditorProps { + cue: CaptionCue; + canMerge: boolean; + currentTimeMs: number; + onBeginEdit: (id: string) => void; + onTextEdit: (id: string, text: string) => void; + onRetime: (id: string, span: CaptionRetimeSpan) => void; + onSplit: (id: string, atMs: number) => void; + onMerge: (id: string) => void; + onDelete: (id: string) => void; +} + +function CaptionEditor({ + cue, + canMerge, + currentTimeMs, + onBeginEdit, + onTextEdit, + onRetime, + onSplit, + onMerge, + onDelete, +}: CaptionEditorProps) { + const [draftText, setDraftText] = useState(cue.text); + const [startValue, setStartValue] = useState(formatTimecode(cue.startMs)); + const [endValue, setEndValue] = useState(formatTimecode(cue.endMs)); + + useEffect(() => { + setDraftText(cue.text); + setStartValue(formatTimecode(cue.startMs)); + setEndValue(formatTimecode(cue.endMs)); + }, [cue.text, cue.startMs, cue.endMs]); + + const commitText = useCallback(() => { + const normalized = draftText.trim(); + if (normalized && normalized !== cue.text) { + onTextEdit(cue.id, normalized); + } else { + setDraftText(cue.text); + } + }, [cue.id, cue.text, draftText, onTextEdit]); + + const commitTiming = useCallback(() => { + const parsedStart = parseTimecode(startValue); + const parsedEnd = parseTimecode(endValue); + if (parsedStart === null || parsedEnd === null || parsedEnd <= parsedStart) { + setStartValue(formatTimecode(cue.startMs)); + setEndValue(formatTimecode(cue.endMs)); + return; + } + if (parsedStart !== cue.startMs || parsedEnd !== cue.endMs) { + onRetime(cue.id, { startMs: parsedStart, endMs: parsedEnd }); + } + }, [cue.endMs, cue.id, cue.startMs, endValue, onRetime, startValue]); + + return ( +
+