Skip to content
Merged
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
78 changes: 32 additions & 46 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand Down
91 changes: 91 additions & 0 deletions src/components/ai-edition/recordingImport.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
55 changes: 55 additions & 0 deletions src/components/ai-edition/recordingImport.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
Loading