Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,8 @@ export interface RecordingPrefs {
camDeviceId: string | null;
systemAudioEnabled: boolean;
cursorCaptureMode: CursorCaptureMode;
/** After a take, suggest cursor-dwell zooms. Default on, matching 1.5. */
autoZoomEnabled: boolean;
}
let recordingPrefs: RecordingPrefs = {
micEnabled: false,
Expand All @@ -614,6 +616,7 @@ let recordingPrefs: RecordingPrefs = {
camDeviceId: null,
systemAudioEnabled: false,
cursorCaptureMode: "editable-overlay",
autoZoomEnabled: true,
};

// Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture.
Expand Down
183 changes: 183 additions & 0 deletions src/components/ai-edition/NewEditorShell.loadedMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// What one `loadedmetadata` event does once it reaches the front of the queue.
//
// The queue is why this is worth its own test: the shell puts this step on
// `useSequentialTimelineOps` alongside the user's own edits, so a step that never
// finishes holds that queue — and everything behind it. The pure decision
// (`documentAfterProbedDuration`) is covered next door; this covers what surrounds
// it — the guards, the bounded save, and what the auto-zoom pass is handed.
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/contexts/ShortcutsContext", async () => {
const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts");
return {
useShortcuts: () => ({
shortcuts: DEFAULT_SHORTCUTS,
isMac: false,
isConfigOpen: false,
openConfig: vi.fn(),
closeConfig: vi.fn(),
setShortcuts: vi.fn(),
persistShortcuts: () => Promise.resolve(true),
}),
};
});

vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({ locale: "en", setLocale: vi.fn() }),
useScopedT: () => (key: string) => key,
}));

import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { runLoadedMetadataWrite } from "./NewEditorShell";

const PROJECT = "proj_a";

/** A fresh import: one video asset on the document, nothing on the timeline yet. */
function freshImport(): AxcutDocument {
const doc = createEmptyDocument({ projectId: PROJECT, title: "A" });
return {
...doc,
project: { ...doc.project, primaryAssetId: "asset_1" },
assets: [
{
id: "asset_1",
kind: "video",
label: "screen.mp4",
originalPath: "/tmp/screen.mp4",
cameraTrack: null,
},
],
};
}

/** Stands in for a save that answers, installing its result the way the real one does. */
function settlingSave() {
return vi.fn(async (document: AxcutDocument) => {
useProjectStore.setState({ document });
return true;
});
}

describe("runLoadedMetadataWrite", () => {
beforeEach(() => {
vi.clearAllMocks();
useProjectStore.setState({ document: freshImport() });
});

it("folds the probed length in and hands auto-zoom the saved document", async () => {
const saveDocument = settlingSave();
useProjectStore.setState({ saveDocument });
const autoZoom = vi.fn(async () => undefined);

await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom });

expect(saveDocument).toHaveBeenCalledTimes(1);
const saved = saveDocument.mock.calls[0][0];
expect(saved.timeline.clips).toHaveLength(1);
expect(saved.assets[0].durationSec).toBe(12.5);
// Not the pre-save snapshot: auto-zoom appends to whatever is on the store now.
expect(autoZoom).toHaveBeenCalledWith(useProjectStore.getState().document);
expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(1);
});

// THE reason the save is bounded. `saveDocument` awaits the bridge with no
// deadline of its own and never rejects, so a main process that stops answering
// leaves this step pending for the life of the renderer — and every edit queued
// behind it waits with it, this take's auto-zoom included. Without the deadline
// this test does not fail with a wrong value, it never finishes.
it("gives up on a save that never answers instead of holding the queue", async () => {
const saveDocument = vi.fn(() => new Promise<boolean>(() => undefined));
useProjectStore.setState({ saveDocument });
const autoZoom = vi.fn(async () => undefined);

await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom, saveTimeoutMs: 20 });

expect(saveDocument).toHaveBeenCalledTimes(1);
// The step let go and carried on, with the document the store actually holds
// — the stuck write never installed one.
expect(autoZoom).toHaveBeenCalledTimes(1);
expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(0);
});

// The switch that happens DURING the save, which the guard at the top cannot
// see. Auto-zoom would not write zooms into the new project — the pending-path
// guard refuses it — but the passes before that check clear the pending flag on
// whatever document they are handed, so the take that was actually imported
// would lose its auto-zoom without a trace.
it("stops when the project changes while the save is in flight", async () => {
// Carrying assets on purpose: an assetless document is turned away a line
// later for a different reason, and this test would then pass without the
// ownership check it exists to cover.
const other = { ...freshImport(), project: { ...freshImport().project, id: "proj_b" } };
const saveDocument = vi.fn(async () => {
useProjectStore.setState({ document: other });
return true;
});
useProjectStore.setState({ saveDocument });
const autoZoom = vi.fn(async () => undefined);

await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom });

expect(saveDocument).toHaveBeenCalledTimes(1);
expect(autoZoom).not.toHaveBeenCalled();
});

