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
15 changes: 13 additions & 2 deletions electron/ai-edition/document-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type AxcutDocument,
createEmptyDocument,
documentSchema,
migrateRawDocumentToCurrent,
} from "../../src/lib/ai-edition/schema";

const PROJECT_FILE_EXTENSION = ".openscreen";
Expand Down Expand Up @@ -78,6 +79,16 @@ function safeProjectId(raw: string): string {
return raw;
}

// ponytail: load-time migration hook. The on-disk file may carry any supported
// `schemaVersion` (v2 EditorProjectData handled separately by
// `migrateProjectDataToAxcutDocument`; v3 / v4 AxcutDocuments handled here).
// `documentSchema.parse` is now a pure v6 validator — every JSON-read path
// (list, get, future bulk-export) must run the upgrader chain first via this
// helper so the in-memory parse is a single `z.literal(6)` + shape check.
function parseLoadedDocument(raw: string): AxcutDocument {
return documentSchema.parse(migrateRawDocumentToCurrent(JSON.parse(raw)));
}

/**
* Windows fails a rename onto an open file with EPERM/EBUSY: an indexer, an
* antivirus or a backup agent can hold the destination for a few milliseconds
Expand Down Expand Up @@ -169,7 +180,7 @@ export class DocumentService {
const filePath = path.join(this.projectsRoot, name);
try {
const raw = await fs.readFile(filePath, "utf8");
const parsed = documentSchema.parse(JSON.parse(raw));
const parsed = parseLoadedDocument(raw);
summaries.push({
id: parsed.project.id,
title: parsed.project.title,
Expand Down Expand Up @@ -211,7 +222,7 @@ export class DocumentService {
);
}
}
return documentSchema.parse(JSON.parse(raw));
return parseLoadedDocument(raw);
}

async createProject(title: string): Promise<AxcutDocument> {
Expand Down
6 changes: 5 additions & 1 deletion src/components/ai-edition/EditorEmptyState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ const bridgeMocks = vi.hoisted(() => ({
}));

const sampleDoc = vi.hoisted(() => ({
schemaVersion: 3,
// ponytail: the bridge contract after the migration hoist is v6 — every
// load site (DocumentService, browserShim) runs `migrateRawDocumentToCurrent`
// before returning, and the renderer's `parseDocument` is a pure v6
// validator. Test fixtures model the post-hoist contract.
schemaVersion: 6,
project: {
id: "proj_test",
title: "Test",
Expand Down
7 changes: 5 additions & 2 deletions src/components/ai-edition/EditorEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import { AlertCircle, 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 } from "@/lib/ai-edition/document/migrate";
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";
Expand Down Expand Up @@ -82,7 +85,7 @@ export function EditorEmptyState({
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
? documentSchema.parse(raw)
? 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;
Expand Down
7 changes: 5 additions & 2 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type { EditorProjectData } from "@/components/video-editor/projectPersist
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
import {
migrateProjectDataToAxcutDocument,
migrateRawDocumentToCurrent,
} from "@/lib/ai-edition/document/migrate";
import {
applyProbedDuration,
replaceTimeline as replaceTimelineOp,
Expand Down Expand Up @@ -520,7 +523,7 @@ export function NewEditorShell() {
const isAxcutDocument =
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
const doc = isAxcutDocument
? documentSchema.parse(raw) // validates + upgrades v3v4
? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4v5, then validate
: migrateProjectDataToAxcutDocument(raw as EditorProjectData);
const saved = await nativeBridgeClient.aiEdition.save(doc);
if (saved.success && saved.document) {
Expand Down
138 changes: 137 additions & 1 deletion src/lib/ai-edition/document/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import type { EditorProjectData } from "@/components/video-editor/projectPersistence";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { migrateAxcutDocumentToProjectData, migrateProjectDataToAxcutDocument } from "./migrate";
import { documentSchema } from "../schema";
import {
migrateAxcutDocumentToProjectData,
migrateProjectDataToAxcutDocument,
migrateRawDocumentToCurrent,
} from "./migrate";

function makeV2Project(overrides: Partial<EditorProjectData> = {}): EditorProjectData {
return {
Expand Down Expand Up @@ -338,3 +343,134 @@ describe("migrateAxcutDocumentToProjectData", () => {
expect(doc.zoomRanges[0].focus.cy).toBe(0);
});
});

// Load-time migration helper. Replaces the `z.preprocess` chain that used to
// run on every `documentSchema.parse(...)` call site. The pre-hoist chain ran
// v3→v4 and v4→v5 on every parse, including in-memory parses that were
// already v5; the post-hoist chain runs once at the disk (or localStorage)
// read site, and the in-memory `documentSchema.parse` is a pure v6 validator.

describe("migrateRawDocumentToCurrent", () => {
const createdAt = "2024-01-01T00:00:00.000Z";

function makeV3Doc(overrides: Record<string, unknown> = {}) {
return {
schemaVersion: 3,
project: { id: "p", title: "t", createdAt, updatedAt: createdAt },
assets: [
{ id: "asset_1", kind: "video", label: "a1", originalPath: "/a1.mp4" },
{ id: "asset_2", kind: "video", label: "a2", originalPath: "/a2.mp4" },
],
cameraTrack: { sourcePath: "/cam.mp4", startMs: 0, offsetMs: 0, visible: true },
...overrides,
};
}

function makeV4Doc(overrides: Record<string, unknown> = {}) {
return {
schemaVersion: 4,
project: { id: "p", title: "t", createdAt, updatedAt: createdAt },
assets: [{ id: "a", kind: "video", label: "A", originalPath: "/a.mp4", cameraTrack: null }],
timeline: {
clips: [
{
id: "c1",
assetId: "a",
sourceStartSec: 0,
sourceEndSec: 10,
timelineStartSec: 0,
timelineEndSec: 10,
origin: "user",
},
],
},
...overrides,
};
}

it("upgrades a v3 document to v6 (cameraTrack relocated onto the primary asset)", () => {
// Models the full load path: every disk-read site runs the helper, then
// the schema parse fills in defaults (cameraTrack: null on non-target
// assets). The helper alone is just the upgrader chain; the schema is
// what produces the final v5 shape with defaults filled in.
const migrated = documentSchema.parse(
migrateRawDocumentToCurrent(
makeV3Doc({
project: {
id: "p",
title: "t",
createdAt,
updatedAt: createdAt,
primaryAssetId: "asset_2",
},
}),
),
);
expect(migrated.schemaVersion).toBe(6);
expect((migrated as Record<string, unknown>).cameraTrack).toBeUndefined();
expect(migrated.assets[0].cameraTrack).toBeNull();
expect(migrated.assets[1].cameraTrack?.sourcePath).toBe("/cam.mp4");
});

it("upgrades a v4 document to v6 (anchors modifiers onto clips)", () => {
const migrated = migrateRawDocumentToCurrent(
makeV4Doc({
zoomRanges: [
{ id: "z1", startMs: 2000, endMs: 5000, depth: 3, focus: { cx: 0.5, cy: 0.5 } },
],
}),
) as Record<string, unknown>;
expect(migrated.schemaVersion).toBe(6);
const zooms = migrated.zoomRanges as Array<Record<string, unknown>>;
expect(zooms).toHaveLength(1);
expect(zooms[0]).toMatchObject({ id: "z1", clipId: "c1", depth: 3 });
});

it("is a no-op for an already-current document (returns an equal value)", () => {
const v5 = makeV4Doc(); // makeV4Doc's body is the v5-compatible shape
const once = migrateRawDocumentToCurrent({ ...v5, schemaVersion: 6 });
// ponytail: the upgrader chain checks schemaVersion and returns the input
// unchanged, so the round-trip allocation is bounded to a property
// comparison per upgrader — the same per-parse overhead the old
// `z.preprocess` carried.
expect(once).toEqual({ ...v5, schemaVersion: 6 });
});

it("passes non-document input through unchanged (the schema is the gate, not this helper)", () => {
// null, primitives, arrays — none of these are v3/v4 documents, so the
// upgraders return them untouched. The downstream `documentSchema.parse`
// is what rejects them via the `schemaVersion` literal.
expect(migrateRawDocumentToCurrent(null)).toBe(null);
expect(migrateRawDocumentToCurrent(undefined)).toBe(undefined);
expect(migrateRawDocumentToCurrent(42)).toBe(42);
expect(migrateRawDocumentToCurrent("not-a-doc")).toBe("not-a-doc");
expect(migrateRawDocumentToCurrent([])).toEqual([]);
});

it("passes v2 input through unchanged (the legacy migrator is a separate path)", () => {
// The pre-hoist schema's `z.preprocess` also passed v2 through; the
// post-hoist helper keeps the same shape so `documentSchema.parse` is
// the single rejection point for unknown versions.
const v2ish = { schemaVersion: 2, project: { id: "p" } };
const out = migrateRawDocumentToCurrent(v2ish) as Record<string, unknown>;
expect(out.schemaVersion).toBe(2);
});

it("the upgraded v5 result round-trips through documentSchema.parse with no error", () => {
// The whole point of the hoist: after `migrateRawDocumentToCurrent`
// runs once at load, the in-memory parse is a pure v6 validation
// step. This is the contract every load site relies on.
const upgraded = migrateRawDocumentToCurrent(
makeV3Doc({
project: {
id: "p",
title: "t",
createdAt,
updatedAt: createdAt,
primaryAssetId: "asset_1",
},
}),
);
expect(() => documentSchema.parse(upgraded)).not.toThrow();
});
});
21 changes: 16 additions & 5 deletions src/lib/ai-edition/document/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type AxcutTrimRange,
type AxcutZoomRegion,
documentSchema,
migrateRawDocumentToCurrent,
} from "../schema";
import { createId } from "./ids";

Expand All @@ -54,6 +55,16 @@ function clampSec(sec: number): number {
return Math.round(sec * 1000) / 1000;
}

/**
* Re-exported from `../schema`, where the composer lives so the Electron main
* process can import it without dragging this module's `@/`-aliased value
* imports into a bundle that has no alias configured.
*
* v2 inputs are not handled by it — `migrateProjectDataToAxcutDocument` below
* still owns the legacy EditorProjectData → AxcutDocument translation.
*/
export { migrateRawDocumentToCurrent };

