fix: keep captions inside clips and harden timeline editing

This commit is contained in:
webadderall
2026-09-21 18:28:25 +10:00
parent f5c9034952
commit ce21f6cd4b
14 changed files with 172 additions and 64 deletions
+1 -1
View File
@@ -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.
@@ -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],
]);
});
+15
View File
@@ -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 = () => {
@@ -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 });
});
@@ -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 };
@@ -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();
});
@@ -488,20 +488,30 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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"
+1 -1
View File
@@ -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,
}
@@ -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 (
<div
ref={ref}
@@ -65,7 +73,7 @@ export function ClipFilmstrip({
className="pointer-events-none absolute inset-0 flex overflow-hidden rounded-[inherit]"
aria-hidden="true"
>
{loading && <Skeleton className="h-full w-full rounded-none" />}
{loading && frames.length === 0 && <Skeleton className="h-full w-full rounded-none" />}
{frames.map((frame, index) => (
<img
key={index}
+13
View File
@@ -41,6 +41,7 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m
getWhisperSmallModelStatus: async () => ({ success: true, exists: false }),
listProjectFiles: async () => ({ success: true, projects: [], entries: [] }),
setCurrentVideoPath: success,
finishRecordingImport: success,
setCurrentRecordingSession: success,
setHasUnsavedChanges: success,
onMenuSaveProject: subscribe,
@@ -97,7 +98,19 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m
document.documentElement.dataset.updateDismissed = "true";
return { success: true };
},
...window.electronAPI,
},
});
}, videoFixture);
}
/** Overrides can run before or after bridge defaults; Playwright does not order init scripts. */
export async function installDesktopBridgeOverrides<T = undefined>(
page: Page,
setup: (arg: T) => void,
arg?: T,
) {
await page.addInitScript({
content: `window.electronAPI ??= {}; (${setup.toString()})(${JSON.stringify(arg) ?? "undefined"});`,
});
}
+5 -11
View File
@@ -1,10 +1,10 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
test("selecting an untouched clip does not introduce a leading gap", async ({ page }) => {
test.setTimeout(120000);
await installDesktopBridge(page);
await page.addInitScript(() => {
await installDesktopBridgeOverrides(page, () => {
window.electronAPI.onMenuSaveProject = (callback) => {
const handler = () => {
void callback();
@@ -56,7 +56,7 @@ test("selecting an untouched clip does not introduce a leading gap", async ({ pa
expect(clips).toHaveLength(1);
expect(clips[0]).toMatchObject({ startMs: 0, endMs: 6000, speed: 1 });
expect(clips[0].sourceStartMs ?? clips[0].startMs).toBe(0);
// Intentional drags still work, and returning to the origin removes a gap.
// A lone clip stays packed at zero even after an intentional drag.
const dragBox = (await clip.boundingBox())!;
const dragX = dragBox.x + dragBox.width / 2;
const dragY = dragBox.y + dragBox.height / 2;
@@ -64,15 +64,9 @@ test("selecting an untouched clip does not introduce a leading gap", async ({ pa
await page.mouse.down();
await page.mouse.move(dragX + 40, dragY, { steps: 8 });
await page.mouse.up();
await expect
.poll(() => clip.evaluate((node) => parseFloat(node.style.left)))
.toBeGreaterThan(10);
const movedBox = (await clip.boundingBox())!;
await page.mouse.move(movedBox.x + 150, movedBox.y + movedBox.height / 2);
await page.mouse.down();
await page.mouse.move(movedBox.x + 70, movedBox.y + movedBox.height / 2, { steps: 8 });
await page.mouse.up();
await expect(clip).toHaveCSS("left", "0px");
await expect(clip).toHaveAttribute("data-start-ms", "0");
await expect(clip).toHaveAttribute("data-end-ms", "6000");
await page.getByRole("button", { name: "Export", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Export", exact: true });
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
test("editor uses skeletons until media opens", async ({ page }) => {
test.setTimeout(60000);
@@ -17,7 +17,7 @@ test("editor uses skeletons until media opens", async ({ page }) => {
});
await page.exposeFunction("waitForTestMedia", () => pending);
await installDesktopBridge(page);
await page.addInitScript(() => {
await installDesktopBridgeOverrides(page, () => {
window.electronAPI.getCurrentVideoPath = async () => {
await (
window as unknown as { waitForTestMedia: () => Promise<void> }
+6 -6
View File
@@ -1,9 +1,9 @@
import { expect, test, type Page } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
async function setup(page: Page) {
await installDesktopBridge(page, "filmstrip.mp4");
await page.addInitScript(() => {
await installDesktopBridgeOverrides(page, () => {
const removed = new Set<string>();
const entries = ["first.mp4", "second.mp4"].map((name, i) => ({
name,
@@ -127,7 +127,7 @@ test("generating captions leaves the current media session intact and shows the
page,
}) => {
await installDesktopBridge(page, "filmstrip.mp4");
await page.addInitScript(() => {
await installDesktopBridgeOverrides(page, () => {
window.electronAPI.getCurrentVideoPath = async () => ({
success: true,
path: "/recordings/caption-test.mp4",
@@ -171,7 +171,7 @@ test("generating captions leaves the current media session intact and shows the
test("preview recovers once when a local video URL fails to load", async ({ page }) => {
await installDesktopBridge(page, "filmstrip.mp4");
await page.addInitScript(() => {
await installDesktopBridgeOverrides(page, () => {
let requests = 0;
window.electronAPI.getCurrentVideoPath = async () => ({
success: true,
@@ -195,7 +195,7 @@ test("preview recovers once when a local video URL fails to load", async ({ page
test("HUD project list fits its popover and scrolls only vertically", async ({ page }) => {
await page.setViewportSize({ width: 980, height: 600 });
await installDesktopBridge(page);
await page.addInitScript(() => {
await installDesktopBridgeOverrides(page, () => {
window.electronAPI.listProjectFiles = async () => ({
success: true,
projects: [],
@@ -228,7 +228,7 @@ test("HUD project list fits its popover and scrolls only vertically", async ({ p
test("HUD source controls have no default gray fill or outline", async ({ page }) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
await installDesktopBridgeOverrides(page, () => {
window.electronAPI.getSources = async () => [
{ id: "screen:1:0", name: "Built-in Display", thumbnail: "", display_id: "1" },
{ id: "window:2:0", name: "Another Window", thumbnail: "", display_id: "" },
+19 -15
View File
@@ -1,25 +1,29 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
for (const savedRoundness of [undefined, 69]) {
test(`new recording webcam uses ${savedRoundness === undefined ? "100% by default" : "saved roundness"}`, async ({
page,
}) => {
await installDesktopBridge(page);
await page.addInitScript((roundness) => {
if (roundness !== undefined) {
window.electronAPI.getAppSetting = (key) =>
key === "recordly.editor.preferences" ? { webcam: { roundness } } : null;
}
window.electronAPI.getCurrentRecordingSession = async () => ({
success: true,
session: {
videoPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
webcamPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
timeOffsetMs: 0,
},
});
}, savedRoundness);
await installDesktopBridgeOverrides(
page,
(roundness) => {
if (roundness !== undefined) {
window.electronAPI.getAppSetting = (key) =>
key === "recordly.editor.preferences" ? { webcam: { roundness } } : null;
}
window.electronAPI.getCurrentRecordingSession = async () => ({
success: true,
session: {
videoPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
webcamPath: `${location.origin}/tests/ui/fixtures/preview.mp4`,
timeOffsetMs: 0,
},
});
},
savedRoundness,
);
await page.goto("/?windowType=editor");
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
const expected = savedRoundness ?? 100;