mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
fix(captions): address caption-editor review feedback
This commit is contained in:
@@ -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:",
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
"sections": {
|
||||
"scene": "场景",
|
||||
"captions": "字幕",
|
||||
"caption": "字幕",
|
||||
"caption": "字幕编辑",
|
||||
"zoom": "缩放",
|
||||
"cursor": "光标",
|
||||
"webcam": "摄像头",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"sections": {
|
||||
"scene": "場景",
|
||||
"captions": "字幕",
|
||||
"caption": "字幕",
|
||||
"caption": "字幕編輯",
|
||||
"zoom": "縮放",
|
||||
"cursor": "游標",
|
||||
"webcam": "網路攝影機",
|
||||
|
||||
Reference in New Issue
Block a user