diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index dee364024..08201eed2 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -18,6 +18,7 @@ import { type AxcutDocument, createEmptyDocument, documentSchema, + migrateRawDocumentToCurrent, } from "../../src/lib/ai-edition/schema"; const PROJECT_FILE_EXTENSION = ".openscreen"; @@ -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 @@ -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, @@ -211,7 +222,7 @@ export class DocumentService { ); } } - return documentSchema.parse(JSON.parse(raw)); + return parseLoadedDocument(raw); } async createProject(title: string): Promise { diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx index 86e319c52..9b33082b6 100644 --- a/src/components/ai-edition/EditorEmptyState.test.tsx +++ b/src/components/ai-edition/EditorEmptyState.test.tsx @@ -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", diff --git a/src/components/ai-edition/EditorEmptyState.tsx b/src/components/ai-edition/EditorEmptyState.tsx index f7e16a946..ae656c85d 100644 --- a/src/components/ai-edition/EditorEmptyState.tsx +++ b/src/components/ai-edition/EditorEmptyState.tsx @@ -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"; @@ -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; diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index e17c533a4..deeb4fdb3 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -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, @@ -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 v3 → v4 + ? 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) { diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index 9b9b0bfcd..d0d951a4a 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -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 { return { @@ -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 = {}) { + 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 = {}) { + 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).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; + expect(migrated.schemaVersion).toBe(6); + const zooms = migrated.zoomRanges as Array>; + 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; + 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(); + }); +}); diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index d8ee6e40d..efb64644f 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -29,6 +29,7 @@ import { type AxcutTrimRange, type AxcutZoomRegion, documentSchema, + migrateRawDocumentToCurrent, } from "../schema"; import { createId } from "./ids"; @@ -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 }; @@ -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: { @@ -230,7 +241,7 @@ export function migrateProjectDataToAxcutDocument( legacyEditor, }; - return documentSchema.parse(draft); + return documentSchema.parse(migrateRawDocumentToCurrent(draft)); } /** diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts index 9b3767c6e..fb5ce1cac 100644 --- a/src/lib/ai-edition/schema/index.test.ts +++ b/src/lib/ai-edition/schema/index.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { migrateRawDocumentToCurrent } from "../document/migrate"; import { annotationRegionSchema, assetSchema, @@ -202,23 +203,28 @@ describe("axcut-schema v6", () => { }); it("documentSchema defaults missing v3 envelopes on a v3 document", () => { + // After the migration hoist, a v3 doc must run through + // `migrateRawDocumentToCurrent` first; this models the new load-time + // contract: the schema parse is a pure v6 validation step. expect(() => - documentSchema.parse({ - schemaVersion: 3, - project: { - id: "p", - title: "t", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - assets: [], - transcript: null, - timeline: {}, - agent: {}, - preview: {}, - export: {}, - history: {}, - }), + documentSchema.parse( + migrateRawDocumentToCurrent({ + schemaVersion: 3, + project: { + id: "p", + title: "t", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + assets: [], + transcript: null, + timeline: {}, + agent: {}, + preview: {}, + export: {}, + history: {}, + }), + ), ).not.toThrow(); }); @@ -252,8 +258,12 @@ describe("axcut-schema v6", () => { } it("relocates a legacy top-level cameraTrack onto the primaryAssetId asset", () => { + // After the migration hoist, v3 input runs through the load-time + // upgrader before the pure v6 schema parse. const doc = documentSchema.parse( - v3Doc({ project: { ...v3Doc().project, primaryAssetId: "asset_2" } }), + migrateRawDocumentToCurrent( + v3Doc({ project: { ...v3Doc().project, primaryAssetId: "asset_2" } }), + ), ); expect(doc.schemaVersion).toBe(6); expect((doc as Record).cameraTrack).toBeUndefined(); @@ -262,20 +272,24 @@ describe("axcut-schema v6", () => { }); it("falls back to the first asset when there is no primaryAssetId", () => { - const doc = documentSchema.parse(v3Doc()); + const doc = documentSchema.parse(migrateRawDocumentToCurrent(v3Doc())); expect(doc.assets[0].cameraTrack?.sourcePath).toBe("/cam.mp4"); expect(doc.assets[1].cameraTrack).toBeNull(); }); it("is a no-op when the v3 document has no legacy cameraTrack", () => { - const doc = documentSchema.parse(v3Doc({ cameraTrack: null })); + const doc = documentSchema.parse(migrateRawDocumentToCurrent(v3Doc({ cameraTrack: null }))); expect(doc.schemaVersion).toBe(6); for (const asset of doc.assets) { expect(asset.cameraTrack).toBeNull(); } }); - it("still rejects schemaVersion 2 (only v3 is auto-upgraded)", () => { + it("rejects schemaVersion 2 (the load-time helper only upgrades v3/v4)", () => { + // The pre-hoist schema auto-upgraded v3 inside its `z.preprocess`; + // the post-hoist schema is a pure v6 validator, and the helper + // only handles v3/v4. v2 still requires the separate + // `migrateProjectDataToAxcutDocument` pure function. expect(() => documentSchema.parse(v3Doc({ schemaVersion: 2 }))).toThrow(); }); }); @@ -400,12 +414,16 @@ describe("v4 -> v5 clip-anchored modifier migration", () => { } it("bumps the version and anchors a zoom wholly inside one clip", () => { + // After the migration hoist, v4 input runs through the load-time + // upgrader before the pure v6 schema parse. const doc = documentSchema.parse( - makeV4Doc({ - zoomRanges: [ - { id: "z1", startMs: 2000, endMs: 5000, depth: 3, focus: { cx: 0.5, cy: 0.5 } }, - ], - }), + migrateRawDocumentToCurrent( + makeV4Doc({ + zoomRanges: [ + { id: "z1", startMs: 2000, endMs: 5000, depth: 3, focus: { cx: 0.5, cy: 0.5 } }, + ], + }), + ), ); expect(doc.schemaVersion).toBe(6); expect(doc.zoomRanges).toHaveLength(1); @@ -420,9 +438,11 @@ describe("v4 -> v5 clip-anchored modifier migration", () => { it("splits a straddling speed region into two fragments that still read as one pill", () => { const doc = documentSchema.parse( - makeV4Doc({ - legacyEditor: { speedRegions: [{ id: "s1", startMs: 8149, endMs: 28575, speed: 3 }] }, - }), + migrateRawDocumentToCurrent( + makeV4Doc({ + legacyEditor: { speedRegions: [{ id: "s1", startMs: 8149, endMs: 28575, speed: 3 }] }, + }), + ), ); const speeds = (doc.legacyEditor as Record).speedRegions as Array< Record @@ -441,42 +461,51 @@ describe("v4 -> v5 clip-anchored modifier migration", () => { it("never drops a region it cannot anchor (unknown clip duration → passes through)", () => { // A v2-imported project before its duration is probed: zero-extent clip. - const doc = documentSchema.parse({ - schemaVersion: 4, - project: { - id: "p2", - title: "unprobed", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - assets: [{ id: "a", kind: "video", label: "A", originalPath: "/a.mp4", cameraTrack: null }], - timeline: { - clips: [ - { - id: "c1", - assetId: "a", - sourceStartSec: 0, - timelineStartSec: 0, - timelineEndSec: 0, - origin: "user", - }, + const doc = documentSchema.parse( + migrateRawDocumentToCurrent({ + schemaVersion: 4, + project: { + id: "p2", + title: "unprobed", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + assets: [{ id: "a", kind: "video", label: "A", originalPath: "/a.mp4", cameraTrack: null }], + timeline: { + clips: [ + { + id: "c1", + assetId: "a", + sourceStartSec: 0, + timelineStartSec: 0, + timelineEndSec: 0, + origin: "user", + }, + ], + }, + zoomRanges: [ + { id: "z1", startMs: 1000, endMs: 2000, depth: 3, focus: { cx: 0.5, cy: 0.5 } }, ], - }, - zoomRanges: [{ id: "z1", startMs: 1000, endMs: 2000, depth: 3, focus: { cx: 0.5, cy: 0.5 } }], - }); + }), + ); expect(doc.zoomRanges).toHaveLength(1); expect(doc.zoomRanges[0]).toMatchObject({ id: "z1", startMs: 1000, endMs: 2000 }); expect(doc.zoomRanges[0].clipId).toBeUndefined(); }); - it("is idempotent — re-parsing the migrated document changes nothing", () => { + it("is idempotent — re-parsing an already-v6 document changes nothing", () => { + // First call: v4 input → load-time upgrade → v6. const once = documentSchema.parse( - makeV4Doc({ - zoomRanges: [ - { id: "z1", startMs: 2000, endMs: 5000, depth: 3, focus: { cx: 0.5, cy: 0.5 } }, - ], - }), + migrateRawDocumentToCurrent( + makeV4Doc({ + zoomRanges: [ + { id: "z1", startMs: 2000, endMs: 5000, depth: 3, focus: { cx: 0.5, cy: 0.5 } }, + ], + }), + ), ); + // Second call: already-current input, no upgrade needed; the parse is now a + // pure v6 validation step. const twice = documentSchema.parse(once); expect(twice).toEqual(once); }); @@ -517,75 +546,79 @@ describe("v5 -> v6 native AspectRatio migration", () => { it("rewrites legacy aspectRatio === 'native' to the largest clip's concrete token", () => { const doc = documentSchema.parse( - makeV5Doc({ - legacyEditor: { aspectRatio: "native" }, - assets: [ - { - id: "asset_f", - kind: "video", - label: "A", - originalPath: "/a.mp4", - cameraTrack: null, - video: { width: 1920, height: 1080 }, - }, - ], - }), + migrateRawDocumentToCurrent( + makeV5Doc({ + legacyEditor: { aspectRatio: "native" }, + assets: [ + { + id: "asset_f", + kind: "video", + label: "A", + originalPath: "/a.mp4", + cameraTrack: null, + video: { width: 1920, height: 1080 }, + }, + ], + }), + ), ); expect(doc.schemaVersion).toBe(6); expect((doc.legacyEditor as Record).aspectRatio).toBe("16:9"); }); it("picks the largest clip when the timeline is mixed-shape", () => { - const doc = documentSchema.parse({ - schemaVersion: 5, - project: { - id: "p1", - title: "mixed", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - assets: [ - { - id: "asset_f", - kind: "video", - label: "A", - originalPath: "/a.mp4", - cameraTrack: null, - video: { width: 1920, height: 1080 }, - }, - { - id: "asset_g", - kind: "video", - label: "B", - originalPath: "/b.mp4", - cameraTrack: null, - video: { width: 2160, height: 3840 }, + const doc = documentSchema.parse( + migrateRawDocumentToCurrent({ + schemaVersion: 5, + project: { + id: "p1", + title: "mixed", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", }, - ], - timeline: { - clips: [ + assets: [ { - id: "clip_a", - assetId: "asset_f", - sourceStartSec: 0, - sourceEndSec: 10, - timelineStartSec: 0, - timelineEndSec: 10, - origin: "user", + id: "asset_f", + kind: "video", + label: "A", + originalPath: "/a.mp4", + cameraTrack: null, + video: { width: 1920, height: 1080 }, }, { - id: "clip_b", - assetId: "asset_g", - sourceStartSec: 0, - sourceEndSec: 10, - timelineStartSec: 10, - timelineEndSec: 20, - origin: "user", + id: "asset_g", + kind: "video", + label: "B", + originalPath: "/b.mp4", + cameraTrack: null, + video: { width: 2160, height: 3840 }, }, ], - }, - legacyEditor: { aspectRatio: "native" }, - }); + timeline: { + clips: [ + { + id: "clip_a", + assetId: "asset_f", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + origin: "user", + }, + { + id: "clip_b", + assetId: "asset_g", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 10, + timelineEndSec: 20, + origin: "user", + }, + ], + }, + legacyEditor: { aspectRatio: "native" }, + }), + ); expect(doc.schemaVersion).toBe(6); expect((doc.legacyEditor as Record).aspectRatio).toBe("9:16"); }); @@ -594,19 +627,25 @@ describe("v5 -> v6 native AspectRatio migration", () => { // Deliberately NOT a 16:9 fallback: an empty/unprobed timeline gives no basis // for a concrete token, and guessing one persists a wrong frame. See the v1.7 // import case below. - const doc = documentSchema.parse(makeV5Doc({ legacyEditor: { aspectRatio: "native" } })); + const doc = documentSchema.parse( + migrateRawDocumentToCurrent(makeV5Doc({ legacyEditor: { aspectRatio: "native" } })), + ); expect(doc.schemaVersion).toBe(6); expect((doc.legacyEditor as Record).aspectRatio).toBe("native"); }); it("passes through a concrete aspectRatio unchanged", () => { - const doc = documentSchema.parse(makeV5Doc({ legacyEditor: { aspectRatio: "4:5" } })); + const doc = documentSchema.parse( + migrateRawDocumentToCurrent(makeV5Doc({ legacyEditor: { aspectRatio: "4:5" } })), + ); expect(doc.schemaVersion).toBe(6); expect((doc.legacyEditor as Record).aspectRatio).toBe("4:5"); }); it("passes through a legacyEditor without aspectRatio unchanged", () => { - const doc = documentSchema.parse(makeV5Doc({ legacyEditor: { someOtherField: "preserved" } })); + const doc = documentSchema.parse( + migrateRawDocumentToCurrent(makeV5Doc({ legacyEditor: { someOtherField: "preserved" } })), + ); expect(doc.schemaVersion).toBe(6); const legacy = doc.legacyEditor as Record; expect(legacy.someOtherField).toBe("preserved"); @@ -615,13 +654,15 @@ describe("v5 -> v6 native AspectRatio migration", () => { it("passes through a v5 doc with no legacyEditor at all (only the version bumps)", () => { const v5 = makeV5Doc(); - const doc = documentSchema.parse(v5); + const doc = documentSchema.parse(migrateRawDocumentToCurrent(v5)); expect(doc.schemaVersion).toBe(6); expect(doc.legacyEditor).toBeNull(); }); it("is idempotent — re-parsing an already-v6 document changes nothing", () => { - const once = documentSchema.parse(makeV5Doc({ legacyEditor: { aspectRatio: "16:9" } })); + const once = documentSchema.parse( + migrateRawDocumentToCurrent(makeV5Doc({ legacyEditor: { aspectRatio: "16:9" } })), + ); const twice = documentSchema.parse(once); expect(twice).toEqual(once); }); @@ -633,39 +674,41 @@ describe("v5 -> v6 native AspectRatio migration", () => { // reframing every portrait v1.7 project saved with "Native". Leave the // sentinel; it resolves dynamically at runtime and converts on a later load, // once useTimeline's probe has written `asset.video` back. - const doc = documentSchema.parse({ - schemaVersion: 5, - project: { - id: "p1", - title: "from v1.7", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - assets: [ - { - id: "asset_u", - kind: "video", - label: "A", - originalPath: "/a.mp4", - cameraTrack: null, - // no `video` — exactly what the v2 import produces + const doc = documentSchema.parse( + migrateRawDocumentToCurrent({ + schemaVersion: 5, + project: { + id: "p1", + title: "from v1.7", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", }, - ], - timeline: { - clips: [ + assets: [ { - id: "clip_a", - assetId: "asset_u", - sourceStartSec: 0, - sourceEndSec: 10, - timelineStartSec: 0, - timelineEndSec: 10, - origin: "user", + id: "asset_u", + kind: "video", + label: "A", + originalPath: "/a.mp4", + cameraTrack: null, + // no `video` — exactly what the v2 import produces }, ], - }, - legacyEditor: { aspectRatio: "native" }, - }); + timeline: { + clips: [ + { + id: "clip_a", + assetId: "asset_u", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + origin: "user", + }, + ], + }, + legacyEditor: { aspectRatio: "native" }, + }), + ); expect(doc.schemaVersion).toBe(6); expect((doc.legacyEditor as Record).aspectRatio).toBe("native"); }); @@ -673,39 +716,41 @@ describe("v5 -> v6 native AspectRatio migration", () => { it("converts 'native' once the probe has persisted dimensions", () => { // Second load of the same project, after useTimeline probed a PORTRAIT source. // This is the case that must not become 16:9. - const doc = documentSchema.parse({ - schemaVersion: 5, - project: { - id: "p1", - title: "from v1.7, probed", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - assets: [ - { - id: "asset_u", - kind: "video", - label: "A", - originalPath: "/a.mp4", - cameraTrack: null, - video: { width: 1080, height: 1920 }, + const doc = documentSchema.parse( + migrateRawDocumentToCurrent({ + schemaVersion: 5, + project: { + id: "p1", + title: "from v1.7, probed", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", }, - ], - timeline: { - clips: [ + assets: [ { - id: "clip_a", - assetId: "asset_u", - sourceStartSec: 0, - sourceEndSec: 10, - timelineStartSec: 0, - timelineEndSec: 10, - origin: "user", + id: "asset_u", + kind: "video", + label: "A", + originalPath: "/a.mp4", + cameraTrack: null, + video: { width: 1080, height: 1920 }, }, ], - }, - legacyEditor: { aspectRatio: "native" }, - }); + timeline: { + clips: [ + { + id: "clip_a", + assetId: "asset_u", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + origin: "user", + }, + ], + }, + legacyEditor: { aspectRatio: "native" }, + }), + ); expect(doc.schemaVersion).toBe(6); expect((doc.legacyEditor as Record).aspectRatio).toBe("9:16"); }); @@ -714,40 +759,42 @@ describe("v5 -> v6 native AspectRatio migration", () => { // "native" resolved to the cropped clip at runtime. A 3840x2160 asset cropped // to its left half is effectively 1920x2160 → 8:9. Reading the raw dims would // wrongly yield 16:9 and silently reframe the project. - const doc = documentSchema.parse({ - schemaVersion: 5, - project: { - id: "p1", - title: "cropped", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - assets: [ - { - id: "asset_c", - kind: "video", - label: "A", - originalPath: "/a.mp4", - cameraTrack: null, - video: { width: 3840, height: 2160 }, + const doc = documentSchema.parse( + migrateRawDocumentToCurrent({ + schemaVersion: 5, + project: { + id: "p1", + title: "cropped", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", }, - ], - timeline: { - clips: [ + assets: [ { - id: "clip_a", - assetId: "asset_c", - sourceStartSec: 0, - sourceEndSec: 10, - timelineStartSec: 0, - timelineEndSec: 10, - origin: "user", - cropRegion: { x: 0, y: 0, width: 0.5, height: 1 }, + id: "asset_c", + kind: "video", + label: "A", + originalPath: "/a.mp4", + cameraTrack: null, + video: { width: 3840, height: 2160 }, }, ], - }, - legacyEditor: { aspectRatio: "native" }, - }); + timeline: { + clips: [ + { + id: "clip_a", + assetId: "asset_c", + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + origin: "user", + cropRegion: { x: 0, y: 0, width: 0.5, height: 1 }, + }, + ], + }, + legacyEditor: { aspectRatio: "native" }, + }), + ); expect(doc.schemaVersion).toBe(6); expect((doc.legacyEditor as Record).aspectRatio).toBe("8:9"); }); diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index 5595f5454..506b1fb81 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -27,10 +27,12 @@ import { anchorRegionsWithDerivedMs } from "../timeline/timelineMap"; // CLIP-ANCHORED fragments: `{clipId, sourceStartSec, sourceEndSec}` is the // source of truth, `startMs`/`endMs` stay as a derived cache for the // transition. See technical-documentation/architecture/timeline-model.md -// 5. v6 — `"native"` AspectRatio retires. The v5→v6 upgrader rewrites every -// stored `"native"` to a concrete `"W:H"` token (the timeline's largest -// clip, falling back to "16:9"), so the value can be dropped from the -// `AspectRatio` union without a runtime bridge. +// 5. v6 — `"native"` AspectRatio is retired OPPORTUNISTICALLY. The v5→v6 +// upgrader rewrites a stored `"native"` to a concrete `"W:H"` token from +// the timeline's largest clip, but only when the source dimensions are +// actually known; otherwise it leaves the sentinel, which keeps resolving +// dynamically at runtime. See `upgradeV5DocumentToV6` for why guessing +// would corrupt v1.7 imports. export const axcutSchemaVersion = 6; // ponytail: every region schema shares the same monotonicity rule @@ -457,13 +459,15 @@ const documentSchemaShape = z.object({ // P4 — v3 documents carried a single project-level `cameraTrack` (one project // = one camera, inherited from the pre-multi-clip editor). v4 moves it onto // the owning asset (see `assetSchema.cameraTrack` above) so each asset in a -// multi-clip project can carry its own camera link. This preprocess upgrades -// any v3 document transparently at every `documentSchema.parse(...)` call -// site — it does NOT touch v2 (still handled solely by the separate -// `migrateProjectDataToAxcutDocument` pure function) or reject unknown -// versions; anything that isn't exactly v3 passes through unchanged and is -// rejected by the `schemaVersion` literal check below as before. -function upgradeV3DocumentToV4(raw: unknown): unknown { +// multi-clip project can carry its own camera link. This is invoked at LOAD +// TIME by `migrateRawDocumentToCurrent` in `document/migrate.ts`, not at every +// `documentSchema.parse(...)` call (the parse is now a pure v6 validation +// step — see the comment above `documentSchema` below). It does NOT touch v2 +// (still handled solely by the separate `migrateProjectDataToAxcutDocument` +// pure function) or reject unknown versions; anything that isn't exactly v3 +// passes through unchanged so the caller's `documentSchema.parse` can +// reject it via the `schemaVersion` literal. +export function upgradeV3DocumentToV4(raw: unknown): unknown { if (!raw || typeof raw !== "object") return raw; const doc = raw as Record; if (doc.schemaVersion !== 3) return raw; @@ -497,8 +501,11 @@ function upgradeV3DocumentToV4(raw: unknown): unknown { * A region covering no clip (zero-length, or off the end of the timeline) is * dropped: it could never play. A document with no clips has nothing to anchor to, * so its regions pass through untouched (still valid — the anchor is optional). + * + * Invoked at LOAD TIME by `migrateRawDocumentToCurrent` in `document/migrate.ts`, + * not at every `documentSchema.parse(...)` call. */ -function upgradeV4DocumentToV5(raw: unknown): unknown { +export function upgradeV4DocumentToV5(raw: unknown): unknown { if (!raw || typeof raw !== "object") return raw; const doc = raw as Record; if (doc.schemaVersion !== 4) return raw; @@ -644,10 +651,28 @@ function upgradeV5DocumentToV6(raw: unknown): unknown { }; } -export const documentSchema = z.preprocess( - (raw) => upgradeV5DocumentToV6(upgradeV4DocumentToV5(upgradeV3DocumentToV4(raw))), - documentSchemaShape, -); +/** + * Runs the whole upgrade chain on a raw, untrusted value. Idempotent: each step + * is gated on an exact `schemaVersion`, so an already-current document passes + * through untouched. + * + * Lives here rather than in `document/migrate.ts` on purpose — the Electron main + * process imports this composer, and `migrate.ts` pulls a value import + * (`PROJECT_VERSION`) through the `@/` alias, which `vite-plugin-electron` does + * not configure for the main bundle. Keep this module alias-free. + */ +export function migrateRawDocumentToCurrent(raw: unknown): unknown { + return upgradeV5DocumentToV6(upgradeV4DocumentToV5(upgradeV3DocumentToV4(raw))); +} + +// PURE v6 validation. Callers that read a document from disk (or any other +// source that might carry an older `schemaVersion`) MUST run +// `migrateRawDocumentToCurrent` on the raw value first. The previous +// implementation wrapped this schema in a `z.preprocess` that re-ran the whole +// v3→v4→v5→v6 chain on EVERY parse, including in-memory parses of documents that +// were already current. Hoisting it to load time makes the in-memory parse a +// single `z.literal(6)` + shape check. +export const documentSchema = documentSchemaShape; export const createProjectInputSchema = z.object({ title: z.string().trim().min(1).default("Untitled Project"), diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts index c4fb4fbce..5378843a5 100644 --- a/src/lib/ai-edition/store/projectStore.test.ts +++ b/src/lib/ai-edition/store/projectStore.test.ts @@ -32,7 +32,11 @@ vi.mock("sonner", () => ({ })); const sampleDoc = { - 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", diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 376c49376..7131f3c27 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -40,7 +40,11 @@ vi.mock("@/native/client", () => ({ })); const sampleDoc = { - 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", @@ -337,7 +341,7 @@ describe("useTimeline.updateClipSourceRange (Edit-clip modal)", () => { projectId: "proj_test", document: { ...sampleDoc, - schemaVersion: 5, + schemaVersion: 6, zoomRanges: [anchoredZoom("z_keep", 2, 3), anchoredZoom("z_drop", 6, 8)], } as unknown as typeof sampleDoc, revision: 1, @@ -383,7 +387,7 @@ describe("useTimeline.updateClipSourceRange (Edit-clip modal)", () => { useProjectStore.setState({ document: { ...sampleDoc, - schemaVersion: 5, + schemaVersion: 6, zoomRanges: [anchoredZoom("z_edge", 3, 7)], } as unknown as typeof sampleDoc, }); diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index 2a7a961f0..c02377556 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -4,6 +4,7 @@ // rapid iteration without the Electron window overhead. import { PROVIDER_DEFINITIONS } from "../../electron/ai-edition/provider-registry"; +import { axcutSchemaVersion, migrateRawDocumentToCurrent } from "../lib/ai-edition/schema"; import { nativeBridgeClient as realClient } from "./client"; function detectBrowserMode(): boolean { @@ -119,7 +120,6 @@ function createShimElectronAPI() { sendCloseConfirmResponse: () => undefined, onRequestCloseConfirm: () => () => undefined, onRequestSaveBeforeClose: () => () => undefined, - onAiEditionChatEvent: () => () => undefined, loadProjectFileFromPath: () => Promise.resolve({ success: false, canceled: true }), getPathForFile: () => "", getSources: () => Promise.resolve(SHIM_SOURCES), @@ -184,7 +184,14 @@ function createShimBridgeClient() { documents: Record; order: string[]; }; - documentsByProject = parsed.documents ?? {}; + // ponytail: load-time migration. Older shims persisted v3 documents; + // the renderer's `documentSchema.parse` now requires v5, so upgrade + // any stale entries once on init (idempotent for v5 inputs). + const loaded = parsed.documents ?? {}; + for (const [id, doc] of Object.entries(loaded)) { + loaded[id] = migrateRawDocumentToCurrent(doc) as ShimDocument; + } + documentsByProject = loaded; projectOrder = parsed.order ?? []; } catch { // ponytail: corrupt/unavailable localStorage — start fresh rather @@ -327,13 +334,23 @@ function createShimBridgeClient() { listProjects: () => Promise.resolve(listProjectSummaries()), get: (projectId: string) => { const doc = documentsByProject[projectId]; + // ponytail: load-time migration. Older shims persisted v3 documents + // in localStorage; the renderer's `documentSchema.parse` now requires + // v5, so upgrade on read (idempotent for v5 inputs). + const migrated = doc ? (migrateRawDocumentToCurrent(doc) as ShimDocument) : null; return Promise.resolve( - doc ? { success: true, document: doc } : { success: false, error: "Project not found" }, + migrated + ? { success: true, document: migrated } + : { success: false, error: "Project not found" }, ); }, create: (title?: string) => { const doc: ShimDocument = { - schemaVersion: 3, + // Mint at the current schema version so the renderer's + // `documentSchema.parse` (a pure validator, no migration) accepts + // the returned document. Must never be a literal: this value has to + // track `axcutSchemaVersion` on every schema bump. + schemaVersion: axcutSchemaVersion, project: { id: `proj_${Math.random().toString(36).slice(2, 10)}`, title: title || "Untitled Project", @@ -471,6 +488,52 @@ function createShimBridgeClient() { success: false, error: "[browser-shim] No agent tool batches to undo in browser mode.", }), + chatRunDefault: (projectId: string, message?: string) => { + // ponytail: legacy single-session consumers — pick the most + // recent session or auto-create one. + const sessions = getSessions(projectId); + let s = [...sessions.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; + if (!s) { + s = { + id: `sess_${Date.now()}`, + projectId, + title: "Conversation 1", + createdAt: new Date().toISOString(), + messages: [], + }; + sessions.set(s.id, s); + } + if (message) { + s.messages.push({ + id: `msg_${Date.now()}_u`, + role: "user", + content: message, + createdAt: new Date().toISOString(), + }); + } + const assistantMessage = { + id: `msg_${Date.now()}_a`, + role: "assistant" as const, + content: + "[browser-shim] AI features need real LLM deps. Configure a provider in Settings, install the LangChain packages, then chat will work for real.", + createdAt: new Date().toISOString(), + }; + s.messages.push(assistantMessage); + persistChat(); + return Promise.resolve({ success: true, assistantMessage }); + }, + chatHistory: (projectId: string) => { + const m = sessionsByProject.get(projectId); + if (!m || m.size === 0) return Promise.resolve([]); + const arr = Array.from(m.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + return Promise.resolve([...arr[0].messages]); + }, + chatClear: (projectId: string) => { + const m = sessionsByProject.get(projectId); + if (m) for (const s of m.values()) s.messages = []; + persistChat(); + return Promise.resolve({ success: true }); + }, chatListSessions: (projectId: string) => { const m = sessionsByProject.get(projectId); if (!m) return Promise.resolve([]); diff --git a/technical-documentation/architecture/document-model.md b/technical-documentation/architecture/document-model.md index 121f81884..667521ffb 100644 --- a/technical-documentation/architecture/document-model.md +++ b/technical-documentation/architecture/document-model.md @@ -36,28 +36,35 @@ and anything unknown is rejected by the `z.literal(5)` check at line 477. Migrations are **one-way and forward-only**. There is no version downgrade path: a newer document that lands on an older build is rejected by the `schemaVersion` -literal, not silently truncated. The current chain is implemented inline in the -schema file (`src/lib/ai-edition/schema/index.ts`) so every `documentSchema.parse(...)` -call site gets the upgrade for free. - -### v3 → v4 (`upgradeV3DocumentToV4`, lines 522-542) +literal, not silently truncated. The chain runs **at load time** through +`migrateRawDocumentToCurrent` (`src/lib/ai-edition/document/migrate.ts`) — the +upgraders compose the chain and `documentSchema.parse` is a pure v5 validator. +Every JSON-read site (`DocumentService`, the browser shim, the renderer's +`handleBrowseProject` / `openLoadedProject` disk-load paths) must call the +helper before `documentSchema.parse`. The pre-hoist implementation wrapped this +chain in a `z.preprocess`, so it ran on every `setDocument` / `saveDocument` / +`loadProject` parse — measurable per-parse overhead on documents that were +already v5. Hoisting it to load time makes the in-memory parse a single +`z.literal(5)` + shape check on already-upgraded data. + +### v3 → v4 (`upgradeV3DocumentToV4`, `schema/index.ts`) v3 documents carried a single project-level `cameraTrack`; v4 moves it onto the -owning asset. The preprocessor pulls the legacy `cameraTrack` field off the +owning asset. The upgrader pulls the legacy `cameraTrack` field off the document root and copies it onto the asset identified by `project.primaryAssetId` (or the first asset if that is unset), then strips the root field and rewrites -`schemaVersion: 4`. v2 documents are not touched by this preprocessor — they are -handled by the separate `migrateProjectDataToAxcutDocument` pure function described -below — and unknown versions pass through unchanged into the literal check at the -top of `documentSchemaShape`. +`schemaVersion: 4`. v2 documents are not touched by this upgrader — they are +handled by the separate `migrateProjectDataToAxcutDocument` pure function +described below — and unknown versions pass through unchanged so the caller's +`documentSchema.parse` can reject them via the `schemaVersion` literal. -### v4 → v5 (`upgradeV4DocumentToV5`, lines 557-596) +### v4 → v5 (`upgradeV4DocumentToV5`, `schema/index.ts`) v5 makes modifiers (zoom, annotation, speed, camera-fullscreen) clip-anchored: each region is split into one fragment per covered clip, with the source-time window (`clipId`, `sourceStartSec`, `sourceEndSec`) as the source of truth and -`startMs`/`endMs` re-derived as a transition cache. The preprocessor reads the -RAW clip layout out of `timeline.clips` and runs every region array through +`startMs`/`endMs` re-derived as a transition cache. The upgrader reads the RAW +clip layout out of `timeline.clips` and runs every region array through `anchorRegionsWithDerivedMs` (`src/lib/ai-edition/timeline/timelineMap.ts:376`): - `document.zoomRanges` @@ -68,9 +75,9 @@ RAW clip layout out of `timeline.clips` and runs every region array through A region that covers no clip — zero-length, or off the end of the timeline — is dropped, because it could never play. A document with no clips has nothing to anchor to, so its regions pass through untouched (the anchor is optional during -the transition, see the v5 schema at line 403-407). The `v4→v5` migration lives -**only** in this preprocessor — `document/migrate.ts` deliberately emits a v4 -draft (line 209) so the same code path is reused for the legacy import. +the transition). The v4→v5 migration lives **only** in this upgrader — +`document/migrate.ts` deliberately emits a v4 draft (see the v2→current +section below) so the same code path is reused for the legacy import. ### Legacy v2 → current (`migrateProjectDataToAxcutDocument`, `document/migrate.ts`) @@ -93,12 +100,12 @@ field into the equivalent v5 slot: and the other ~20 fields without a first-class home — round-trips through `legacyEditor` so toggling AI-edition off then back on is lossless. -The function returns the v5 result by emitting a v4-shaped draft and letting -`documentSchema.parse` run it through the v4→v5 preprocessor described above. -That is why the draft is labelled `schemaVersion: 4` and not `axcutSchemaVersion` -(see the comment at `document/migrate.ts:201-207`): labelling it already-v5 would -make the preprocessor skip the anchoring and leave the imported regions without -clip anchors. +The function returns the v5 result by emitting a v4-shaped draft and running it +through `migrateRawDocumentToCurrent` (which composes `upgradeV3DocumentToV4` + +`upgradeV4DocumentToV5`) before the v5-validating `documentSchema.parse`. That +is why the draft is labelled `schemaVersion: 4` and not `axcutSchemaVersion`: +labelling it already-v5 would make the v4→v5 upgrader skip the anchoring and +leave the imported regions without clip anchors. ## Persistence @@ -115,9 +122,10 @@ service stores exactly what `JSON.stringify(document, null, 2)` produces. | Extension | — | **`.openscreen`**. Older builds wrote the same v3/v4 documents under `.axcut`; the service renames them to `.openscreen` on first access (`migrateLegacyExtensions`, lines 119-145). The file content is unchanged — the extension migration is a pure rename keyed off the schema version in the JSON, not the file name. | | Atomicity | — | Temp + rename per save (`writeProjectNow`, lines 354-394), with a per-project write queue (`writeQueues`, line 106) so two concurrent saves serialise and an interrupted write cannot leave a half-written document behind. | -Both layers run every read through `documentSchema.parse` so an on-disk document -of any supported version comes out as the current `AxcutDocument` shape; the -renderer never holds a stale `schemaVersion: 3` snapshot. +Both layers run every read through `migrateRawDocumentToCurrent` then +`documentSchema.parse` so an on-disk document of any supported version comes out +as the current `AxcutDocument` shape; the renderer never holds a stale +`schemaVersion: 3` snapshot. ## Undo / history