diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 208942e2..4a69effe 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -41,6 +41,7 @@ import { } from "./Modals"; import { Preview } from "./Preview"; import type { TrimTarget } from "./RightPanes"; +import { importPendingRecording } from "./recordingImport"; import v4 from "./v4/EditorShellV4.module.css"; import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar"; import { type Facet, FloatingInspector } from "./v4/FloatingInspector"; @@ -91,7 +92,6 @@ export function NewEditorShell() { const projectId = useProjectStore((s) => s.projectId); const dirty = useProjectStore((s) => s.dirty); const createProject = useProjectStore((s) => s.createProject); - const addAsset = useProjectStore((s) => s.addAsset); const setCurrentTime = useProjectStore((s) => s.setCurrentTime); const setSourceDuration = useProjectStore((s) => s.setSourceDuration); const loadProject = useProjectStore((s) => s.loadProject); @@ -222,59 +222,45 @@ export function NewEditorShell() { void (async () => { if (!window.electronAPI) return; try { - const result = await window.electronAPI.getCurrentRecordingSession(); - if (!result.success || !result.session?.screenVideoPath) { - // ponytail: no active recording — try to restore the user's - // most recent project. The browser-shim's listProjects - // returns the seeded `browser-shim-projects` entries, so - // e2e tests can land directly in a populated editor; for - // real Electron users this is the expected "open last - // project on launch" UX. - try { - const projects = await nativeBridgeClient.aiEdition.listProjects(); - console.info("[editor] listProjects returned", projects); - if (projects.length > 0) { - console.info("[editor] auto-loading project", projects[0].id); - await loadProject(projects[0].id); - const state = useProjectStore.getState(); - console.info( - "[editor] post-loadProject status=", - state.status, - "error=", - JSON.stringify(state.error), - "doc=", - state.document ? "loaded" : "null", - ); - } - } catch (e) { - console.warn("[editor] auto-load failed", e); - } + if (await importPendingRecording()) { + toast.success("Recording added to a new project"); return; } - const screenPath = result.session.screenVideoPath; - const label = screenPath.split(/[\\/]/).pop() || "Recording"; - await createProject(`Recording ${new Date().toLocaleString()}`); - await addAsset(screenPath, label); - // ponytail: MediaRecorder WebMs ship with duration = NaN until - // fix-webm-duration patches the EBML header; until that flows - // through the asset, drop a default 60s clip into the timeline - // so the editor isn't stuck on "No clips yet" the moment the - // user lands in the project. Real duration overwrites this - // when handleLoadedMetadata fires with a finite value. - const doc = useProjectStore.getState().document; - if (doc && doc.timeline.clips.length === 0 && doc.assets.length > 0) { - await useProjectStore - .getState() - .replaceTimeline([{ startSec: 0, endSec: 60 }], "Auto-imported recording"); - } - toast.success("Recording added to a new project"); } catch (err) { toast.error("Could not auto-create project from recording", { description: err instanceof Error ? err.message : String(err), }); + return; + } + // ponytail: no recording waiting — restore the user's most recent + // project. The browser-shim's listProjects returns the seeded + // `browser-shim-projects` entries, so e2e tests can land directly in a + // populated editor; for real Electron users this is the expected "open + // last project on launch" UX — and, now that the recording hand-off is + // consumed on import, it is also what reopening the editor after a + // recording lands on: the project that recording went into, settings and + // all, instead of a second project on the same file. + try { + const projects = await nativeBridgeClient.aiEdition.listProjects(); + console.info("[editor] listProjects returned", projects); + if (projects.length > 0) { + console.info("[editor] auto-loading project", projects[0].id); + await loadProject(projects[0].id); + const state = useProjectStore.getState(); + console.info( + "[editor] post-loadProject status=", + state.status, + "error=", + JSON.stringify(state.error), + "doc=", + state.document ? "loaded" : "null", + ); + } + } catch (e) { + console.warn("[editor] auto-load failed", e); } })(); - }, [addAsset, createProject, loadProject]); + }, [loadProject]); // Warn on close when dirty useEffect(() => { diff --git a/src/components/ai-edition/recordingImport.test.ts b/src/components/ai-edition/recordingImport.test.ts new file mode 100644 index 00000000..2c9728d4 --- /dev/null +++ b/src/components/ai-edition/recordingImport.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { importPendingRecording } from "./recordingImport"; + +// The store's own bridge calls are never reached — every action the import uses +// is stubbed below — but importing the store pulls the client in, so stub it. +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } })); + +const createProject = vi.fn(async () => undefined); +const addAsset = vi.fn(async () => null); +const replaceTimeline = vi.fn(async () => undefined); + +/** Stands in for the main-process recording slot: one value, set and read. */ +function stubElectronApi(screenVideoPath: string | null) { + let session = screenVideoPath ? { screenVideoPath, createdAt: 0 } : null; + const api = { + getCurrentRecordingSession: vi.fn(async () => + session ? { success: true, session } : { success: false }, + ), + setCurrentRecordingSession: vi.fn(async (next: typeof session) => { + session = next; + return { success: true }; + }), + }; + // biome-ignore lint/suspicious/noExplicitAny: test-only stub of the contextBridge surface + (window as any).electronAPI = api; + return api; +} + +describe("importPendingRecording", () => { + beforeEach(() => { + vi.clearAllMocks(); + useProjectStore.setState({ + document: null, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched + createProject: createProject as any, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched + addAsset: addAsset as any, + replaceTimeline, + }); + }); + + it("does nothing when no recording is waiting", async () => { + stubElectronApi(null); + await expect(importPendingRecording()).resolves.toBe(false); + expect(createProject).not.toHaveBeenCalled(); + }); + + it("imports the recording into a new project and consumes the hand-off", async () => { + const api = stubElectronApi("C:\\recordings\\recording-1.mp4"); + + await expect(importPendingRecording()).resolves.toBe(true); + + expect(createProject).toHaveBeenCalledTimes(1); + expect(addAsset).toHaveBeenCalledWith("C:\\recordings\\recording-1.mp4", "recording-1.mp4"); + expect(api.setCurrentRecordingSession).toHaveBeenCalledWith(null); + }); + + // The regression: the editor window is destroyed and recreated on every open, + // so a session left in the slot was imported again — a second project on the + // same recording, at default settings, with the user's saved ones stranded in + // the first one. + it("imports one recording once, however often the editor mounts", async () => { + stubElectronApi("C:\\recordings\\recording-1.mp4"); + + await importPendingRecording(); + await expect(importPendingRecording()).resolves.toBe(false); + + expect(createProject).toHaveBeenCalledTimes(1); + expect(addAsset).toHaveBeenCalledTimes(1); + }); + + it("seeds a placeholder clip when the imported asset has none", async () => { + stubElectronApi("/recordings/recording-1.webm"); + addAsset.mockImplementationOnce(async () => { + useProjectStore.setState({ + // biome-ignore lint/suspicious/noExplicitAny: only the two fields the seed reads + document: { assets: [{ id: "a1" }], timeline: { clips: [] } } as any, + }); + return null; + }); + + await importPendingRecording(); + + expect(replaceTimeline).toHaveBeenCalledWith( + [{ startSec: 0, endSec: 60 }], + "Auto-imported recording", + ); + }); +}); diff --git a/src/components/ai-edition/recordingImport.ts b/src/components/ai-edition/recordingImport.ts new file mode 100644 index 00000000..0b9b656b --- /dev/null +++ b/src/components/ai-edition/recordingImport.ts @@ -0,0 +1,55 @@ +// Hand-off from the recorder to the editor. +// +// The HUD parks the recording it just finished in ONE main-process slot +// (`set/getCurrentRecordingSession`) and opens the editor, which imports it into +// a fresh project on mount. The slot has to be emptied once that project owns +// the file, because opening the editor destroys and recreates its window +// (`createEditorWindowWrapper` in electron/main.ts) — so a session left in place +// is imported AGAIN on the next open: a second project on the same recording, +// back at the default padding / roundness / wallpaper, while everything the user +// set and saved stays behind in the first project, which is no longer the one on +// screen. That reads exactly like "the editor forgot my settings" (#364). +// +// `setCurrentRecordingSession(null)` is the existing clear (it also drops the +// derived `currentVideoPath`); the only renderer that still needs the session +// after this point is the CLI runner, which lives in its own process. + +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; + +/** + * Imports the recording the HUD handed over into a new project, and consumes the + * hand-off so it is imported exactly once. + * + * Returns false when there is nothing pending — the caller then falls back to + * reopening the most recent project. Throws if the import itself fails, leaving + * the session in place so a later mount can retry it. + */ +export async function importPendingRecording(): Promise { + const api = window.electronAPI; + if (!api) return false; + + const result = await api.getCurrentRecordingSession(); + const screenPath = result.success ? result.session?.screenVideoPath : undefined; + if (!screenPath) return false; + + const label = screenPath.split(/[\\/]/).pop() || "Recording"; + await useProjectStore.getState().createProject(`Recording ${new Date().toLocaleString()}`); + await useProjectStore.getState().addAsset(screenPath, label); + // Consumed: the recording now lives in a project. Cleared here rather than + // after the timeline seed below so a failure down there can't hand the same + // recording to the next editor window. + await api.setCurrentRecordingSession(null); + + // ponytail: MediaRecorder WebMs ship with duration = NaN until + // fix-webm-duration patches the EBML header; until that flows through the + // asset, drop a default 60s clip into the timeline so the editor isn't stuck + // on "No clips yet" the moment the user lands in the project. Real duration + // overwrites this when handleLoadedMetadata fires with a finite value. + const doc = useProjectStore.getState().document; + if (doc && doc.timeline.clips.length === 0 && doc.assets.length > 0) { + await useProjectStore + .getState() + .replaceTimeline([{ startSec: 0, endSec: 60 }], "Auto-imported recording"); + } + return true; +}