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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function CaptionListPanel({
+ cues,
+ selectedCaptionId,
+ currentTimeMs,
+ onBeginCaptionEdit,
+ onCaptionTextEdit,
+ onCaptionRetime,
+ onCaptionSplit,
+ onCaptionMerge,
+ onCaptionDelete,
+}: CaptionListPanelProps) {
+ const index = cues.findIndex((cue) => cue.id === selectedCaptionId);
+ if (index < 0) {
+ return null;
+ }
+
+ const cue = cues[index];
+ const canMerge = index < cues.length - 1;
+
+ return (
+ {
+ if (canMerge) {
+ onCaptionMerge(id, cues[index + 1].id);
+ }
+ }}
+ onDelete={onCaptionDelete}
+ />
+ );
+}
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index fbf390b4..90e2ca8e 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -45,6 +45,8 @@ import { useI18n, useScopedT } from "../../contexts/I18nContext";
import type { AppLocale } from "../../i18n/config";
import { SUPPORTED_LOCALES } from "../../i18n/config";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
+import CaptionListPanel from "./CaptionListPanel";
+import type { CaptionRetimeSpan } from "./captionOps";
import {
CURSOR_MOTION_PRESETS,
type CursorMotionPresetId,
@@ -179,9 +181,10 @@ function isHexWallpaper(value: string): boolean {
function hexToRgba(hex: string, alpha: number) {
const normalized = isHexWallpaper(hex) ? hex : DEFAULT_CURSOR_CLICK_EFFECT_COLOR;
- const value = normalized.length === 4
- ? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
- : normalized;
+ const value =
+ normalized.length === 4
+ ? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
+ : normalized;
const color = Number.parseInt(value.slice(1), 16);
const red = (color >> 16) & 255;
const green = (color >> 8) & 255;
@@ -529,8 +532,23 @@ function CursorClickEffectPreview({
viewBox="0 0 40 40"
aria-hidden="true"
>
-
-
+
+
) : null}
{effect === "ripple" ? (
@@ -571,13 +589,17 @@ function CursorClickEffectPreview({
viewBox="0 0 48 48"
aria-hidden="true"
>
-
+
-
+
) : null}
@@ -660,7 +682,10 @@ function CursorClickEffectCards({
>
@@ -815,6 +840,14 @@ interface SettingsPanelProps {
onClearAutoCaptions?: () => void;
onDownloadWhisperSmallModel?: () => void;
onDeleteWhisperSmallModel?: () => void;
+ captionCurrentTimeMs?: number;
+ selectedCaptionId?: string | null;
+ 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;
nativeCaptureUnavailableSession?: boolean;
onOpenNativeCaptureUnavailableModal?: () => void;
}
@@ -1249,6 +1282,14 @@ export function SettingsPanel({
onClearAutoCaptions,
onDownloadWhisperSmallModel,
onDeleteWhisperSmallModel,
+ captionCurrentTimeMs = 0,
+ selectedCaptionId = null,
+ onBeginCaptionEdit,
+ onCaptionTextEdit,
+ onCaptionRetime,
+ onCaptionSplit,
+ onCaptionMerge,
+ onCaptionDelete,
nativeCaptureUnavailableSession = false,
onOpenNativeCaptureUnavailableModal,
}: SettingsPanelProps) {
@@ -3549,6 +3590,34 @@ export function SettingsPanel({
);
+ const captionSectionContent = (
+
+ {tSettings("sections.caption", "Caption")}
+ {selectedCaptionId !== null ? (
+ onBeginCaptionEdit?.(id)}
+ onCaptionTextEdit={(id, text) => onCaptionTextEdit?.(id, text)}
+ onCaptionRetime={(id, span) => onCaptionRetime?.(id, span)}
+ onCaptionSplit={(id, atMs) => onCaptionSplit?.(id, atMs)}
+ onCaptionMerge={(idA, idB) => onCaptionMerge?.(idA, idB)}
+ onCaptionDelete={(id) => onCaptionDelete?.(id)}
+ />
+ ) : (
+
+
+ {tSettings(
+ "captions.selectOnTimeline",
+ "Select a caption on the timeline to edit it.",
+ )}
+
+
+ )}
+
+ );
+
switch (activeEffectSection) {
case "settings":
return settingsSectionContent;
@@ -3566,6 +3635,8 @@ export function SettingsPanel({
return sceneSectionContent;
case "captions":
return captionsSectionContent;
+ case "caption":
+ return captionSectionContent;
case "cursor":
return (
@@ -3694,12 +3765,15 @@ export function SettingsPanel({
{CLICK_EFFECT_COLOR_OPTIONS.map((color) => {
const isSelected =
- cursorClickEffectColor.toLowerCase() === color.toLowerCase();
+ cursorClickEffectColor.toLowerCase() ===
+ color.toLowerCase();
return (
) : null}
- {activeCaptionLayout && autoCaptionSettings ? (
-
+ {activeCaptionLayout && autoCaptionSettings ? (
+
(
>
{
- event.stopPropagation();
- if (!isCaptionEditing) {
- beginCaptionEdit();
- }
- }}
- onPointerDown={(event) => {
- event.stopPropagation();
- }}
- onKeyDown={(event) => {
- if (!onEditAutoCaption || isCaptionEditing) {
- return;
- }
+ 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();
- }
- }}
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ beginCaptionEdit();
+ }
+ }}
style={{
backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`,
fontFamily: getDefaultCaptionFontFamily(),
@@ -3199,122 +3204,138 @@ const VideoPlayback = forwardRef
(
),
)}px`,
boxSizing: "border-box",
- cursor:
- onEditAutoCaption && !isCaptionEditing ? "text" : undefined,
- pointerEvents: onEditAutoCaption ? "auto" : undefined,
+ cursor:
+ onEditAutoCaption && !isCaptionEditing
+ ? "text"
+ : undefined,
+ pointerEvents: onEditAutoCaption ? "auto" : undefined,
}}
>
- {captionEditSession ? (
-
@@ -3335,32 +3356,44 @@ const VideoPlayback = forwardRef
(
top: annotationRecordingRect.y || 0,
width:
annotationRecordingRect.width ||
- (overlayRef.current?.clientWidth || 800),
+ overlayRef.current?.clientWidth ||
+ 800,
height:
annotationRecordingRect.height ||
- (overlayRef.current?.clientHeight || 600),
+ overlayRef.current?.clientHeight ||
+ 600,
}}
>
{(() => {
- const filtered = (annotationRegions || []).filter((annotation) => {
- if (
- typeof annotation.startMs !== "number" ||
- typeof annotation.endMs !== "number"
- )
- return false;
+ const filtered = (annotationRegions || []).filter(
+ (annotation) => {
+ if (
+ typeof annotation.startMs !== "number" ||
+ typeof annotation.endMs !== "number"
+ )
+ return false;
- if (annotation.id === selectedAnnotationId) return true;
+ if (annotation.id === selectedAnnotationId) return true;
- const timeMs = Math.round(currentTime * 1000);
- return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
- });
+ const timeMs = Math.round(currentTime * 1000);
+ return (
+ timeMs >= annotation.startMs &&
+ timeMs <= annotation.endMs
+ );
+ },
+ );
- const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
+ const sorted = [...filtered].sort(
+ (a, b) => a.zIndex - b.zIndex,
+ );
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
- if (clickedId === selectedAnnotationId && sorted.length > 1) {
+ if (
+ clickedId === selectedAnnotationId &&
+ sorted.length > 1
+ ) {
const currentIndex = sorted.findIndex(
(a) => a.id === clickedId,
);
@@ -3378,21 +3411,25 @@ const VideoPlayback = forwardRef(
isSelected={annotation.id === selectedAnnotationId}
containerWidth={
annotationRecordingRect.width ||
- (overlayRef.current?.clientWidth || 800)
+ overlayRef.current?.clientWidth ||
+ 800
}
containerHeight={
annotationRecordingRect.height ||
- (overlayRef.current?.clientHeight || 600)
+ overlayRef.current?.clientHeight ||
+ 600
}
recordingRect={{
x: 0,
y: 0,
width:
annotationRecordingRect.width ||
- (overlayRef.current?.clientWidth || 800),
+ overlayRef.current?.clientWidth ||
+ 800,
height:
annotationRecordingRect.height ||
- (overlayRef.current?.clientHeight || 600),
+ overlayRef.current?.clientHeight ||
+ 600,
}}
sceneTransform={{ scale: 1, x: 0, y: 0 }}
interactionScale={annotationSceneTransform.scale}
diff --git a/src/components/video-editor/captionEditing.ts b/src/components/video-editor/captionEditing.ts
index 530457a3..c410b47a 100644
--- a/src/components/video-editor/captionEditing.ts
+++ b/src/components/video-editor/captionEditing.ts
@@ -55,7 +55,7 @@ function buildCaptionWordsForEditedText(
});
}
-function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
+export function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
const validSourceWords = Array.isArray(cue.words)
? cue.words.filter(
(word): word is CaptionCueWord =>
@@ -87,14 +87,14 @@ function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
.filter((word) => word.text.length > 0);
}
-function captionWordsToText(words: CaptionCueWord[]) {
+export function captionWordsToText(words: CaptionCueWord[]) {
return words
.map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`)
.join("")
.trim();
}
-function normalizeCaptionWordSpacing(words: CaptionCueWord[]): CaptionCueWord[] {
+export function normalizeCaptionWordSpacing(words: CaptionCueWord[]): CaptionCueWord[] {
return words
.slice()
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs)
diff --git a/src/components/video-editor/captionLayout.ts b/src/components/video-editor/captionLayout.ts
index 9d383294..727e4c44 100644
--- a/src/components/video-editor/captionLayout.ts
+++ b/src/components/video-editor/captionLayout.ts
@@ -131,7 +131,26 @@ function getActiveCaptionCue(cues: CaptionCue[], timeMs: number) {
return null;
}
-function flattenCaptionWords(cues: CaptionCue[]) {
+function isWithinCaptionCoverage(cues: CaptionCue[], timeMs: number) {
+ const sorted = [...cues].sort((left, right) => left.startMs - right.startMs);
+ for (let index = 0; index < sorted.length; index += 1) {
+ const cue = sorted[index];
+ if (timeMs < cue.startMs) {
+ return false;
+ }
+ const next = sorted[index + 1];
+ const bridgesGap =
+ next !== undefined && next.startMs - cue.endMs < CAPTION_BLOCK_GAP_BREAK_MS;
+ const effectiveEndMs = bridgesGap ? Math.max(cue.endMs, next.startMs) : cue.endMs;
+ if (timeMs <= effectiveEndMs) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+export function flattenCaptionWords(cues: CaptionCue[]) {
const flattened: Array<{
cueId: string;
cueWordIndex: number;
@@ -421,6 +440,10 @@ export function buildActiveCaptionLayout(options: {
return null;
}
+ if (!isWithinCaptionCoverage(options.cues, options.timeMs)) {
+ return null;
+ }
+
let activeWordIndex = -1;
activeWordIndex = sourceWords.findIndex(
(word) => options.timeMs >= word.startMs && options.timeMs < word.endMs,
diff --git a/src/components/video-editor/captionOps.test.ts b/src/components/video-editor/captionOps.test.ts
new file mode 100644
index 00000000..047b242c
--- /dev/null
+++ b/src/components/video-editor/captionOps.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from "vitest";
+import { flattenCaptionWords } from "./captionLayout";
+import { deleteCue, mergeCues, retimeCue, splitCue } from "./captionOps";
+import type { CaptionCue } from "./types";
+
+function makeCues(): CaptionCue[] {
+ return [
+ {
+ id: "a",
+ startMs: 0,
+ endMs: 2_000,
+ text: "one two three four",
+ words: [
+ { text: "one", startMs: 0, endMs: 500 },
+ { text: "two", startMs: 500, endMs: 1_000, leadingSpace: true },
+ { text: "three", startMs: 1_000, endMs: 1_500, leadingSpace: true },
+ { text: "four", startMs: 1_500, endMs: 2_000, leadingSpace: true },
+ ],
+ },
+ {
+ id: "b",
+ startMs: 2_200,
+ endMs: 3_000,
+ text: "five six",
+ words: [
+ { text: "five", startMs: 2_200, endMs: 2_600 },
+ { text: "six", startMs: 2_600, endMs: 3_000, leadingSpace: true },
+ ],
+ },
+ ];
+}
+
+function assertCuesValid(cues: CaptionCue[]) {
+ for (let index = 0; index < cues.length - 1; index += 1) {
+ expect(cues[index].startMs).toBeLessThanOrEqual(cues[index + 1].startMs);
+ expect(cues[index].endMs).toBeLessThanOrEqual(cues[index + 1].startMs);
+ }
+
+ const words = flattenCaptionWords(cues);
+ for (let index = 0; index < words.length; index += 1) {
+ expect(words[index].endMs).toBeGreaterThan(words[index].startMs);
+ if (index > 0) {
+ expect(words[index].startMs).toBeGreaterThanOrEqual(words[index - 1].startMs);
+ expect(words[index].startMs).toBeGreaterThanOrEqual(words[index - 1].endMs);
+ }
+ }
+}
+
+describe("captionOps.retimeCue", () => {
+ it("extends end and rescales word timings proportionally", () => {
+ const single: CaptionCue[] = [makeCues()[0]];
+ const result = retimeCue(single, "a", { startMs: 0, endMs: 4_000 });
+ const cue = result.find((value) => value.id === "a");
+ expect(cue?.endMs).toBe(4_000);
+ expect(cue?.words?.[cue.words.length - 1].endMs).toBe(4_000);
+ expect(cue?.words?.[1].startMs).toBe(1_000);
+ expect(cue?.text).toBe("one two three four");
+ assertCuesValid(result);
+ });
+
+ it("honors requested timing without clamping to neighbors", () => {
+ const result = retimeCue(makeCues(), "a", { startMs: 1_000, endMs: 2_500 });
+ const cue = result.find((value) => value.id === "a");
+ const neighbor = result.find((value) => value.id === "b");
+ expect(cue?.startMs).toBe(1_000);
+ expect(cue?.endMs).toBe(2_500);
+ expect(cue?.words?.[0].startMs).toBe(1_000);
+ expect(cue?.words?.[cue.words.length - 1].endMs).toBe(2_500);
+ expect(neighbor?.startMs).toBe(2_200);
+ });
+
+ it("returns cue-level timing when the cue has no words", () => {
+ const cues: CaptionCue[] = [{ id: "a", startMs: 0, endMs: 1_000, text: "hello" }];
+ const result = retimeCue(cues, "a", { startMs: 200, endMs: 1_500 });
+ expect(result[0].startMs).toBe(200);
+ expect(result[0].endMs).toBe(1_500);
+ expect(result[0].words).toBeUndefined();
+ });
+
+ it("is a no-op for an unknown id", () => {
+ const cues = makeCues();
+ expect(retimeCue(cues, "missing", { startMs: 0, endMs: 100 })).toBe(cues);
+ });
+});
+
+describe("captionOps.splitCue", () => {
+ it("splits at the nearest word boundary into two cues", () => {
+ const result = splitCue(makeCues(), "a", 1_000);
+ expect(result).toHaveLength(3);
+ const [left, right] = result;
+ expect(left.id).toBe("a");
+ expect(left.text).toBe("one two");
+ expect(right.id).not.toBe("a");
+ expect(right.text).toBe("three four");
+ expect(right.words?.[0].leadingSpace).toBeUndefined();
+ expect(left.endMs).toBeLessThanOrEqual(right.startMs);
+ assertCuesValid(result);
+ });
+
+ it("does not split a single-word cue", () => {
+ const cues: CaptionCue[] = [
+ {
+ id: "solo",
+ startMs: 0,
+ endMs: 500,
+ text: "word",
+ words: [{ text: "word", startMs: 0, endMs: 500 }],
+ },
+ ];
+ expect(splitCue(cues, "solo", 250)).toBe(cues);
+ });
+});
+
+describe("captionOps.mergeCues", () => {
+ it("merges adjacent cues and re-derives spacing", () => {
+ const result = mergeCues(makeCues(), "a", "b");
+ expect(result).toHaveLength(1);
+ const merged = result[0];
+ expect(merged.id).toBe("a");
+ expect(merged.startMs).toBe(0);
+ expect(merged.endMs).toBe(3_000);
+ expect(merged.text).toBe("one two three four five six");
+ expect(merged.words?.[4].leadingSpace).toBe(true);
+ assertCuesValid(result);
+ });
+
+ it("rejects a non-adjacent merge", () => {
+ const cues: CaptionCue[] = [
+ ...makeCues(),
+ {
+ id: "c",
+ startMs: 4_000,
+ endMs: 5_000,
+ text: "seven",
+ words: [{ text: "seven", startMs: 4_000, endMs: 5_000 }],
+ },
+ ];
+ expect(mergeCues(cues, "a", "c")).toBe(cues);
+ });
+});
+
+describe("captionOps.deleteCue", () => {
+ it("removes the cue and keeps the rest sorted", () => {
+ const result = deleteCue(makeCues(), "a");
+ expect(result.map((cue) => cue.id)).toEqual(["b"]);
+ });
+
+ it("is a no-op for an unknown id", () => {
+ const cues = makeCues();
+ expect(deleteCue(cues, "missing")).toBe(cues);
+ });
+});
diff --git a/src/components/video-editor/captionOps.ts b/src/components/video-editor/captionOps.ts
new file mode 100644
index 00000000..5d2324f3
--- /dev/null
+++ b/src/components/video-editor/captionOps.ts
@@ -0,0 +1,189 @@
+import {
+ captionWordsToText,
+ normalizeCaptionWordSpacing,
+ normalizeCaptionWords,
+} from "./captionEditing";
+import type { CaptionCue, CaptionCueWord } from "./types";
+
+export interface CaptionRetimeSpan {
+ startMs: number;
+ endMs: number;
+}
+
+function sortCaptionCues(cues: CaptionCue[]): CaptionCue[] {
+ return [...cues].sort(
+ (left, right) => left.startMs - right.startMs || left.endMs - right.endMs,
+ );
+}
+
+function createCaptionCueId(): string {
+ return `caption-${globalThis.crypto.randomUUID()}`;
+}
+
+function rescaleWordsIntoSpan(
+ words: CaptionCueWord[],
+ oldStartMs: number,
+ oldEndMs: number,
+ newStartMs: number,
+ newEndMs: number,
+): CaptionCueWord[] {
+ const oldSpan = oldEndMs - oldStartMs;
+ const newSpan = newEndMs - newStartMs;
+ const factor = oldSpan > 0 ? newSpan / oldSpan : 0;
+
+ let cursorMs = newStartMs;
+ const rescaled: CaptionCueWord[] = [];
+
+ words.forEach((word, index) => {
+ const mappedStartMs =
+ oldSpan > 0
+ ? newStartMs + (word.startMs - oldStartMs) * factor
+ : newStartMs + (newSpan * index) / Math.max(1, words.length);
+ const mappedEndMs =
+ oldSpan > 0
+ ? newStartMs + (word.endMs - oldStartMs) * factor
+ : newStartMs + (newSpan * (index + 1)) / Math.max(1, words.length);
+
+ const startMs = Math.min(newEndMs - 1, Math.max(cursorMs, Math.round(mappedStartMs)));
+ const endMs = Math.min(newEndMs, Math.max(startMs + 1, Math.round(mappedEndMs)));
+ cursorMs = endMs;
+
+ rescaled.push({
+ text: word.text,
+ startMs,
+ endMs,
+ ...(word.leadingSpace ? { leadingSpace: true } : {}),
+ });
+ });
+
+ return normalizeCaptionWordSpacing(rescaled);
+}
+
+export function retimeCue(cues: CaptionCue[], id: string, span: CaptionRetimeSpan): CaptionCue[] {
+ const sorted = sortCaptionCues(cues);
+ const index = sorted.findIndex((cue) => cue.id === id);
+ if (index < 0) {
+ return cues;
+ }
+
+ const cue = sorted[index];
+ const newStartMs = Math.max(0, Math.round(span.startMs));
+ const newEndMs = Math.max(newStartMs + 1, Math.round(span.endMs));
+
+ if (newStartMs === cue.startMs && newEndMs === cue.endMs) {
+ return cues;
+ }
+
+ const hasWords = Array.isArray(cue.words) && cue.words.length > 0;
+ const nextWords = hasWords
+ ? rescaleWordsIntoSpan(
+ normalizeCaptionWords(cue),
+ cue.startMs,
+ cue.endMs,
+ newStartMs,
+ newEndMs,
+ )
+ : null;
+
+ const nextCueValue: CaptionCue = {
+ id: cue.id,
+ startMs: newStartMs,
+ endMs: newEndMs,
+ text: nextWords ? captionWordsToText(nextWords) : cue.text,
+ ...(nextWords ? { words: nextWords } : {}),
+ };
+
+ const nextCues = sorted.map((value, valueIndex) =>
+ valueIndex === index ? nextCueValue : value,
+ );
+ return sortCaptionCues(nextCues);
+}
+
+export function splitCue(cues: CaptionCue[], id: string, atMs: number): CaptionCue[] {
+ const sorted = sortCaptionCues(cues);
+ const index = sorted.findIndex((cue) => cue.id === id);
+ if (index < 0) {
+ return cues;
+ }
+
+ const cue = sorted[index];
+ const words = normalizeCaptionWords(cue);
+ if (words.length < 2) {
+ return cues;
+ }
+
+ let splitIndex = 1;
+ let nearestDistance = Number.POSITIVE_INFINITY;
+ for (let candidate = 1; candidate < words.length; candidate += 1) {
+ const boundaryMs = (words[candidate - 1].endMs + words[candidate].startMs) / 2;
+ const distance = Math.abs(boundaryMs - atMs);
+ if (distance < nearestDistance) {
+ nearestDistance = distance;
+ splitIndex = candidate;
+ }
+ }
+
+ const leftWords = normalizeCaptionWordSpacing(words.slice(0, splitIndex));
+ const rightWords = normalizeCaptionWordSpacing(words.slice(splitIndex));
+
+ const leftCue: CaptionCue = {
+ id: cue.id,
+ startMs: cue.startMs,
+ endMs: leftWords[leftWords.length - 1].endMs,
+ text: captionWordsToText(leftWords),
+ words: leftWords,
+ };
+ const rightCue: CaptionCue = {
+ id: createCaptionCueId(),
+ startMs: rightWords[0].startMs,
+ endMs: cue.endMs,
+ text: captionWordsToText(rightWords),
+ words: rightWords,
+ };
+
+ const nextCues = sorted.flatMap((value, valueIndex) =>
+ valueIndex === index ? [leftCue, rightCue] : [value],
+ );
+ return sortCaptionCues(nextCues);
+}
+
+export function mergeCues(cues: CaptionCue[], idA: string, idB: string): CaptionCue[] {
+ const sorted = sortCaptionCues(cues);
+ const indexA = sorted.findIndex((cue) => cue.id === idA);
+ const indexB = sorted.findIndex((cue) => cue.id === idB);
+ if (indexA < 0 || indexB < 0 || Math.abs(indexA - indexB) !== 1) {
+ return cues;
+ }
+
+ const leftIndex = Math.min(indexA, indexB);
+ const left = sorted[leftIndex];
+ const right = sorted[leftIndex + 1];
+ const mergedWords = normalizeCaptionWordSpacing([
+ ...normalizeCaptionWords(left),
+ ...normalizeCaptionWords(right),
+ ]);
+
+ const mergedCue: CaptionCue = {
+ id: left.id,
+ startMs: left.startMs,
+ endMs: right.endMs,
+ text: captionWordsToText(mergedWords),
+ words: mergedWords,
+ };
+
+ const nextCues = sorted.flatMap((value, valueIndex) => {
+ if (valueIndex === leftIndex) {
+ return [mergedCue];
+ }
+ if (valueIndex === leftIndex + 1) {
+ return [];
+ }
+ return [value];
+ });
+ return sortCaptionCues(nextCues);
+}
+
+export function deleteCue(cues: CaptionCue[], id: string): CaptionCue[] {
+ const next = cues.filter((cue) => cue.id !== id);
+ return next.length === cues.length ? cues : sortCaptionCues(next);
+}
diff --git a/src/components/video-editor/captionStyle.ts b/src/components/video-editor/captionStyle.ts
index 65ae376b..6a4ed6c6 100644
--- a/src/components/video-editor/captionStyle.ts
+++ b/src/components/video-editor/captionStyle.ts
@@ -47,30 +47,11 @@ export function getCaptionTextMaxWidth(
);
}
-export function getCaptionWordVisualState(hasWordTimings: boolean, state: CaptionWordState) {
- if (!hasWordTimings) {
- return {
- isInactive: false,
- opacity: 1,
- };
- }
-
- switch (state) {
- case "upcoming":
- return {
- isInactive: true,
- opacity: 0.82,
- };
- case "spoken":
- return {
- isInactive: false,
- opacity: 0.72,
- };
- case "active":
- default:
- return {
- isInactive: false,
- opacity: 1,
- };
- }
+export function getCaptionWordVisualState(_hasWordTimings: boolean, _state: CaptionWordState) {
+ // Per-word "spoken" highlighting is disabled: word-level timings from the
+ // transcriber are unreliable, so captions render as a single uniform block.
+ return {
+ isInactive: false,
+ opacity: 1,
+ };
}
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index 5d335b8e..7dff788b 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -35,7 +35,7 @@ interface ItemProps {
waveformGain?: number;
waveformNormalize?: boolean;
muted?: boolean;
- variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
+ variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | "caption";
isLoading?: boolean;
loadingLabel?: string;
}
@@ -125,6 +125,7 @@ export default function Item({
const isClip = variant === "clip";
const isSpeed = variant === "speed";
const isAudio = variant === "audio";
+ const isCaption = variant === "caption";
const showAudioWaveform = isAudio && Boolean(waveformPeaks);
const clipSpeedLabel = isClip ? formatClipSpeedLabel(speedValue ?? 1) : null;
@@ -138,7 +139,9 @@ export default function Item({
? glassStyles.glassAmber
: isAudio
? glassStyles.glassDarkGreen
- : glassStyles.glassYellow;
+ : isCaption
+ ? glassStyles.glassCaption
+ : glassStyles.glassYellow;
const MIN_ITEM_PX = 6;
const handleSelect = () => {
diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css
index 64256bb4..1ad9fdd0 100644
--- a/src/components/video-editor/timeline/ItemGlass.module.css
+++ b/src/components/video-editor/timeline/ItemGlass.module.css
@@ -152,6 +152,28 @@
z-index: 10;
}
+.glassCaption {
+ position: relative;
+ border-radius: 8px;
+ background: #5b2746;
+ border: 1px solid #7a3157;
+ transition:
+ background 0.15s ease,
+ border-color 0.15s ease;
+}
+
+.glassCaption:hover {
+ background: #6d2f54;
+ border-color: #933a68;
+}
+
+.glassCaption.selected {
+ background: #7a3157;
+ border-color: #f472b6;
+ box-shadow: inset 0 0 0 1.5px #f472b6;
+ z-index: 10;
+}
+
/* Pill resize handle — visible on hover */
.zoomEndCap {
position: absolute;
@@ -272,6 +294,20 @@
box-shadow: inset 0 0 0 1.5px #22c55e;
}
+:global(:root:not(.dark)) .glassCaption {
+ background: linear-gradient(180deg, #fce7f3 0%, #f9a8d4 100%);
+ border-color: #f472b6;
+}
+:global(:root:not(.dark)) .glassCaption:hover {
+ background: linear-gradient(180deg, #fbcfe8 0%, #f472b6 100%);
+ border-color: #ec4899;
+}
+:global(:root:not(.dark)) .glassCaption.selected {
+ background: linear-gradient(180deg, #f9a8d4 0%, #ec4899 100%);
+ border-color: #ec4899;
+ box-shadow: inset 0 0 0 1.5px #ec4899;
+}
+
:global(:root:not(.dark)) .zoomEndCap {
background: rgba(0, 0, 0, 0.5);
}
@@ -289,7 +325,8 @@
.glassAmber:hover .zoomEndCap,
.glassPurple:hover .zoomEndCap,
.glassCyan:hover .zoomEndCap,
-.glassDarkGreen:hover .zoomEndCap {
+.glassDarkGreen:hover .zoomEndCap,
+.glassCaption:hover .zoomEndCap {
opacity: 1;
}
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 041bc318..2b353b29 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -11,6 +11,7 @@ import { fromFileUrl } from "../projectPersistence";
import type {
AnnotationRegion,
AudioRegion,
+ CaptionCue,
ClipRegion,
CursorTelemetryPoint,
SpeedRegion,
@@ -68,6 +69,10 @@ export interface TimelineEditorProps {
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
+ captionRegions?: CaptionCue[];
+ onCaptionSpanChange?: (id: string, span: Span) => void;
+ selectedCaptionId?: string | null;
+ onSelectCaption?: (id: string | null) => void;
videoPath?: string | null;
videoSourcePath?: string | null;
cursorTelemetrySourcePath?: string | null;
@@ -142,6 +147,10 @@ const TimelineEditor = forwardRef(
onAudioDelete,
selectedAudioId,
onSelectAudio,
+ captionRegions = [],
+ onCaptionSpanChange,
+ selectedCaptionId,
+ onSelectCaption,
videoPath,
videoSourcePath,
cursorTelemetrySourcePath,
@@ -361,6 +370,8 @@ const TimelineEditor = forwardRef(
onAudioDelete,
selectedAudioId,
onSelectAudio,
+ captionCues: captionRegions,
+ onCaptionSpanChange,
isMac,
keyShortcuts,
isTimelineFocusedRef,
@@ -453,10 +464,12 @@ const TimelineEditor = forwardRef(
onSelectClip={handleSelectClip}
onSelectAnnotation={handleSelectAnnotation}
onSelectAudio={handleSelectAudio}
+ onSelectCaption={onSelectCaption}
selectedZoomId={selectedZoomId}
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
selectedAudioId={selectedAudioId}
+ selectedCaptionId={selectedCaptionId}
selectAllBlocksActive={selectAllBlocksActive}
onClearBlockSelection={clearSelectedBlocks}
keyframes={keyframes}
diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
index 6fac793f..4e0e61bd 100644
--- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
+++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
@@ -15,7 +15,12 @@ import type {
SourceAudioTrackWithPeaks,
} from "@/components/video-editor/audio/audioTypes";
import { cn } from "@/lib/utils";
-import { CLIP_ROW_ID, SOURCE_AUDIO_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
+import {
+ CAPTION_ROW_ID,
+ CLIP_ROW_ID,
+ SOURCE_AUDIO_ROW_ID,
+ ZOOM_ROW_ID,
+} from "../../core/constants";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
@@ -53,11 +58,13 @@ interface TimelineCanvasProps {
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
+ onSelectCaption?: (id: string | null) => void;
onAddZoomAtMs?: (startMs: number) => void;
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
+ selectedCaptionId?: string | null;
selectAllBlocksActive?: boolean;
onClearBlockSelection?: () => void;
keyframes?: { id: string; time: number }[];
@@ -231,10 +238,12 @@ interface TimelineCanvasRowsProps {
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
+ selectedCaptionId?: string | null;
onSelectZoom?: (id: string | null) => void;
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
+ onSelectCaption?: (id: string | null) => void;
sourceAudioTracks?: SourceAudioTrackWithPeaks[];
getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings;
showSourceAudioTrack?: boolean;
@@ -298,10 +307,12 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
selectedClipId,
selectedAnnotationId,
selectedAudioId,
+ selectedCaptionId,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
+ onSelectCaption,
sourceAudioTracks = [],
getSourceAudioTrackSettingsForClip,
showSourceAudioTrack = false,
@@ -319,9 +330,10 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
onZoomRowClick,
}: TimelineCanvasRowsProps) {
const hiddenIds = useMemo(() => new Set(liveHiddenItemIds ?? []), [liveHiddenItemIds]);
- const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => {
+ const { clipItems, zoomItems, captionItems, annotationRows, audioRows } = useMemo(() => {
const nextClipItems: TimelineRenderItem[] = [];
const nextZoomItems: TimelineRenderItem[] = [];
+ const nextCaptionItems: TimelineRenderItem[] = [];
const annotationBuckets = new Map();
const audioBuckets = new Map();
@@ -334,6 +346,10 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
nextZoomItems.push(item);
continue;
}
+ if (item.rowId === CAPTION_ROW_ID) {
+ nextCaptionItems.push(item);
+ continue;
+ }
if (isAnnotationTrackRowId(item.rowId)) {
const trackIndex = getAnnotationTrackIndex(item.rowId);
const bucket = annotationBuckets.get(trackIndex);
@@ -365,6 +381,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
return {
clipItems: nextClipItems,
zoomItems: nextZoomItems,
+ captionItems: nextCaptionItems,
annotationRows: annotationRowsSorted,
audioRows: audioRowsSorted,
};
@@ -480,6 +497,24 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
))}
+ {captionItems.length > 0 && (
+
+ {captionItems.map((item) => (
+ -
+ {item.label}
+
+ ))}
+
+ )}
+
{annotationRows.map(({ rowId, items: rowItems }, index) => (
{
const annotationRowIds = new Set();
const audioRowIds = new Set();
+ let hasCaptionRow = false;
for (const item of items) {
if (isAnnotationTrackRowId(item.rowId)) annotationRowIds.add(item.rowId);
if (isAudioTrackRowId(item.rowId)) audioRowIds.add(item.rowId);
+ if (item.rowId === CAPTION_ROW_ID) hasCaptionRow = true;
}
const sourceAudioRows = showSourceAudioTrack ? sourceAudioTracks.length : 0;
- return 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size;
+ return (
+ 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size + (hasCaptionRow ? 1 : 0)
+ );
}, [items, showSourceAudioTrack, sourceAudioTracks.length]);
const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount);
const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount);
@@ -786,10 +831,12 @@ export default function TimelineCanvas({
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
selectedAudioId={selectedAudioId}
+ selectedCaptionId={selectedCaptionId}
onSelectZoom={onSelectZoom}
onSelectClip={onSelectClip}
onSelectAnnotation={onSelectAnnotation}
onSelectAudio={onSelectAudio}
+ onSelectCaption={onSelectCaption}
sourceAudioTracks={sourceAudioTracks}
getSourceAudioTrackSettingsForClip={getSourceAudioTrackSettingsForClip}
showSourceAudioTrack={showSourceAudioTrack}
diff --git a/src/components/video-editor/timeline/core/constants.ts b/src/components/video-editor/timeline/core/constants.ts
index 88acba23..97508022 100644
--- a/src/components/video-editor/timeline/core/constants.ts
+++ b/src/components/video-editor/timeline/core/constants.ts
@@ -3,6 +3,7 @@ export const CLIP_ROW_ID = "row-clip";
export const ANNOTATION_ROW_ID = "row-annotation";
export const AUDIO_ROW_ID = "row-audio";
export const SOURCE_AUDIO_ROW_ID = "row-source-audio";
+export const CAPTION_ROW_ID = "row-captions";
export const ANNOTATION_ROW_PREFIX = `${ANNOTATION_ROW_ID}-`;
export const AUDIO_ROW_PREFIX = `${AUDIO_ROW_ID}-`;
diff --git a/src/components/video-editor/timeline/core/timelineTypes.ts b/src/components/video-editor/timeline/core/timelineTypes.ts
index d1f705d7..881ec39c 100644
--- a/src/components/video-editor/timeline/core/timelineTypes.ts
+++ b/src/components/video-editor/timeline/core/timelineTypes.ts
@@ -1,5 +1,5 @@
-import type { ShortcutBinding } from "@/lib/shortcuts";
import type { Span } from "dnd-timeline";
+import type { ShortcutBinding } from "@/lib/shortcuts";
import type { ZoomMode } from "../../types";
export interface TimelineRegionSpan {
@@ -41,7 +41,7 @@ export interface TimelineRenderItem {
speedValue?: number;
showSourceAudio?: boolean;
muted?: boolean;
- variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
+ variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | "caption";
}
export interface AudioPeaksData {
diff --git a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
index c49fc338..4c570337 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
@@ -3,14 +3,20 @@ import { useCallback, useMemo } from "react";
import type {
AnnotationRegion,
AudioRegion,
+ CaptionCue,
ClipRegion,
SpeedRegion,
TrimRegion,
ZoomRegion,
} from "../../types";
-import type { TimelineRenderItem } from "../core/timelineTypes";
-import { getAnnotationTrackIndex, getAudioTrackIndex, isAnnotationTrackRowId, isAudioTrackRowId } from "../core/rows";
+import {
+ getAnnotationTrackIndex,
+ getAudioTrackIndex,
+ isAnnotationTrackRowId,
+ isAudioTrackRowId,
+} from "../core/rows";
import { spansOverlap } from "../core/spans";
+import type { TimelineRenderItem } from "../core/timelineTypes";
import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "../model/timelineModel";
interface UseTimelineDndBindingsParams {
@@ -20,15 +26,25 @@ interface UseTimelineDndBindingsParams {
annotationRegions: AnnotationRegion[];
speedRegions: SpeedRegion[];
audioRegions: AudioRegion[];
+ captionCues: CaptionCue[];
onZoomSpanChange: (id: string, span: Span) => void;
onTrimSpanChange?: (id: string, span: Span) => void;
onClipSpanChange?: (id: string, span: Span) => void;
onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
+ onCaptionSpanChange?: (id: string, span: Span) => void;
}
-type TimelineItemKind = "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | null;
+type TimelineItemKind =
+ | "zoom"
+ | "trim"
+ | "clip"
+ | "annotation"
+ | "speed"
+ | "audio"
+ | "caption"
+ | null;
export function useTimelineDndBindings({
zoomRegions,
@@ -37,12 +53,14 @@ export function useTimelineDndBindings({
annotationRegions,
speedRegions,
audioRegions,
+ captionCues,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
+ onCaptionSpanChange,
}: UseTimelineDndBindingsParams) {
const resolveItemKind = useCallback(
(id: string): TimelineItemKind => {
@@ -52,9 +70,18 @@ export function useTimelineDndBindings({
if (annotationRegions.some((r) => r.id === id)) return "annotation";
if (speedRegions.some((r) => r.id === id)) return "speed";
if (audioRegions.some((r) => r.id === id)) return "audio";
+ if (captionCues.some((c) => c.id === id)) return "caption";
return null;
},
- [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions],
+ [
+ zoomRegions,
+ trimRegions,
+ clipRegions,
+ annotationRegions,
+ speedRegions,
+ audioRegions,
+ captionCues,
+ ],
);
const resolveTrackIndex = useCallback(
@@ -76,7 +103,7 @@ export function useTimelineDndBindings({
if (!excludeId) return false;
const itemKind = resolveItemKind(excludeId);
- if (itemKind === "annotation") return false;
+ if (itemKind === "annotation" || itemKind === "caption") return false;
const checkOverlap = (
regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[],
@@ -118,8 +145,9 @@ export function useTimelineDndBindings({
clipRegions,
annotationRegions,
audioRegions,
+ captionCues,
}),
- [zoomRegions, clipRegions, annotationRegions, audioRegions],
+ [zoomRegions, clipRegions, annotationRegions, audioRegions, captionCues],
);
const allRegionSpans = useMemo(
@@ -154,6 +182,8 @@ export function useTimelineDndBindings({
} else if (itemKind === "audio") {
const nextTrackIndex = resolveTrackIndex("audio", id, rowId);
onAudioSpanChange?.(id, span, nextTrackIndex);
+ } else if (itemKind === "caption") {
+ onCaptionSpanChange?.(id, span);
}
},
[
@@ -165,6 +195,7 @@ export function useTimelineDndBindings({
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
+ onCaptionSpanChange,
],
);
diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
index 7cf3c162..865f01a1 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
@@ -4,6 +4,7 @@ import { useCallback, useImperativeHandle } from "react";
import type {
AnnotationRegion,
AudioRegion,
+ CaptionCue,
ClipRegion,
CursorTelemetryPoint,
SpeedRegion,
@@ -59,6 +60,8 @@ interface UseTimelineEditorRuntimeParams {
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
+ captionCues: CaptionCue[];
+ onCaptionSpanChange?: (id: string, span: Span) => void;
isMac: boolean;
keyShortcuts: TimelineShortcutBindings;
isTimelineFocusedRef: RefObject;
@@ -103,6 +106,8 @@ export function useTimelineEditorRuntime({
onAudioDelete,
selectedAudioId,
onSelectAudio,
+ captionCues,
+ onCaptionSpanChange,
isMac,
keyShortcuts,
isTimelineFocusedRef,
@@ -175,12 +180,14 @@ export function useTimelineEditorRuntime({
annotationRegions,
speedRegions,
audioRegions,
+ captionCues,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
+ onCaptionSpanChange,
});
const {
diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts
index 60737062..e1589fd6 100644
--- a/src/components/video-editor/timeline/model/timelineModel.ts
+++ b/src/components/video-editor/timeline/model/timelineModel.ts
@@ -2,10 +2,11 @@ import { formatClipSpeedLabel } from "../../clipSpeedChange";
import type {
AnnotationRegion,
AudioRegion,
+ CaptionCue,
ClipRegion,
ZoomRegion,
} from "../../types";
-import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
+import { CAPTION_ROW_ID, CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
@@ -28,7 +29,17 @@ export function getAnnotationLabel(region: AnnotationRegion): string {
}
export function getAudioLabel(region: AudioRegion): string {
- return region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") || "Audio";
+ return (
+ region.audioPath
+ .split(/[\\/]/)
+ .pop()
+ ?.replace(/\.[^.]+$/, "") || "Audio"
+ );
+}
+
+function getCaptionLabel(cue: CaptionCue): string {
+ const preview = cue.text.trim() || "Caption";
+ return preview.length > 24 ? `${preview.substring(0, 24)}...` : preview;
}
export function buildTimelineItems(params: {
@@ -36,8 +47,9 @@ export function buildTimelineItems(params: {
clipRegions: ClipRegion[];
annotationRegions: AnnotationRegion[];
audioRegions: AudioRegion[];
+ captionCues?: CaptionCue[];
}): TimelineRenderItem[] {
- const { zoomRegions, clipRegions, annotationRegions, audioRegions } = params;
+ const { zoomRegions, clipRegions, annotationRegions, audioRegions, captionCues = [] } = params;
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
id: region.id,
rowId: ZOOM_ROW_ID,
@@ -86,7 +98,15 @@ export function buildTimelineItems(params: {
variant: "audio",
}));
- return [...zooms, ...clips, ...annotations, ...audios];
+ const captions: TimelineRenderItem[] = captionCues.map((cue) => ({
+ id: cue.id,
+ rowId: CAPTION_ROW_ID,
+ span: { start: cue.startMs, end: cue.endMs },
+ label: getCaptionLabel(cue),
+ variant: "caption",
+ }));
+
+ return [...zooms, ...clips, ...annotations, ...audios, ...captions];
}
export function buildAllRegionSpans(params: {
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index 4f839b77..6ed6c9b4 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -103,6 +103,7 @@ export type EditorEffectSection =
| "scene"
| "cursor"
| "captions"
+ | "caption"
| "webcam"
| "settings"
| "zoom"
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 0adb90df..3e1d2be1 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -158,6 +158,7 @@
"sections": {
"scene": "Scene",
"captions": "Captions",
+ "caption": "Caption",
"zoom": "Zoom",
"cursor": "Cursor",
"webcam": "Webcam",
@@ -165,6 +166,7 @@
"crop": "Crop"
},
"captions": {
+ "selectOnTimeline": "Select a caption on the timeline to edit it.",
"enabled": "Show",
"language": "Language",
"downloading": "Downloading...",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index f0a51b3a..c2e1c7bd 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -110,6 +110,7 @@
"sections": {
"scene": "Escena",
"captions": "Subtítulos",
+ "caption": "Subtítulo",
"zoom": "Zoom",
"cursor": "Cursor",
"webcam": "Cámara",
@@ -117,6 +118,7 @@
"crop": "Recorte"
},
"captions": {
+ "selectOnTimeline": "Selecciona un subtítulo en la línea de tiempo para editarlo.",
"enabled": "Mostrar",
"language": "Idioma",
"downloading": "Descargando...",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 2db7d2f0..a497c28d 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -110,6 +110,7 @@
"sections": {
"scene": "Scène",
"captions": "Sous-titres",
+ "caption": "Sous-titre",
"zoom": "Zoom",
"cursor": "Curseur",
"webcam": "Webcam",
@@ -117,6 +118,7 @@
"crop": "Recadrage"
},
"captions": {
+ "selectOnTimeline": "Sélectionnez un sous-titre sur la timeline pour le modifier.",
"enabled": "Afficher",
"language": "Langue",
"downloading": "Téléchargement...",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 5b5b9c27..4d2e9234 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -132,6 +132,7 @@
"sections": {
"scene": "Scena",
"captions": "Sottotitoli",
+ "caption": "Sottotitolo",
"zoom": "Zoom",
"cursor": "Cursore",
"webcam": "Webcam",
@@ -139,6 +140,7 @@
"crop": "Ritaglio"
},
"captions": {
+ "selectOnTimeline": "Seleziona un sottotitolo sulla timeline per modificarlo.",
"enabled": "Mostra",
"language": "Lingua",
"downloading": "Download in corso...",
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index 5df1385f..d779b16e 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -110,6 +110,7 @@
"sections": {
"scene": "장면",
"captions": "자막",
+ "caption": "자막",
"zoom": "확대",
"cursor": "커서",
"webcam": "웹캠",
@@ -117,6 +118,7 @@
"crop": "자르기"
},
"captions": {
+ "selectOnTimeline": "타임라인에서 자막을 선택하여 편집하세요.",
"enabled": "표시",
"language": "언어",
"downloading": "다운로드 중...",
diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json
index 3f9409f0..dea24ea3 100644
--- a/src/i18n/locales/nl/settings.json
+++ b/src/i18n/locales/nl/settings.json
@@ -110,6 +110,7 @@
"sections": {
"scene": "Scène",
"captions": "Ondertiteling",
+ "caption": "Ondertitel",
"zoom": "Zoom",
"cursor": "Cursor",
"webcam": "Webcam",
@@ -117,6 +118,7 @@
"crop": "Bijsnijden"
},
"captions": {
+ "selectOnTimeline": "Selecteer een ondertitel op de tijdlijn om deze te bewerken.",
"enabled": "Tonen",
"language": "Taal",
"downloading": "Downloaden...",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index f543ce43..f0f4e558 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -110,6 +110,7 @@
"sections": {
"scene": "Cena",
"captions": "Legendas",
+ "caption": "Legenda",
"zoom": "Zoom",
"cursor": "Cursor",
"webcam": "Webcam",
@@ -117,6 +118,7 @@
"crop": "Corte"
},
"captions": {
+ "selectOnTimeline": "Selecione uma legenda na linha do tempo para editá-la.",
"enabled": "Mostrar",
"language": "Idioma",
"downloading": "Baixando...",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index 2768d995..4637398b 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -132,6 +132,7 @@
"sections": {
"scene": "Сцена",
"captions": "Субтитры",
+ "caption": "Субтитр",
"zoom": "Зум",
"cursor": "Курсор",
"webcam": "Веб-камера",
@@ -139,6 +140,7 @@
"crop": "Обрезать видео"
},
"captions": {
+ "selectOnTimeline": "Выберите субтитры на таймлайне, чтобы отредактировать их.",
"enabled": "Показать",
"language": "Язык",
"downloading": "Загрузка...",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 1ae88c02..79dfbc95 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -127,6 +127,7 @@
"sections": {
"scene": "场景",
"captions": "字幕",
+ "caption": "字幕",
"zoom": "缩放",
"cursor": "光标",
"webcam": "摄像头",
@@ -134,6 +135,7 @@
"crop": "裁剪"
},
"captions": {
+ "selectOnTimeline": "在时间轴上选择字幕进行编辑。",
"enabled": "显示",
"language": "语言",
"downloading": "下载中...",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index a2f78ae3..8fdfcb82 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -110,6 +110,7 @@
"sections": {
"scene": "場景",
"captions": "字幕",
+ "caption": "字幕",
"zoom": "縮放",
"cursor": "游標",
"webcam": "網路攝影機",
@@ -117,6 +118,7 @@
"crop": "裁切"
},
"captions": {
+ "selectOnTimeline": "在時間軸上選擇字幕進行編輯。",
"enabled": "顯示",
"language": "語言",
"downloading": "下載中...",