Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
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
180 changes: 180 additions & 0 deletions src/components/ai-edition/NewEditorShell.loadedMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// What one `loadedmetadata` event does once it reaches the front of the chain.
//
// The chain is why this is worth its own test: the shell runs these steps one
// after another so a queued duration cannot land on the wrong project, and that
// same ordering means a step that never finishes stalls every later event. The
// pure decision (`documentAfterLoadedMetadata`) is covered next door; this covers
// what surrounds it — the guards, the bounded save, and what auto-zoom 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 later
// `loadedmetadata` queues behind it, 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 pinning the chain", 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 () => {
const other = createEmptyDocument({ projectId: "proj_b", title: "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
// chain 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);
});
});
127 changes: 80 additions & 47 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,20 @@ import {
migrateProjectDataToAxcutDocument,
migrateRawDocumentToCurrent,
} from "@/lib/ai-edition/document/migrate";
import {
applyProbedDuration,
replaceTimeline as replaceTimelineOp,
} from "@/lib/ai-edition/document/timeline";
import {
type InsertSide,
insertDocumentWord,
removeDocumentWords,
setDocumentWordText,
} from "@/lib/ai-edition/document/transcript";
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
import { type AxcutAudioTrack, type AxcutClip, documentSchema } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
type AxcutAudioTrack,
type AxcutClip,
type AxcutDocument,
documentSchema,
} from "@/lib/ai-edition/schema";
import { saveWithDeadline, useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
useAutoTranscription,
Expand All @@ -52,6 +53,11 @@ import { useNativePlaybackSync } from "@/native/useNativePlaybackSync";
import { ExportDialog } from "./ExportDialog";
import { insertionsEnabled } from "./insertionsEnabled";
import { ChatStripPanel } from "./LeftPanel";
import {
documentAfterLoadedMetadata,
isFiniteMediaDuration,
isLoadedMetadataForDocument,
} from "./loadedRecordingMetadata";
import {
EditClipModal,
NewProjectModal,
Expand All @@ -61,7 +67,7 @@ import {
type UnsavedChoice,
} from "./Modals";
import { Preview } from "./Preview";
import { importPendingRecording } from "./recordingImport";
import { importPendingRecording, maybeSaveFreshRecordingAutoZooms } from "./recordingImport";
import { AddAudioLayerDialog } from "./v4/AddAudioLayerDialog";
import v4 from "./v4/EditorShellV4.module.css";
import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar";
Expand Down Expand Up @@ -115,6 +121,60 @@ export const DEFAULT_TIMELINE_HEIGHT_PX = 392;
export const MIN_TIMELINE_HEIGHT_PX = 160;
export const MAX_TIMELINE_HEIGHT_PX = 560;

/**
* What one `loadedmetadata` event does once it reaches the front of the chain.
*
* Exported because the chain is the point: the shell runs these one after another
* so a queued duration cannot land on the wrong project, which also means one
* stuck step stalls every later event. That behaviour is only reachable from
* outside the component — the event itself arrives through Preview,
* PreviewCanvas, VirtualPreview and a real `<video>` decoding real media, which
* no test environment here provides.
*
* Every store read is at call time, not from a closure: by the time this runs the
* document may have moved on, and `originatingProjectId` is what says whether it
* moved to a different project.
*/
export async function runLoadedMetadataWrite(
durationSec: number,
assetId: string,
originatingProjectId: string | undefined,
deps: {
/** Defaults to the real auto-zoom pass; injected in tests. */
autoZoom?: (document: AxcutDocument) => Promise<unknown>;
saveTimeoutMs?: number;
} = {},
): Promise<void> {
const state = useProjectStore.getState();
const doc = state.document;
if (!isLoadedMetadataForDocument(doc, originatingProjectId) || doc.assets.length === 0) return;
const next = documentAfterLoadedMetadata(doc, durationSec, assetId);
// `history: false` for the duration write: it is the probed length being folded
// in on load, not something the user did — an undo landing on it would empty
// their timeline. Auto-zoom is a later, undoable suggestion.
//
// Bounded, because a bridge call that never answers leaves the save pending
// forever and takes the chain with it: every later `loadedmetadata` and the
// auto-zoom pass below would queue behind a promise that is not coming back.
// `saveDocument` never rejects, so the abandoned write is safe to let go of; on
// a timeout we carry on with whatever the store actually holds, and auto-zoom's
// own guards decide what that is worth.
if (next !== doc) {
await saveWithDeadline(state.saveDocument(next, { history: false }), deps.saveTimeoutMs);
}
// Re-checked rather than assumed: that await is exactly when a project switch
// lands, and the guard at the top only spoke for the document as it was before
// it. Handing the auto-zoom pass another project's document would not write
// zooms into it — `canApplyFreshRecordingAutoZooms` refuses a document that
// does not hold the pending recording — but the passes before that check DO
// clear the pending flag on what they are given, so the take that was actually
// imported would silently lose its auto-zoom. Stopping is also right when the
// store has no document at all: there is nothing left this event belongs to.
const settled = useProjectStore.getState().document;
if (!isLoadedMetadataForDocument(settled, originatingProjectId)) return;
await (deps.autoZoom ?? maybeSaveFreshRecordingAutoZooms)(settled);
}

export function NewEditorShell() {
const te = useScopedT("editor");
const document = useProjectStore((s) => s.document);
Expand Down Expand Up @@ -421,53 +481,22 @@ export function NewEditorShell() {
}));
}, [document]);