function toLegacyMedia(input: ProjectMedia | undefined): ProjectMedia | null {
if (!input) return null;
const media: ProjectMedia = { screenVideoPath: input.screenVideoPath };
Expand Down Expand Up @@ -199,12 +210,12 @@ export function migrateProjectDataToAxcutDocument(
const legacyEditor: AxcutLegacyEditor = input.editor ? { ...input.editor } : null;

// Emits the **v4** shape (per-asset cameraTrack + RAW-virtual-ms regions) and lets
// `documentSchema`'s v4→v5 preprocess perform the clip-anchoring, so the
// `migrateRawDocumentToCurrent` perform the v4→v5 clip-anchoring, so the
// modifier migration lives in exactly ONE place instead of being duplicated here.
// Deliberately not `axcutSchemaVersion`: that would label the draft as already-v5
// and the preprocess would skip anchoring, leaving v2-imported regions unanchored.
// Untyped on purpose — this is the INPUT to `documentSchema.parse` (which upgrades
// and validates it), not an already-valid v5 document.
// and the upgrader would skip anchoring, leaving v2-imported regions unanchored.
// Untyped on purpose — this is the INPUT to `documentSchema.parse` (which
// validates it), not an already-valid v5 document.
const draft = {
schemaVersion: 4,
project: {
Expand All @@ -230,7 +241,7 @@ export function migrateProjectDataToAxcutDocument(
legacyEditor,
};

return documentSchema.parse(draft);
return documentSchema.parse(migrateRawDocumentToCurrent(draft));
}

/**
Expand Down
Loading
Loading