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
30 changes: 15 additions & 15 deletions electron/ai-edition/document-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,21 +870,6 @@ describe("DocumentService", () => {
expect((await service.getProject(created.project.id)).project.title).toBe("Synced");
});

it("counts the open-file dialog reading the project's own file as a read", async () => {
const created = await service.createProject("Browsed");
const { document, version } = await service.getProjectForUpdate(created.project.id);
await service.beforeProjectFileRead(path.join(tempDir, `${created.project.id}.openscreen`));
expect(await service.saveProjectIfUnchanged(renamed(document, "Edited"), version)).toBeNull();
});

it("ignores the open-file dialog reading a file outside the projects folder", async () => {
const created = await service.createProject("Elsewhere");
const { document, version } = await service.getProjectForUpdate(created.project.id);
await service.beforeProjectFileRead(path.join(mediaDir, `${created.project.id}.openscreen`));
const saved = await service.saveProjectIfUnchanged(renamed(document, "Edited"), version);
expect(saved?.project.title).toBe("Edited");
});

it("writes nothing over a project deleted since", async () => {
const created = await service.createProject("Deleted");
const { document, version } = await service.getProjectForUpdate(created.project.id);
Expand All @@ -893,4 +878,19 @@ describe("DocumentService", () => {
await expect(service.getProject(created.project.id)).rejects.toThrow(DocumentNotFoundError);
});
});

describe("storedProjectId", () => {
it("names the project a file in the projects folder is the own file of", () => {
expect(service.storedProjectId(path.join(tempDir, "proj_a.openscreen"))).toBe("proj_a");
expect(service.storedProjectId(path.join(tempDir, "proj_a.axcut"))).toBe("proj_a");
});

it("names nothing for a file anywhere else", () => {
expect(service.storedProjectId(path.join(mediaDir, "proj_a.openscreen"))).toBeUndefined();
expect(
service.storedProjectId(path.join(tempDir, "sub", "proj_a.openscreen")),
).toBeUndefined();
expect(service.storedProjectId(path.join(tempDir, "proj_a.json"))).toBeUndefined();
});
});
});
18 changes: 8 additions & 10 deletions electron/ai-edition/document-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,21 +336,19 @@ export class DocumentService {
}

