diff --git a/electron/ipc/captions/generate.ts b/electron/ipc/captions/generate.ts index f394769a..1e118434 100644 --- a/electron/ipc/captions/generate.ts +++ b/electron/ipc/captions/generate.ts @@ -344,7 +344,7 @@ export async function generateAutoCaptionsFromVideo(options: { (source) => !microphone.includes(source) && !system.includes(source), ); if (microphone.length === 0) { - return generateCaptionsForSource({ ...options, candidates: [...system, ...candidates] }); + return generateCaptionsForSource({ ...options, candidates: [...system, ...secondary] }); } // Decode independently so simultaneous voices do not confuse recognition. The // sidecar replaces embedded system audio to avoid transcribing it twice. diff --git a/electron/ipc/captions/mergeSources.test.ts b/electron/ipc/captions/mergeSources.test.ts index 4e7bb524..01a3cc4e 100644 --- a/electron/ipc/captions/mergeSources.test.ts +++ b/electron/ipc/captions/mergeSources.test.ts @@ -92,3 +92,26 @@ it("preserves system speech between timed microphone words", () => { ); expect(result.map((cue) => cue.text)).toContain("In the gap"); }); + +it("retains the unopposed portions of an untimed system cue", () => { + const result = mergeCaptionSources( + [ + { + id: "mic", + startMs: 0, + endMs: 3000, + text: "Mic", + words: [{ text: "Mic", startMs: 1000, endMs: 2000 }], + }, + ], + [{ id: "system", startMs: 0, endMs: 3000, text: "System paragraph" }], + ); + expect( + result + .filter((cue) => cue.text === "System paragraph") + .map(({ startMs, endMs }) => [startMs, endMs]), + ).toEqual([ + [0, 1000], + [2000, 3000], + ]); +}); diff --git a/electron/ipc/captions/mergeSources.ts b/electron/ipc/captions/mergeSources.ts index 6d44622c..1eab4c6c 100644 --- a/electron/ipc/captions/mergeSources.ts +++ b/electron/ipc/captions/mergeSources.ts @@ -17,6 +17,21 @@ export function mergeCaptionSources( systemCues.push(cue); continue; } + if (!cue.words?.length) { + let spans = [{ startMs: cue.startMs, endMs: cue.endMs }]; + for (const mic of micSpans) { + spans = spans.flatMap((span) => { + if (mic.endMs <= span.startMs || mic.startMs >= span.endMs) return [span]; + return [ + { startMs: span.startMs, endMs: Math.min(span.endMs, mic.startMs) }, + { startMs: Math.max(span.startMs, mic.endMs), endMs: span.endMs }, + ].filter((part) => part.endMs > part.startMs); + }); + } + // SRT has no word boundaries: retain its text during the unopposed portions. + systemCues.push(...spans.map((span) => ({ ...cue, ...span }))); + continue; + } // Preserve words outside the conflict, rather than dropping a whole paragraph. let run: CaptionWordPayload[] = []; const flush = () => { diff --git a/src/components/video-editor/clipSpanChange.test.ts b/src/components/video-editor/clipSpanChange.test.ts index f969e8ae..c1fd43f3 100644 --- a/src/components/video-editor/clipSpanChange.test.ts +++ b/src/components/video-editor/clipSpanChange.test.ts @@ -40,3 +40,13 @@ it("does not reveal a neighboring recording when extending an imported clip", () expect(changeClipSpan(clip, -500, 2000, 20000)).toEqual(clip); expect(changeClipSpan(clip, 0, 2500, 20000)).toEqual(clip); }); + +it.each([0, -1, NaN, Infinity])("uses normal speed when resizing corrupt speed %s", (speed) => { + const result = changeClipSpan( + { id: "bad", startMs: 0, endMs: 3000, sourceStartMs: 0, speed }, + 1000, + 3000, + 5000, + ); + expect(result).toMatchObject({ startMs: 1000, endMs: 3000, sourceStartMs: 1000 }); +}); diff --git a/src/components/video-editor/clipSpanChange.ts b/src/components/video-editor/clipSpanChange.ts index 0e55de50..d0fca2bb 100644 --- a/src/components/video-editor/clipSpanChange.ts +++ b/src/components/video-editor/clipSpanChange.ts @@ -6,6 +6,7 @@ export function changeClipSpan( endMs: number, sourceDurationMs: number, ): ClipRegion { + const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; const sourceStart = getClipSourceStartMs(clip); const isMove = startMs - clip.startMs === endMs - clip.endMs; if (isMove) return { ...clip, startMs, endMs, sourceStartMs: sourceStart }; @@ -13,15 +14,15 @@ export function changeClipSpan( // Resizing reveals/hides footage; it cannot manufacture source before 0 or after EOF. const start = Math.max( startMs, - Math.ceil(clip.startMs - (sourceStart - (clip.sourceMinMs ?? 0)) / clip.speed), + Math.ceil(clip.startMs - (sourceStart - (clip.sourceMinMs ?? 0)) / speed), ); - const sourceStartMs = Math.round(sourceStart + (start - clip.startMs) * clip.speed); + const sourceStartMs = Math.round(sourceStart + (start - clip.startMs) * speed); const end = Math.min( endMs, Math.floor( start + (Math.min(sourceDurationMs, clip.sourceMaxMs ?? sourceDurationMs) - sourceStartMs) / - clip.speed, + speed, ), ); return { ...clip, startMs: start, endMs: end, sourceStartMs }; diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 8a0982fb..ac963884 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -211,12 +211,10 @@ describe("loaded clip sequence migration", () => { }); it("reopens persisted local media URLs using the current server port", async () => { - const getLocalMediaUrl = vi - .fn() - .mockResolvedValue({ - success: true, - url: "http://127.0.0.1:9999/video?path=%2Ftmp%2Fclip.mp4", - }); + const getLocalMediaUrl = vi.fn().mockResolvedValue({ + success: true, + url: "http://127.0.0.1:9999/video?path=%2Ftmp%2Fclip.mp4", + }); vi.stubGlobal("window", { electronAPI: { getLocalMediaUrl } }); await expect( resolveVideoUrl("http://127.0.0.1:1234/video?path=%2Ftmp%2Fclip.mp4"), @@ -224,16 +222,48 @@ it("reopens persisted local media URLs using the current server port", async () expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/clip.mp4"); }); - describe("annotations across the canvas and imported webcam ranges", () => { it("preserves annotations outside the recording rectangle and webcam visibility when a project is reopened", () => { const editor = normalizeProjectEditor({ - annotationRegions: [{ id: "outside", startMs: 0, endMs: 1000, type: "text", position: { x: -12, y: 110 }, size: { width: 140, height: 20 } }] as never, - webcam: { sourcePath: "/sequence-webcam.mp4", visibleRanges: [{ startMs: 1200, endMs: 2000 }] } as never, + annotationRegions: [ + { + id: "outside", + startMs: 0, + endMs: 1000, + type: "text", + position: { x: -12, y: 110 }, + size: { width: 140, height: 20 }, + }, + ] as never, + webcam: { + sourcePath: "/sequence-webcam.mp4", + visibleRanges: [{ startMs: 1200, endMs: 2000 }], + } as never, }); expect(editor.annotationRegions[0].position).toEqual({ x: -12, y: 110 }); expect(editor.annotationRegions[0].size.width).toBe(140); expect(normalizeProjectEditor(editor).annotationRegions).toEqual(editor.annotationRegions); - expect(normalizeProjectEditor(editor).webcam.visibleRanges).toEqual([{ startMs: 1200, endMs: 2000 }]); + expect(normalizeProjectEditor(editor).webcam.visibleRanges).toEqual([ + { startMs: 1200, endMs: 2000 }, + ]); }); }); + +it("discards inverted saved source bounds while preserving the in-point", () => { + const editor = normalizeProjectEditor({ + clipRegions: [ + { + id: "clip", + startMs: 0, + endMs: 1000, + sourceStartMs: 5000, + sourceMinMs: 6000, + sourceMaxMs: 4000, + speed: 0, + }, + ], + }); + expect(editor.clipRegions[0]).toMatchObject({ sourceStartMs: 5000, speed: 1 }); + expect(editor.clipRegions[0].sourceMinMs).toBeUndefined(); + expect(editor.clipRegions[0].sourceMaxMs).toBeUndefined(); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 3a664433..0b569074 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -488,20 +488,30 @@ export function normalizeProjectEditor(editor: Partial): Pro : rawStart + 1000; const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); + const sourceStartMs = isFiniteNumber(region.sourceStartMs) + ? Math.max(0, Math.round(region.sourceStartMs)) + : undefined; + let sourceMinMs = isFiniteNumber(region.sourceMinMs) + ? Math.max(0, Math.round(region.sourceMinMs)) + : undefined; + let sourceMaxMs = isFiniteNumber(region.sourceMaxMs) + ? Math.max(0, Math.round(region.sourceMaxMs)) + : undefined; + if ( + sourceMaxMs !== undefined && + sourceMaxMs < Math.max(sourceMinMs ?? 0, sourceStartMs ?? startMs) + ) { + sourceMinMs = undefined; + sourceMaxMs = undefined; + } return { id: region.id, startMs, endMs, - ...(isFiniteNumber(region.sourceStartMs) - ? { sourceStartMs: Math.max(0, Math.round(region.sourceStartMs)) } - : {}), - ...(isFiniteNumber(region.sourceMinMs) - ? { sourceMinMs: Math.max(0, Math.round(region.sourceMinMs)) } - : {}), - ...(isFiniteNumber(region.sourceMaxMs) - ? { sourceMaxMs: Math.max(0, Math.round(region.sourceMaxMs)) } - : {}), - speed: isFiniteNumber(region.speed) ? region.speed : 1, + sourceStartMs, + sourceMinMs, + sourceMaxMs, + speed: isFiniteNumber(region.speed) && region.speed > 0 ? region.speed : 1, muted: typeof region.muted === "boolean" ? region.muted : false, showSourceAudio: typeof region.showSourceAudio === "boolean" diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 3dcb432c..818b0fcb 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -48,7 +48,7 @@ export default function Row({ ? { position: "absolute" as const, insetInline: 0, - ...(caption ? { top: -20 } : { bottom: 7 }), + ...(caption ? { top: 36 } : { bottom: 7 }), zIndex: 15, pointerEvents: "none" as const, } diff --git a/src/components/video-editor/timeline/components/filmstrip/ClipFilmstrip.tsx b/src/components/video-editor/timeline/components/filmstrip/ClipFilmstrip.tsx index 0649dd38..82469ff4 100644 --- a/src/components/video-editor/timeline/components/filmstrip/ClipFilmstrip.tsx +++ b/src/components/video-editor/timeline/components/filmstrip/ClipFilmstrip.tsx @@ -34,14 +34,22 @@ export function ClipFilmstrip({ }, []); const start = Math.max(span.start, range.start); const end = Math.min(span.end, range.end); + const [windowRange, setWindowRange] = useState({ start, end, count }); + useEffect(() => { + const timer = setTimeout(() => setWindowRange({ start, end, count }), 150); + return () => clearTimeout(timer); + }, [start, end, count]); + // biome-ignore lint/correctness/useExhaustiveDependencies: clear stale thumbnails when their source or clip bounds change. + useEffect(() => { + setFrames([]); + }, [path, span.start, span.end, sourceSpan.start, sourceSpan.end]); useEffect(() => { const controller = new AbortController(); - setFrames([]); const times = filmstripSampleTimes( { start: span.start, end: span.end }, { start: sourceSpan.start, end: sourceSpan.end }, - { start, end }, - count, + windowRange, + windowRange.count, ); setLoading(times.length > 0); if (times.length) { @@ -57,7 +65,7 @@ export function ClipFilmstrip({ }); } return () => controller.abort(); - }, [path, span.start, span.end, sourceSpan.start, sourceSpan.end, start, end, count]); + }, [path, span.start, span.end, sourceSpan.start, sourceSpan.end, windowRange]); return (