Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
12 changes: 10 additions & 2 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 Expand Up @@ -3471,11 +3474,16 @@ export function registerIpcHandlers(
...(cursorCaptureMode ? { cursorCaptureMode } : {}),
}
: { screenVideoPath, createdAt, ...(cursorCaptureMode ? { cursorCaptureMode } : {}) };
// Sidecar BEFORE the session is published, as the three native stop paths already
// do it. Publishing first opens a window where `getCurrentRecordingSession` hands
// the editor a take whose `.cursor.json` is not on disk yet, and the editor's
// fresh-take auto-zoom reads that file the moment it imports -- an empty read there
// is indistinguishable from a take with no dwell, so the zooms are silently
// skipped.
await writePendingCursorTelemetry(screenVideoPath);
setCurrentRecordingSessionState(session);
currentProjectPath = null;

await writePendingCursorTelemetry(screenVideoPath);

const sessionManifestPath = path.join(
RECORDINGS_DIR,
`${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`,
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);
});
});
Loading
Loading