// Same for the project being closed outright: there is nothing left for this
// event to belong to, and the pre-switch snapshot is not a stand-in for it.
it("stops when the project is closed while the save is in flight", async () => {
const saveDocument = vi.fn(async () => {
useProjectStore.setState({ document: null });
return true;
});
useProjectStore.setState({ saveDocument });
const autoZoom = vi.fn(async () => undefined);

await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom });

expect(autoZoom).not.toHaveBeenCalled();
});

// The event is bound to the project that owned the video when it fired, and the
// queue puts real time between the two.
it("writes nothing when the project changed before it ran", async () => {
const saveDocument = settlingSave();
useProjectStore.setState({ saveDocument });
const autoZoom = vi.fn(async () => undefined);

await runLoadedMetadataWrite(12.5, "asset_1", "proj_switched_away_from", { autoZoom });

expect(saveDocument).not.toHaveBeenCalled();
expect(autoZoom).not.toHaveBeenCalled();
});

it("writes nothing without a document, or without assets", async () => {
const saveDocument = settlingSave();
const autoZoom = vi.fn(async () => undefined);

useProjectStore.setState({ document: null, saveDocument });
await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom });

const empty = createEmptyDocument({ projectId: PROJECT, title: "A" });
useProjectStore.setState({ document: empty, saveDocument });
await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom });

expect(saveDocument).not.toHaveBeenCalled();
expect(autoZoom).not.toHaveBeenCalled();
});

// Nothing to fold in is not a reason to skip auto-zoom: a second event for a
// document that already has its length still has to let the suggestion pass run.
it("still runs auto-zoom when the document needs no write", async () => {
const saveDocument = settlingSave();
useProjectStore.setState({ saveDocument });
const autoZoom = vi.fn(async () => undefined);

await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom });
saveDocument.mockClear();
await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom });

expect(saveDocument).not.toHaveBeenCalled();
expect(autoZoom).toHaveBeenCalledTimes(2);
});
});
195 changes: 195 additions & 0 deletions src/components/ai-edition/NewEditorShell.probedDuration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// The decision a `loadedmetadata` event makes, on its own.
//
// The event itself arrives through Preview -> PreviewCanvas -> VirtualPreview and a
// real <video>, which no test environment here can decode, so the component cannot
// be driven end to end. `documentAfterProbedDuration` is the part that decides what gets
// written, and both guards below live in it: the queue the shell puts this write on
// is what makes them necessary, because it puts real time between the event and the
// write. The queue's own serialization is covered by useSequentialTimelineOps.test.
import { describe, expect, it, vi } from "vitest";

vi.mock("@/contexts/ShortcutsContext", async () => {
const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts");
return {
useShortcuts: () => ({
shortcuts: DEFAULT_SHORTCUTS,
isMac: false,
isConfigOpen: false,
openConfig: vi.fn(),
closeConfig: vi.fn(),
setShortcuts: vi.fn(),
persistShortcuts: () => Promise.resolve(true),
}),
};
});

vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({ locale: "en", setLocale: vi.fn() }),
useScopedT: () => (key: string) => key,
}));

import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
import { PLACEHOLDER_DURATION_SEC, replaceTimeline } from "@/lib/ai-edition/document/timeline";
import { type AxcutDocument, createEmptyDocument, documentSchema } from "@/lib/ai-edition/schema";
import { documentAfterProbedDuration } from "./NewEditorShell";

const PROJECT = "proj_a";

/** A fresh import: assets on the document, nothing on the timeline yet. */
function emptyTimeline(primaryAssetId: string, assetIds: string[]): AxcutDocument {
const doc = createEmptyDocument({ projectId: PROJECT, title: "A" });
return {
...doc,
project: { ...doc.project, primaryAssetId },
assets: assetIds.map((id) => ({
id,
kind: "video" as const,
label: `${id}.mp4`,
originalPath: `/tmp/${id}.mp4`,
cameraTrack: null,
})),
};
}

/** A v1.7 project: one clip with no source extent, waiting for a real length. */
function legacyWithClip(): AxcutDocument {
return documentSchema.parse(
migrateProjectDataToAxcutDocument({
version: 2,
videoPath: "C:/rec/screen.webm",
media: { videoPath: "C:/rec/screen.webm" },
editor: {
zoomRegions: [],
annotationRegions: [],
trimRegions: [],
speedRegions: [],
cameraFullscreenRegions: [],
},
} as never),
);
}

