diff --git a/electron/ipc/captions/generate.ts b/electron/ipc/captions/generate.ts
index 038d1f87..2a3f49cd 100644
--- a/electron/ipc/captions/generate.ts
+++ b/electron/ipc/captions/generate.ts
@@ -280,10 +280,10 @@ export async function generateAutoCaptionsFromVideo(options: {
let cuesToReturn = cues;
try {
const silences = await detectSilenceIntervals({ ffmpegPath, wavPath });
- const resegmented = segmentCuesIntoPhrases(cues, silences);
- if (resegmented.length > 0) {
- cuesToReturn = resegmented;
- }
+ // An empty result is a valid resegmentation (e.g. every transcribed word fell
+ // inside a long detected silence and was dropped as a hallucination), so take it
+ // as-is. Only a thrown exception should fall back to the raw cues.
+ cuesToReturn = segmentCuesIntoPhrases(cues, silences);
} catch (error) {
console.warn(
"[auto-captions] Silence-aware re-segmentation failed, using raw cues:",
diff --git a/electron/ipc/captions/segment.test.ts b/electron/ipc/captions/segment.test.ts
index 94344d59..e6ebaf00 100644
--- a/electron/ipc/captions/segment.test.ts
+++ b/electron/ipc/captions/segment.test.ts
@@ -104,6 +104,30 @@ describe("segmentCuesIntoPhrases", () => {
expect(result[0].text).toBe("Okay. Great.");
});
+ it("does not merge two short captions across a real pause just above the merge gap", () => {
+ // Two short, sentence-ended phrases separated by a 450ms gap (just above the 400ms
+ // mergeGapMs). Edge padding shrinks the apparent gap, so if merge eligibility ran on
+ // padded timings it would wrongly fuse them. Merge must run on the true speech gap.
+ const cues: CaptionCuePayload[] = [
+ {
+ id: "caption-1",
+ startMs: 0,
+ endMs: 1_250,
+ text: "Okay. Great.",
+ words: [
+ { text: "Okay.", startMs: 0, endMs: 400 },
+ { text: "Great.", startMs: 850, endMs: 1_250, leadingSpace: true },
+ ],
+ },
+ ];
+ const result = segmentCuesIntoPhrases(cues, []);
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("Okay.");
+ expect(result[1].text).toBe("Great.");
+ // Padding still applies, but cues stay separate and non-overlapping.
+ expect(result[0].endMs).toBeLessThanOrEqual(result[1].startMs);
+ });
+
it("does not merge a short sentence into a full-length one", () => {
const cues: CaptionCuePayload[] = [
{
diff --git a/electron/ipc/captions/segment.ts b/electron/ipc/captions/segment.ts
index 912f089c..60407a00 100644
--- a/electron/ipc/captions/segment.ts
+++ b/electron/ipc/captions/segment.ts
@@ -409,7 +409,10 @@ export function segmentCuesIntoPhrases(
const shouldBreak =
endsSentence(word.text) ||
gapMs >= pauseMs ||
- silenceInGap(word.endMs, next.startMs, silences, splitSilenceMs) ||
+ // Only consult acoustic silence when there's a real gap between the words. When
+ // consecutive words overlap or abut (gapMs <= 0) a silence interval spanning that
+ // region must not manufacture a bogus split.
+ (gapMs > 0 && silenceInGap(word.endMs, next.startMs, silences, splitSilenceMs)) ||
phraseDurationMs >= maxPhraseMs;
if (shouldBreak) {
@@ -438,7 +441,6 @@ export function segmentCuesIntoPhrases(
}
pieces.sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
- padSpans(pieces, edgePadMs);
const sentenceCues: CaptionCuePayload[] = pieces.map((piece) => ({
id: "",
@@ -447,5 +449,11 @@ export function segmentCuesIntoPhrases(
text: piece.text,
...(piece.words.length > 0 ? { words: piece.words } : {}),
}));
- return renumberCues(mergeShortAdjacentCaptions(sentenceCues, mergeOptions));
+ // Merge rapid-fire short captions BEFORE padding so merge eligibility sees the true
+ // speech gaps. Padding pulls cue edges toward each other, which would shrink the
+ // apparent gap and could merge two captions across a real pause sitting just above
+ // mergeGapMs. Pad the survivors afterward so envelopes still get their edge padding.
+ const merged = mergeShortAdjacentCaptions(sentenceCues, mergeOptions);
+ padSpans(merged, edgePadMs);
+ return renumberCues(merged);
}
diff --git a/src/components/video-editor/CaptionListPanel.tsx b/src/components/video-editor/CaptionListPanel.tsx
index 91898c50..ef04e5e7 100644
--- a/src/components/video-editor/CaptionListPanel.tsx
+++ b/src/components/video-editor/CaptionListPanel.tsx
@@ -1,6 +1,7 @@
import { ArrowsMerge, Scissors, Trash } from "@phosphor-icons/react";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
+import { useScopedT } from "@/contexts/I18nContext";
import type { CaptionRetimeSpan } from "./captionOps";
import type { CaptionCue } from "./types";
@@ -62,9 +63,14 @@ function CaptionEditor({
onMerge,
onDelete,
}: CaptionEditorProps) {
+ const t = useScopedT("settings");
const [draftText, setDraftText] = useState(cue.text);
const [startValue, setStartValue] = useState(formatTimecode(cue.startMs));
const [endValue, setEndValue] = useState(formatTimecode(cue.endMs));
+ // Escape resets the draft and blurs, but `setDraftText` is batched so the blur-driven
+ // `commitText` would still see the stale (edited) draft and save it. This flag lets the
+ // cancel path tell the next blur to discard instead of commit.
+ const cancelNextCommitRef = useRef(false);
useEffect(() => {
setDraftText(cue.text);
@@ -73,6 +79,11 @@ function CaptionEditor({
}, [cue.text, cue.startMs, cue.endMs]);
const commitText = useCallback(() => {
+ if (cancelNextCommitRef.current) {
+ cancelNextCommitRef.current = false;
+ setDraftText(cue.text);
+ return;
+ }
const normalized = draftText.trim();
if (normalized && normalized !== cue.text) {
onTextEdit(cue.id, normalized);
@@ -98,7 +109,7 @@ function CaptionEditor({
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 6bfe0f7d..a90028e2 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -2787,6 +2787,10 @@ export function SettingsPanel({
onCheckedChange={(timelineQuickAdd) =>
updateAutoCaptionSettings({ timelineQuickAdd })
}
+ aria-label={tSettings(
+ "captions.timelineQuickAdd",
+ "Hover to add on timeline",
+ )}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 96b75e82..0b23fb69 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -4530,6 +4530,12 @@ export default function VideoEditor() {
}
}, [selectedAudioId, audioRegions]);
+ useEffect(() => {
+ if (selectedCaptionId && !autoCaptions.some((cue) => cue.id === selectedCaptionId)) {
+ setSelectedCaptionId(null);
+ }
+ }, [selectedCaptionId, autoCaptions]);
+
const showExportSuccessToast = useCallback((filePath: string) => {
toast.success(`Exported successfully to ${filePath}`, {
action: {
diff --git a/src/components/video-editor/captionOps.test.ts b/src/components/video-editor/captionOps.test.ts
index 047b242c..deb1f948 100644
--- a/src/components/video-editor/captionOps.test.ts
+++ b/src/components/video-editor/captionOps.test.ts
@@ -77,6 +77,16 @@ describe("captionOps.retimeCue", () => {
expect(result[0].words).toBeUndefined();
});
+ it("keeps words valid and non-overlapping when retimed shorter than its word count", () => {
+ // Retiming a 4-word cue into a 3ms span can't fit one monotonic 1ms range per word,
+ // which is where overlapping/reordered word timings appear. The span is widened to
+ // the minimum viable length so the words stay valid.
+ const result = retimeCue(makeCues(), "a", { startMs: 0, endMs: 3 });
+ const cue = result.find((value) => value.id === "a");
+ expect(cue?.words).toHaveLength(4);
+ assertCuesValid(result);
+ });
+
it("is a no-op for an unknown id", () => {
const cues = makeCues();
expect(retimeCue(cues, "missing", { startMs: 0, endMs: 100 })).toBe(cues);
diff --git a/src/components/video-editor/captionOps.ts b/src/components/video-editor/captionOps.ts
index 95f15802..1fc1fe2e 100644
--- a/src/components/video-editor/captionOps.ts
+++ b/src/components/video-editor/captionOps.ts
@@ -30,10 +30,12 @@ export function createCaptionCue(params: {
endMs: number;
text?: string;
}): CaptionCue {
+ const startMs = Math.round(params.startMs);
return {
id: createCaptionCueId(),
- startMs: Math.round(params.startMs),
- endMs: Math.round(params.endMs),
+ startMs,
+ // Preserve the minimum-duration invariant (endMs > startMs) for every caller.
+ endMs: Math.max(startMs + 1, Math.round(params.endMs)),
text: params.text ?? "",
};
}
@@ -91,22 +93,24 @@ export function retimeCue(cues: CaptionCue[], id: string, span: CaptionRetimeSpa
const cue = sorted[index];
const newStartMs = Math.max(0, Math.round(span.startMs));
- const newEndMs = Math.max(newStartMs + 1, Math.round(span.endMs));
+ const requestedEndMs = Math.max(newStartMs + 1, Math.round(span.endMs));
+
+ const words =
+ Array.isArray(cue.words) && cue.words.length > 0 ? normalizeCaptionWords(cue) : [];
+ // Each word needs a monotonic, non-overlapping range of at least 1ms, so the span must
+ // be at least as long as the word count. A shorter span would force later words to pile
+ // up and overlap/reorder, so widen the end to the minimum viable span in that case.
+ const newEndMs =
+ words.length > 0 ? Math.max(requestedEndMs, newStartMs + words.length) : requestedEndMs;
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 nextWords =
+ words.length > 0
+ ? rescaleWordsIntoSpan(words, cue.startMs, cue.endMs, newStartMs, newEndMs)
+ : null;
const nextCueValue: CaptionCue = {
id: cue.id,
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 27d98006..7b90ec62 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -333,6 +333,7 @@ const TimelineEditor = forwardRef(
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
+ handleSelectCaption,
hasOverlap,
timelineItems,
allRegionSpans,
@@ -342,6 +343,7 @@ const TimelineEditor = forwardRef(
addZoomAtMs,
canPlaceCaptionAtMs,
addCaptionAtMs,
+ resolveCaptionSpanAtMs,
} = useTimelineEditorRuntime({
ref,
videoDuration,
@@ -478,13 +480,14 @@ const TimelineEditor = forwardRef(
canPlaceZoomAtMs={canPlaceZoomAtMs}
onAddCaptionAtMs={addCaptionAtMs}
canPlaceCaptionAtMs={canPlaceCaptionAtMs}
+ resolveCaptionSpanAtMs={resolveCaptionSpanAtMs}
captionsEnabled={captionsEnabled}
captionQuickAddEnabled={captionQuickAddEnabled}
onSelectZoom={handleSelectZoom}
onSelectClip={handleSelectClip}
onSelectAnnotation={handleSelectAnnotation}
onSelectAudio={handleSelectAudio}
- onSelectCaption={onSelectCaption}
+ onSelectCaption={handleSelectCaption}
selectedZoomId={selectedZoomId}
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
index e217894e..a2a20cc1 100644
--- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
+++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
@@ -63,6 +63,7 @@ interface TimelineCanvasProps {
onAddZoomAtMs?: (startMs: number) => void;
onAddCaptionAtMs?: (startMs: number) => void;
canPlaceCaptionAtMs?: (startMs: number) => boolean;
+ resolveCaptionSpanAtMs?: (startMs: number) => { start: number; end: number } | null;
captionsEnabled?: boolean;
captionQuickAddEnabled?: boolean;
selectedZoomId: string | null;
@@ -96,6 +97,9 @@ interface LaneHoverParams {
isDragging: boolean;
onAddAtMs?: (startMs: number) => void;
canPlaceAtMs?: (startMs: number) => boolean;
+ // When set, the ghost previews the exact span an add would produce (clamped to
+ // neighbors/end) instead of a fixed ghostDurationMs. Returns null when no add fits.
+ resolveGhostSpanMs?: (startMs: number) => { start: number; end: number } | null;
}
/**
@@ -115,6 +119,7 @@ function useTimelineLaneHover({
isDragging,
onAddAtMs,
canPlaceAtMs,
+ resolveGhostSpanMs,
}: LaneHoverParams) {
const [isHovered, setIsHovered] = useState(false);
const [hoverMs, setHoverMs] = useState(null);
@@ -175,11 +180,20 @@ function useTimelineLaneHover({
setHoverMs(null);
}, []);
- const ghostStartMs = hoverMs === null ? null : Math.max(0, Math.min(hoverMs, videoDurationMs));
+ const clampedHoverMs =
+ hoverMs === null ? null : Math.max(0, Math.min(hoverMs, videoDurationMs));
+ // When a resolver is supplied, preview the exact span the add would create (clamped to
+ // the next item / end of timeline); otherwise fall back to a fixed-length ghost.
+ const resolvedSpan =
+ resolveGhostSpanMs && clampedHoverMs !== null ? resolveGhostSpanMs(clampedHoverMs) : null;
+ const ghostStartMs =
+ clampedHoverMs === null ? null : resolvedSpan ? resolvedSpan.start : clampedHoverMs;
const ghostEndMs =
ghostStartMs === null
? null
- : Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs));
+ : resolvedSpan
+ ? resolvedSpan.end
+ : Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs));
const ghostStartOffsetPx =
ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - rangeStart));
const ghostEndOffsetPx =
@@ -190,7 +204,11 @@ function useTimelineLaneHover({
enabled &&
isHovered &&
ghostStartMs !== null &&
- (onAddAtMs ? (canPlaceAtMs?.(ghostStartMs) ?? true) : false);
+ (resolveGhostSpanMs
+ ? resolvedSpan !== null
+ : onAddAtMs
+ ? (canPlaceAtMs?.(ghostStartMs) ?? true)
+ : false);
return {
reset,
@@ -216,6 +234,7 @@ interface TimelineHoverParams {
canPlaceZoomAtMs?: (startMs: number) => boolean;
onAddCaptionAtMs?: (startMs: number) => void;
canPlaceCaptionAtMs?: (startMs: number) => boolean;
+ resolveCaptionSpanAtMs?: (startMs: number) => { start: number; end: number } | null;
captionsEnabled?: boolean;
captionQuickAddEnabled?: boolean;
isDragging: boolean;
@@ -232,6 +251,7 @@ function useTimelineHover({
canPlaceZoomAtMs,
onAddCaptionAtMs,
canPlaceCaptionAtMs,
+ resolveCaptionSpanAtMs,
captionsEnabled,
captionQuickAddEnabled = true,
isDragging,
@@ -297,6 +317,7 @@ function useTimelineHover({
isDragging,
onAddAtMs: onAddCaptionAtMs,
canPlaceAtMs: canPlaceCaptionAtMs,
+ resolveGhostSpanMs: resolveCaptionSpanAtMs,
});
const handleTimelineMouseLeave = useCallback(() => {
@@ -734,6 +755,7 @@ export default function TimelineCanvas({
canPlaceZoomAtMs,
onAddCaptionAtMs,
canPlaceCaptionAtMs,
+ resolveCaptionSpanAtMs,
captionsEnabled,
captionQuickAddEnabled,
onSelectZoom,
@@ -916,10 +938,12 @@ export default function TimelineCanvas({
if (item.rowId === CAPTION_ROW_ID) hasCaptionRow = true;
}
const sourceAudioRows = showSourceAudioTrack ? sourceAudioTracks.length : 0;
- return (
- 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size + (hasCaptionRow ? 1 : 0)
- );
- }, [items, showSourceAudioTrack, sourceAudioTracks.length]);
+ // The caption lane is always shown when captions are enabled (even before any cue
+ // exists), so count it whenever captionsEnabled — not only when a caption item is
+ // present — or the min-height/stretch math undersizes the empty lane.
+ const captionRows = hasCaptionRow || captionsEnabled ? 1 : 0;
+ return 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size + captionRows;
+ }, [items, showSourceAudioTrack, sourceAudioTracks.length, captionsEnabled]);
const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount);
const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount);
const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount);
@@ -958,6 +982,7 @@ export default function TimelineCanvas({
canPlaceZoomAtMs,
onAddCaptionAtMs,
canPlaceCaptionAtMs,
+ resolveCaptionSpanAtMs,
captionsEnabled,
captionQuickAddEnabled,
isDragging,
diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts
index 09190bb0..c0d19fe2 100644
--- a/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts
+++ b/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts
@@ -32,6 +32,34 @@ export function useTimelineCaptionActions({
[totalMs, captionRegions],
);
+ // Resolve the exact span an add would create at `startMs`, clamped to the next caption
+ // and the end of the timeline. Shared with the hover ghost so the preview matches the
+ // inserted caption exactly. Returns null when no caption can be placed there.
+ const resolveCaptionSpanAtMs = useCallback(
+ (startMs: number): Span | null => {
+ if (totalMs === 0) {
+ return null;
+ }
+ const startPos = Math.max(0, Math.min(startMs, totalMs));
+ if (!canPlaceCaptionAtMs(startPos)) {
+ return null;
+ }
+ const nextCaptionStartMs = captionRegions
+ .filter((cue) => cue.startMs > startPos)
+ .reduce((min, cue) => Math.min(min, cue.startMs), totalMs);
+ const endPos = Math.min(
+ startPos + DEFAULT_CAPTION_DURATION_MS,
+ totalMs,
+ nextCaptionStartMs,
+ );
+ if (endPos <= startPos) {
+ return null;
+ }
+ return { start: startPos, end: endPos };
+ },
+ [totalMs, canPlaceCaptionAtMs, captionRegions],
+ );
+
const addCaptionAtMs = useCallback(
(startMs: number) => {
if (!onCaptionAdded || totalMs === 0) {
@@ -45,24 +73,18 @@ export function useTimelineCaptionActions({
);
return;
}
- const nextCaptionStartMs = captionRegions
- .filter((cue) => cue.startMs > startPos)
- .reduce((min, cue) => Math.min(min, cue.startMs), totalMs);
- const endPos = Math.min(
- startPos + DEFAULT_CAPTION_DURATION_MS,
- totalMs,
- nextCaptionStartMs,
- );
- if (endPos <= startPos) {
+ const span = resolveCaptionSpanAtMs(startPos);
+ if (!span) {
return;
}
- onCaptionAdded({ start: startPos, end: endPos });
+ onCaptionAdded(span);
},
- [onCaptionAdded, totalMs, canPlaceCaptionAtMs, captionRegions],
+ [onCaptionAdded, totalMs, canPlaceCaptionAtMs, resolveCaptionSpanAtMs],
);
return {
canPlaceCaptionAtMs,
addCaptionAtMs,
+ resolveCaptionSpanAtMs,
};
}
diff --git a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
index 4c570337..4e0af769 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
@@ -103,11 +103,9 @@ export function useTimelineDndBindings({
if (!excludeId) return false;
const itemKind = resolveItemKind(excludeId);
- if (itemKind === "annotation" || itemKind === "caption") return false;
+ if (itemKind === "annotation") return false;
- const checkOverlap = (
- regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[],
- ) =>
+ const checkOverlap = (regions: { id: string; startMs: number; endMs: number }[]) =>
regions.some((region) => {
if (region.id === excludeId) return false;
return spansOverlap(newSpan, { start: region.startMs, end: region.endMs });
@@ -117,6 +115,9 @@ export function useTimelineDndBindings({
if (itemKind === "trim") return checkOverlap(trimRegions);
if (itemKind === "clip") return checkOverlap(clipRegions);
if (itemKind === "speed") return checkOverlap(speedRegions);
+ // Captions share a single lane and must never overlap, so validate a dragged or
+ // resized caption against the other cues just like the other timeline items.
+ if (itemKind === "caption") return checkOverlap(captionCues);
if (itemKind === "audio") {
const activeTrackIndex = resolveTrackIndex("audio", excludeId, rowId);
@@ -135,6 +136,7 @@ export function useTimelineDndBindings({
clipRegions,
audioRegions,
speedRegions,
+ captionCues,
],
);
diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
index 25d830c3..9f7092db 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
@@ -142,6 +142,7 @@ export function useTimelineEditorRuntime({
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
+ handleSelectCaption,
cycleAnnotationsAtCurrentTime,
} = useTimelineSelection({
totalMs,
@@ -220,11 +221,12 @@ export function useTimelineEditorRuntime({
onZoomSuggested,
});
- const { canPlaceCaptionAtMs, addCaptionAtMs } = useTimelineCaptionActions({
- totalMs,
- captionRegions: captionCues,
- onCaptionAdded,
- });
+ const { canPlaceCaptionAtMs, addCaptionAtMs, resolveCaptionSpanAtMs } =
+ useTimelineCaptionActions({
+ totalMs,
+ captionRegions: captionCues,
+ onCaptionAdded,
+ });
const handleSplitClip = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) {
@@ -317,6 +319,7 @@ export function useTimelineEditorRuntime({
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
+ handleSelectCaption,
hasOverlap,
timelineItems,
allRegionSpans,
@@ -326,6 +329,7 @@ export function useTimelineEditorRuntime({
addZoomAtMs,
canPlaceCaptionAtMs,
addCaptionAtMs,
+ resolveCaptionSpanAtMs,
handleAddZoom,
handleSuggestZooms,
handleSplitClip,
diff --git a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts
index a0811f96..7681d8e5 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts
@@ -89,6 +89,7 @@ export function useTimelineSelection({
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
+ onSelectCaption?.(null);
setSelectAllBlocksActive(false);
}, [
selectAllBlocksActive,
@@ -99,6 +100,7 @@ export function useTimelineSelection({
onSelectClip,
onSelectAnnotation,
onSelectAudio,
+ onSelectCaption,
]);
const deleteSelectedClip = useCallback(() => {
@@ -130,17 +132,19 @@ export function useTimelineSelection({
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
+ onSelectCaption?.(null);
setSelectAllBlocksActive(false);
- }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]);
+ }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, onSelectCaption]);
const activateSelectAllZooms = useCallback(() => {
onSelectZoom(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
+ onSelectCaption?.(null);
setSelectedKeyframeId(null);
setSelectAllBlocksActive(true);
- }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]);
+ }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, onSelectCaption]);
const handleSelectZoom = useCallback(
(id: string | null) => {
@@ -174,6 +178,14 @@ export function useTimelineSelection({
[onSelectAudio],
);
+ const handleSelectCaption = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ onSelectCaption?.(id);
+ },
+ [onSelectCaption],
+ );
+
const cycleAnnotationsAtCurrentTime = useCallback(
(backward = false) => {
const overlapping = annotationRegions
@@ -219,6 +231,7 @@ export function useTimelineSelection({
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
+ handleSelectCaption,
cycleAnnotationsAtCurrentTime,
};
}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 45df5b06..60aa8622 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -192,7 +192,15 @@
"maxWidth": "Max Width",
"boxRadius": "Box Radius",
"backgroundOpacity": "Background Opacity",
- "textColor": "Text Color"
+ "textColor": "Text Color",
+ "editor": {
+ "text": "Text",
+ "start": "Start",
+ "end": "End",
+ "split": "Split",
+ "merge": "Merge",
+ "delete": "Delete"
+ }
},
"crop": {
"title": "Crop Video",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 6f237730..1b34c58b 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -127,7 +127,7 @@
"sections": {
"scene": "场景",
"captions": "字幕",
- "caption": "字幕",
+ "caption": "字幕编辑",
"zoom": "缩放",
"cursor": "光标",
"webcam": "摄像头",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 0be69657..9adea246 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -110,7 +110,7 @@
"sections": {
"scene": "場景",
"captions": "字幕",
- "caption": "字幕",
+ "caption": "字幕編輯",
"zoom": "縮放",
"cursor": "游標",
"webcam": "網路攝影機",