const metadataChainRef = useRef(Promise.resolve());
const handleLoadedMetadata = useCallback(
(durationSec: number, assetId: string) => {
// ponytail: WebM recordings from MediaRecorder report NaN/Infinity
// until the main-process EBML fix lands. Fall back to a 60s seed if
// duration is unknown so the timeline never gets stuck on an empty
// placeholder. All store reads go through getState() to avoid
// stale-closure bugs.
const known = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 60;
const state = useProjectStore.getState();
// stale-closure bugs. The queued work is bound to the project that
// owned the video when this fired — draining after a project switch
// would seed the wrong primary.
const originatingProjectId = useProjectStore.getState().document?.project.id;
const known = isFiniteMediaDuration(durationSec) ? durationSec : 60;
setSourceDuration(known);
const doc = state.document;
if (!doc || doc.assets.length === 0) return;
if (doc.timeline.clips.length === 0) {
// ponytail: replaceTimeline derives clip length from
// asset.durationSec, which import never populates — without this
// patch the first auto-created clip silently comes out empty
// (normalizeIntervals clamps against a 0 duration and drops it).
const primaryAssetId = doc.project.primaryAssetId ?? doc.assets[0]?.id;
const docWithDuration = primaryAssetId
? {
...doc,
assets: doc.assets.map((a) =>
a.id === primaryAssetId ? { ...a, durationSec: known } : a,
),
}
: doc;
const next = replaceTimelineOp(
docWithDuration,
[{ startSec: 0, endSec: known }],
"Auto-created full-duration clip",
);
// `history: false` for both writes in this callback: they are the probed
// duration being folded into the document on load, not something the user
// did — an undo landing on one of them would empty their timeline.
void state.saveDocument(next, { history: false });
return;
}
// Hand the probed duration to the pure document layer: it patches only the
// clips of THIS asset that are still waiting for a real length (the
// pre-probe placeholder, or the extent-less clip a legacy v2 import mints),
// shifts what follows, and brings the modifiers along — anchoring the ones
// migration had to leave unanchored. Returns the document untouched when
// nothing is waiting, so there is nothing to guard here.
const next = applyProbedDuration(doc, assetId, known);
if (next !== doc) {
void state.saveDocument(next, { history: false });
}
metadataChainRef.current = metadataChainRef.current
.catch(() => undefined)
.then(() => runLoadedMetadataWrite(durationSec, assetId, originatingProjectId));
},
[setSourceDuration],
);
Expand Down Expand Up @@ -1559,6 +1588,10 @@ export function NewEditorShell() {
hasProject={hasProject}
hasAsset={hasAsset}
videoSources={videoSources}
// While the timeline is empty the preview mounts this asset rather
// than whichever one sorts first, so the clip `handleLoadedMetadata`
// seeds comes from the video it is sized against.
primaryAssetId={document?.project.primaryAssetId}
// Imported audio tracks (issue #350). `videoSources` already
// resolves a URL for every asset (audio included), so it doubles as
// the audio source list; VirtualPreview looks each track up by assetId.
Expand Down
Loading
Loading