describe("documentAfterProbedDuration", () => {
it("seeds a full-duration clip when the primary asset reports its length", () => {
const doc = emptyTimeline("asset_1", ["asset_1"]);

const next = documentAfterProbedDuration(doc, "asset_1", 30, PROJECT);

expect(next).not.toBeNull();
expect(next?.assets[0].durationSec).toBe(30);
expect(next?.timeline.clips).toHaveLength(1);
expect(next?.timeline.clips[0].timelineStartSec).toBe(0);
expect(next?.timeline.clips[0].timelineEndSec).toBe(30);
});

// The write is queued, so the user can switch projects between the event and this
// decision. `knownSec` came off the OLD video; applying it to whatever is loaded
// now writes one recording's length into another project.
it("writes nothing when the project changed after the event fired", () => {
const doc = emptyTimeline("asset_1", ["asset_1"]);

expect(documentAfterProbedDuration(doc, "asset_1", 30, "proj_switched_to")).toBeNull();
expect(documentAfterProbedDuration(doc, "asset_1", 30, undefined)).toBeNull();
});

// `replaceTimeline` pins every clip it builds to the primary asset, and the seed
// sizes that clip from `knownSec` — so seeding on another asset's event would put
// one video's length under a different asset's id. The primary's own event seeds it.
it("does not seed from an asset that is not the one the seed is about", () => {
const doc = emptyTimeline("asset_1", ["asset_1", "asset_2"]);

expect(documentAfterProbedDuration(doc, "asset_2", 30, PROJECT)).toBeNull();
// And the primary still seeds normally on the same document.
expect(documentAfterProbedDuration(doc, "asset_1", 30, PROJECT)).not.toBeNull();
});

it("folds the length into a clip that was waiting for one", () => {
const doc = legacyWithClip();
const assetId = doc.assets[0].id;

const next = documentAfterProbedDuration(doc, assetId, 30, doc.project.id);

expect(next?.timeline.clips[0].sourceEndSec).toBe(30);
expect(next?.assets[0].durationSec).toBe(30);
});

it("writes nothing when the length is already recorded", () => {
const doc = legacyWithClip();
const assetId = doc.assets[0].id;
const settled = documentAfterProbedDuration(doc, assetId, 30, doc.project.id);

expect(settled).not.toBeNull();
expect(
documentAfterProbedDuration(settled as AxcutDocument, assetId, 30, doc.project.id),
).toBeNull();
});

// MediaRecorder WebMs report NaN/Infinity until the main-process EBML fix lands.
// A clip still has to be seeded — an empty timeline is worse than a wrong length.
// The placeholder goes on the asset too, and that is not sloppiness: the timeline
// layer clamps every interval it builds against `primaryAssetDuration`, so an
// asset with no duration makes the NEXT `replaceTimeline` clamp to zero, drop
// every clip, and persist an empty timeline. Auto-zoom is kept honest elsewhere,
// by asking the clips rather than this field.
it("seeds a placeholder clip from an unusable duration", () => {
const doc = emptyTimeline("asset_1", ["asset_1"]);

for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) {
const next = documentAfterProbedDuration(doc, "asset_1", bad, PROJECT);
expect(next?.timeline.clips).toHaveLength(1);
expect(next?.timeline.clips[0].timelineEndSec).toBe(PLACEHOLDER_DURATION_SEC);
expect(next?.assets[0].durationSec).toBe(PLACEHOLDER_DURATION_SEC);
}
});

// The reason the line above matters: a seeded placeholder document has to survive
// the next timeline write. With no duration on the asset this comes back empty.
it("leaves a placeholder document that a later timeline write does not empty", () => {
const seeded = documentAfterProbedDuration(
emptyTimeline("asset_1", ["asset_1"]),
"asset_1",
Number.NaN,
PROJECT,
) as AxcutDocument;

const rebuilt = replaceTimeline(seeded, [{ startSec: 0, endSec: 30 }], "trim");

expect(rebuilt.timeline.clips).toHaveLength(1);
expect(rebuilt.timeline.clips[0].sourceEndSec).toBe(30);
});

// …and once a timeline exists, an unusable duration has nothing to fold in: the
// placeholder above only exists so the FIRST clip is not empty.
it("writes nothing into an existing timeline from an unusable duration", () => {
const seeded = documentAfterProbedDuration(
emptyTimeline("asset_1", ["asset_1"]),
"asset_1",
Number.NaN,
PROJECT,
) as AxcutDocument;

expect(documentAfterProbedDuration(seeded, "asset_1", Number.NaN, PROJECT)).toBeNull();
});

// The real length arriving later replaces the placeholder.
it("replaces a placeholder clip once a real duration arrives", () => {
const seeded = documentAfterProbedDuration(
emptyTimeline("asset_1", ["asset_1"]),
"asset_1",
Number.NaN,
PROJECT,
) as AxcutDocument;

const next = documentAfterProbedDuration(seeded, "asset_1", 8, PROJECT);

expect(next?.timeline.clips[0].sourceEndSec).toBe(8);
expect(next?.assets[0].durationSec).toBe(8);
});

it("writes nothing without a document or without assets", () => {
expect(documentAfterProbedDuration(null, "asset_1", 30, PROJECT)).toBeNull();
expect(
documentAfterProbedDuration(emptyTimeline("asset_1", []), "asset_1", 30, PROJECT),
).toBeNull();
});
});
Loading
Loading