/**
* For a read of a project file that bypasses getProject: the open-file dialog,
* which then saves back what it read. If the file is one of this service's
* projects, that counts as a read of it, behind its queued reads and saves, as in
* getProject. Any other file is not this service's business.
* The id of the project `filePath` is the own file of, or undefined for any other
* file. The open-file dialog hands it to the editor, which then loads that project
* instead of saving back the file as read, over any change made since.
*/
async beforeProjectFileRead(filePath: string): Promise<void> {
storedProjectId(filePath: string): string | undefined {
const resolved = path.resolve(filePath);
if (path.relative(path.resolve(this.projectsRoot), path.dirname(resolved)) !== "") return;
if (path.relative(path.resolve(this.projectsRoot), path.dirname(resolved)) !== "") {
return undefined;
}
const extension = [PROJECT_FILE_EXTENSION, LEGACY_PROJECT_FILE_EXTENSION].find((ext) =>
resolved.endsWith(ext),
);
if (!extension) return;
const projectId = path.basename(resolved, extension);
this.touch(projectId);
await this.writeQueues.get(projectId);
return extension ? path.basename(resolved, extension) : undefined;
}

private touch(projectId: string): number {
Expand Down
2 changes: 2 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ interface Window {
success: boolean;
path?: string;
project?: unknown;
storedProjectId?: string;
message?: string;
canceled?: boolean;
error?: string;
Expand All @@ -444,6 +445,7 @@ interface Window {
success: boolean;
path?: string;
project?: unknown;
storedProjectId?: string;
message?: string;
canceled?: boolean;
error?: string;
Expand Down
11 changes: 7 additions & 4 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4718,8 +4718,6 @@ export function registerIpcHandlers(
}

const filePath = result.filePaths[0];
// The editor saves back what it opened here: see beforeProjectFileRead.
await aiEditionDocuments.beforeProjectFileRead(filePath);
const content = await fs.readFile(filePath, "utf-8");
const project = await relinkProjectMedia(JSON.parse(content), DEFAULT_RECORDINGS_DIR);
currentProjectPath = filePath;
Expand All @@ -4738,6 +4736,7 @@ export function registerIpcHandlers(
success: true,
path: filePath,
project,
storedProjectId: aiEditionDocuments.storedProjectId(filePath),
};
} catch (error) {
console.error("Failed to load project file:", error);
Expand Down Expand Up @@ -4766,7 +4765,6 @@ export function registerIpcHandlers(
if (!stats?.isFile()) {
return { success: false, message: "File not found" };
}
await aiEditionDocuments.beforeProjectFileRead(filePath);
const content = await fs.readFile(filePath, "utf-8");
const project = await relinkProjectMedia(JSON.parse(content), DEFAULT_RECORDINGS_DIR);
currentProjectPath = filePath;
Expand Down Expand Up @@ -4796,7 +4794,12 @@ export function registerIpcHandlers(
);
}
setCurrentRecordingSessionState(session);
return { success: true, path: filePath, project };
return {
success: true,
path: filePath,
project,
storedProjectId: aiEditionDocuments.storedProjectId(filePath),
};
} catch (error) {
console.error("Failed to load project file from path:", error);
return {
Expand Down
34 changes: 7 additions & 27 deletions src/components/ai-edition/EditorEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,8 @@ import { AlertCircle, Circle, Film, FolderOpen, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
import {
migrateProjectDataToAxcutDocument,
migrateRawDocumentToCurrent,
} from "@/lib/ai-edition/document/migrate";
import { documentSchema } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { nativeBridgeClient } from "@/native";
import { importProjectFile } from "./importProjectFile";
import styles from "./NewEditorShell.module.css";

type DropError = "unsupported-format" | "load-failed" | null;
Expand Down Expand Up @@ -77,22 +72,11 @@ export function EditorEmptyState({
}
}, [addAsset, ensureProject]);

// A loaded project JSON is either a current AxcutDocument (has its own
// `schemaVersion`) or a legacy EditorProjectData that must be migrated.
// Discriminate on the version field so a current document is never fed to
// the legacy migrator (which reads `.media`/`.editor` and would yield an
// empty doc). Returns true once the project is saved and loaded.
// What the open-file dialog or a drop read: see importProjectFile.
const openLoadedProject = useCallback(
async (raw: unknown): Promise<boolean> => {
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate
: migrateProjectDataToAxcutDocument(raw as never);
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (!saved.success || !saved.document) return false;
await loadProject(doc.project.id);
return true;
async (file: { project?: unknown; storedProjectId?: string }) => {
const { projectId } = await importProjectFile(file.project, file.storedProjectId);
await loadProject(projectId);
},
[loadProject],
);
Expand All @@ -101,9 +85,7 @@ export function EditorEmptyState({
try {
const result = await window.electronAPI?.loadProjectFile?.();
if (!result?.success || !result.project) return;
if (!(await openLoadedProject(result.project))) {
setDropError("load-failed");
}
await openLoadedProject(result);
} catch {
setDropError("load-failed");
}
Expand Down Expand Up @@ -150,9 +132,7 @@ export function EditorEmptyState({
return;
}
try {
if (!(await openLoadedProject(result.project))) {
setDropError("load-failed");
}
await openLoadedProject(result);
} catch {
setDropError("load-failed");
}
Expand Down
34 changes: 5 additions & 29 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import type { EditorProjectData } from "@/components/video-editor/projectPersistence";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import { useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
Expand All @@ -10,10 +9,6 @@ import {
computeAudioRowCount,
} from "@/lib/ai-edition/document/audioTracks";
import { createId } from "@/lib/ai-edition/document/ids";
import {
migrateProjectDataToAxcutDocument,
migrateRawDocumentToCurrent,
} from "@/lib/ai-edition/document/migrate";
import {
documentAfterProbedDuration,
PLACEHOLDER_DURATION_SEC,
Expand All @@ -25,12 +20,7 @@ import {
setDocumentWordText,
} from "@/lib/ai-edition/document/transcript";
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
import {
type AxcutAudioTrack,
type AxcutClip,
type AxcutDocument,
documentSchema,
} from "@/lib/ai-edition/schema";
import type { AxcutAudioTrack, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
import { useMcpDocumentHost } from "@/lib/ai-edition/store/mcpDocumentHost";
import { saveWithDeadline, useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
Expand Down Expand Up @@ -59,6 +49,7 @@ import type { AiEditionProjectSummary } from "@/native/contracts";
import { resolveVisibleClips } from "@/native/sceneDescription";
import { useNativePlaybackSync } from "@/native/useNativePlaybackSync";
import { ExportDialog } from "./ExportDialog";
import { importProjectFile } from "./importProjectFile";
import { insertionsEnabled } from "./insertionsEnabled";
import { ChatStripPanel } from "./LeftPanel";
import {
Expand Down Expand Up @@ -650,24 +641,9 @@ export function NewEditorShell() {
try {
const result = await window.electronAPI?.loadProjectFile();
if (!result?.success || !result.project) return;
const raw = result.project as unknown;
// A current project file already carries its own `schemaVersion` (v3/v4
// AxcutDocument); an older legacy export is EditorProjectData and must be
// migrated. Discriminate on the version field so a current document is
// never fed to the legacy migrator (which reads `.media`/`.editor` and
// would yield an empty doc).
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate
: migrateProjectDataToAxcutDocument(raw as EditorProjectData);
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (saved.success && saved.document) {
await loadProject(doc.project.id);
toast.success(isAxcutDocument ? "Project opened" : "Legacy project migrated and loaded");
} else {
toast.error(saved.error ?? "Failed to open project");
}
const { projectId, legacy } = await importProjectFile(result.project, result.storedProjectId);
await loadProject(projectId);
toast.success(legacy ? "Legacy project migrated and loaded" : "Project opened");
} catch (err) {
toast.error("Could not load project", {
description: err instanceof Error ? err.message : String(err),
Expand Down
56 changes: 56 additions & 0 deletions src/components/ai-edition/importProjectFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema";
import { DocumentService } from "../../../electron/ai-edition/document-service";
import { importProjectFile } from "./importProjectFile";

// The bridge's save is the main process's: the real DocumentService, on a temp folder.
const bridge = vi.hoisted(() => ({ save: vi.fn() }));
vi.mock("@/native", () => ({ nativeBridgeClient: { aiEdition: bridge } }));

describe("importProjectFile", () => {
let projectsDir: string;
let otherDir: string;
let service: DocumentService;

beforeEach(async () => {
projectsDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-projects-"));
otherDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-elsewhere-"));
service = new DocumentService(projectsDir, otherDir);
bridge.save.mockImplementation(async (doc: AxcutDocument) => ({
success: true,
document: await service.saveProject(doc),
}));
});

afterEach(async () => {
await fs.rm(projectsDir, { recursive: true, force: true });
await fs.rm(otherDir, { recursive: true, force: true });
});

it("keeps an MCP edit made between the dialog reading a project's own file and opening it", async () => {
const created = await service.createProject("As read");
const file = path.join(projectsDir, `${created.project.id}.openscreen`);
const read: unknown = JSON.parse(await fs.readFile(file, "utf8"));

const { document, version } = await service.getProjectForUpdate(created.project.id);
const edited = { ...document, project: { ...document.project, title: "Edited over MCP" } };
expect(await service.saveProjectIfUnchanged(edited, version)).not.toBeNull();

const { projectId } = await importProjectFile(read, service.storedProjectId(file));

expect(projectId).toBe(created.project.id);
expect((await service.getProject(projectId)).project.title).toBe("Edited over MCP");
});

it("saves a file from outside the projects folder as a project", async () => {
const doc = createEmptyDocument({ projectId: "proj_imported", title: "Imported" });
const file = path.join(otherDir, "proj_imported.openscreen");

const { projectId } = await importProjectFile(doc, service.storedProjectId(file));

expect((await service.getProject(projectId)).project.title).toBe("Imported");
});
});
43 changes: 43 additions & 0 deletions src/components/ai-edition/importProjectFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// What the open-file dialog (or a drop) read, turned into a project the editor can load.

import type { EditorProjectData } from "@/components/video-editor/projectPersistence";
import {
migrateProjectDataToAxcutDocument,
migrateRawDocumentToCurrent,
} from "@/lib/ai-edition/document/migrate";
import { documentSchema } from "@/lib/ai-edition/schema";
import { nativeBridgeClient } from "@/native";

/**
* Saves `raw` as a project and returns its id, for the caller to load.
*
* A current project file carries its own `schemaVersion` (an AxcutDocument) and is upgraded;
* anything else is a legacy EditorProjectData and is migrated. Discriminate on the version
* field so a current document is never fed to the legacy migrator (which reads
* `.media`/`.editor` and would yield an empty doc).
*
* A project's own file (`storedProjectId`) is not saved: saving it back would write the file
* as read over any change made since, an MCP edit included. Loading it by id reads it again,
* as opening it from the project list does.
*/
export async function importProjectFile(
raw: unknown,
storedProjectId?: string,
): Promise<{ projectId: string; legacy: boolean }> {
const legacy = !(
typeof raw === "object" &&
raw !== null &&
"schemaVersion" in raw &&
"timeline" in raw
);
const doc = legacy
? migrateProjectDataToAxcutDocument(raw as EditorProjectData)
: documentSchema.parse(migrateRawDocumentToCurrent(raw)); // disk-load: upgrade, then validate
if (doc.project.id !== storedProjectId) {
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (!saved.success || !saved.document) {
throw new Error(saved.error ?? "Failed to open project");
}
}
return { projectId: doc.project.id, legacy };
}
2 changes: 2 additions & 0 deletions src/native/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export interface ProjectFileResult {
success: boolean;
path?: string;
project?: unknown;
/** Set when the file is a stored project's own file: load that project, never save it back. */
storedProjectId?: string;
message?: string;
canceled?: boolean;
error?: string;
Expand Down
3 changes: 1 addition & 2 deletions technical-documentation/architecture/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ A call naming a project reads its file **before** asking the editor for a snapsh

There is no revision to guard the file with, so the save is refused with "NOT applied … re-read, then retry" when, since the call's read:

1. **Anything read the project.** This is the editor opening it: from that read on it holds a copy without the MCP edit, and its next save would drop the edit without a word. A snapshot cannot see this in time, since the editor installs what it read only after the read returns. `DocumentService` counts every read and save of a project instead, the open-file dialog's direct read of a project file included (`beforeProjectFileRead`). The client's retry, with the same `projectId`, then goes through the editor.
1. **Anything read the project.** This is the editor opening it: from that read on it holds a copy without the MCP edit, and its next save would drop the edit without a word. A snapshot cannot see this in time, since the editor installs what it read only after the read returns. `DocumentService` counts every read and save of a project instead. The client's retry, with the same `projectId`, then goes through the editor. The open-file dialog never saves back a project's own file: it loads that project by id (`storedProjectId`), a read like any other.
2. **Anything saved it, or its file changed.** The file must still be, byte for byte, the one the tool ran against. This also covers a writer outside the app (a sync tool, a restored copy) and a delete.

The check runs inside the project's queue, so nothing in the app reads or saves the project between the check and the write.
Expand Down Expand Up @@ -100,5 +100,4 @@ codex mcp add openscreen --url http://127.0.0.1:47821/mcp --bearer-token-env-var
- **Only what the agent can do.** The server exposes the agent's timeline tools. Recording, export, import and project management are not tools, for MCP or for the in-app agent.
- **No Ctrl+Z for edits to closed projects.** Their undo is the checkpoint the write requires, which lives in memory: it is gone once the app quits or 20 newer ones push it out, and the edits stay.
- **An editor that stops answering looks closed.** A snapshot unanswered for 30 s reads as "no project open", so an edit naming the project that editor holds would go to its file, and the editor's next save would drop it. It takes a renderer hung for 30 s.
- **The open-file dialog saves back what it read.** Opening a project's own file through it reads the file, then saves that content as the project. An MCP edit landing between the read and that save is overwritten, as any other change would be. Closing it is the dialog's job (not saving back a file that already is the project), not the server's.
- **No project management.** Projects can be listed, read and edited, not created, renamed or deleted.
Loading