mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
fix: keep captions inside clips and harden timeline editing
This commit is contained in:
@@ -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],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"});`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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: "" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user