fix(captions): address caption-editor review feedback

This commit is contained in:
Joe Hachem
2026-06-28 18:03:30 +03:00
parent bd1aff562e
commit a8ba050335
17 changed files with 205 additions and 60 deletions
+4 -4
View File
@@ -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:",
+24
View File
@@ -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[] = [
{
+11 -3
View File
@@ -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);
}
@@ -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({
<div className="flex flex-col gap-3 rounded-lg bg-foreground/[0.03] px-2.5 py-2.5">
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Text
{t("captions.editor.text", "Text")}
</span>
<textarea
value={draftText}
@@ -115,6 +126,7 @@ function CaptionEditor({
event.currentTarget.blur();
}
if (event.key === "Escape") {
cancelNextCommitRef.current = true;
setDraftText(cue.text);
event.currentTarget.blur();
}
@@ -126,7 +138,7 @@ function CaptionEditor({
<div className="flex items-center gap-2">
<label className="flex flex-1 flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Start
{t("captions.editor.start", "Start")}
</span>
<input
value={startValue}
@@ -142,7 +154,7 @@ function CaptionEditor({
</label>
<label className="flex flex-1 flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
End
{t("captions.editor.end", "End")}
</span>
<input
value={endValue}
@@ -167,7 +179,7 @@ function CaptionEditor({
className="flex h-9 items-center justify-center gap-1.5 rounded-lg border border-foreground/10 bg-foreground/5 text-xs font-medium text-foreground transition-colors hover:bg-foreground/10"
>
<Scissors className="h-4 w-4" />
Split
{t("captions.editor.split", "Split")}
</button>
<button
type="button"
@@ -176,7 +188,7 @@ function CaptionEditor({
className="flex h-9 items-center justify-center gap-1.5 rounded-lg border border-foreground/10 bg-foreground/5 text-xs font-medium text-foreground transition-colors hover:bg-foreground/10 disabled:opacity-40"
>
<ArrowsMerge className="h-4 w-4" />
Merge
{t("captions.editor.merge", "Merge")}
</button>
<Button
type="button"
@@ -186,7 +198,7 @@ function CaptionEditor({
className="h-9 gap-1.5 rounded-lg border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash className="h-3 w-3" />
Delete
{t("captions.editor.delete", "Delete")}
</Button>
</div>
</div>
@@ -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"
/>
</div>
@@ -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: {
@@ -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);
+17 -13
View File
@@ -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,
@@ -333,6 +333,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
handleSelectCaption,
hasOverlap,
timelineItems,
allRegionSpans,
@@ -342,6 +343,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
addZoomAtMs,
canPlaceCaptionAtMs,
addCaptionAtMs,
resolveCaptionSpanAtMs,
} = useTimelineEditorRuntime({
ref,
videoDuration,
@@ -478,13 +480,14 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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}
@@ -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<number | null>(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,
@@ -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,
};
}
@@ -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,
],
);
@@ -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,
@@ -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,
};
}
+9 -1
View File
@@ -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",
+1 -1
View File
@@ -127,7 +127,7 @@
"sections": {
"scene": "场景",
"captions": "字幕",
"caption": "字幕",
"caption": "字幕编辑",
"zoom": "缩放",
"cursor": "光标",
"webcam": "摄像头",
+1 -1
View File
@@ -110,7 +110,7 @@
"sections": {
"scene": "場景",
"captions": "字幕",
"caption": "字幕",
"caption": "字幕編輯",
"zoom": "縮放",
"cursor": "游標",
"webcam": "網路攝影機",