Skip to content

Commit 196abbf

Browse files
EtienneLescotsepion02
authored andcommitted
perf: hoist v3/v4/v5 schema migrations to load-time
Removes the per-parse schema migration overhead from every documentSchema.parse(...) call. The pre-hoist schema was wrapped in a z.preprocess that ran upgradeV3DocumentToV4 + upgradeV4DocumentToV5 on every parse, including in-memory parses of v5 docs. - documentSchema is now a pure v5 validator; the z.preprocess wrapper is removed and both upgraders are exported - New migrateRawDocumentToCurrent helper in document/migrate.ts composes the two upgraders into the load-time equivalent of the old chain - DocumentService.getProject / listProjects wired to run the helper before parse - browserShim get callback and localStorage-load IIFE wired; new shim docs written as v5 directly - NewEditorShell.handleBrowseProject and EditorEmptyState.openLoadedProject (renderer disk-load paths) wired to run the helper before parse - migrateProjectDataToAxcutDocument updated to call the new helper so the v2 -> v3 -> v4 -> v5 chain still lives in one place - Existing schema tests updated to model the new contract - New tests for migrateRawDocumentToCurrent (v3/v4/v5/non-doc/v2) - Test fixtures (projectStore, useTimeline, EditorEmptyState) bumped to v5 to model the new bridge contract (load sites now return v5) - technical-documentation/architecture/document-model.md updated Every render-side setDocument / saveDocument / loadProject is now a single z.literal(5) + shape check on already-v5 data, instead of a function call into each upgrader + the parse. Note for #195: that PR adds a v5->v6 upgrader to the same z.preprocess chain this PR removes. Either land this PR first and rebase #195 on top, or combine them — the only conflict point is the z.preprocess line.
1 parent d7439c2 commit 196abbf

12 files changed

Lines changed: 401 additions & 143 deletions

File tree

electron/ai-edition/document-service.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import fs, { type FileHandle } from "node:fs/promises";
1414
import path from "node:path";
1515
import { createId } from "../../src/lib/ai-edition/document/ids";
16+
import { migrateRawDocumentToCurrent } from "../../src/lib/ai-edition/document/migrate";
1617
import {
1718
type AxcutAsset,
1819
type AxcutDocument,
@@ -78,6 +79,16 @@ function safeProjectId(raw: string): string {
7879
return raw;
7980
}
8081

82+
// ponytail: load-time migration hook. The on-disk file may carry any supported
83+
// `schemaVersion` (v2 EditorProjectData handled separately by
84+
// `migrateProjectDataToAxcutDocument`; v3 / v4 AxcutDocuments handled here).
85+
// `documentSchema.parse` is now a pure v6 validator — every JSON-read path
86+
// (list, get, future bulk-export) must run the upgrader chain first via this
87+
// helper so the in-memory parse is a single `z.literal(6)` + shape check.
88+
function parseLoadedDocument(raw: string): AxcutDocument {
89+
return documentSchema.parse(migrateRawDocumentToCurrent(JSON.parse(raw)));
90+
}
91+
8192
/**
8293
* Windows fails a rename onto an open file with EPERM/EBUSY: an indexer, an
8394
* antivirus or a backup agent can hold the destination for a few milliseconds
@@ -169,7 +180,7 @@ export class DocumentService {
169180
const filePath = path.join(this.projectsRoot, name);
170181
try {
171182
const raw = await fs.readFile(filePath, "utf8");
172-
const parsed = documentSchema.parse(JSON.parse(raw));
183+
const parsed = parseLoadedDocument(raw);
173184
summaries.push({
174185
id: parsed.project.id,
175186
title: parsed.project.title,
@@ -211,7 +222,7 @@ export class DocumentService {
211222
);
212223
}
213224
}
214-
return documentSchema.parse(JSON.parse(raw));
225+
return parseLoadedDocument(raw);
215226
}
216227

217228
async createProject(title: string): Promise<AxcutDocument> {

src/components/ai-edition/EditorEmptyState.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ const bridgeMocks = vi.hoisted(() => ({
2121
}));
2222

2323
const sampleDoc = vi.hoisted(() => ({
24-
schemaVersion: 3,
24+
// ponytail: the bridge contract after the migration hoist is v5 — every
25+
// load site (DocumentService, browserShim) runs `migrateRawDocumentToCurrent`
26+
// before returning, and the renderer's `parseDocument` is a pure v6
27+
// validator. Test fixtures model the post-hoist contract.
28+
schemaVersion: 5,
2529
project: {
2630
id: "proj_test",
2731
title: "Test",

src/components/ai-edition/EditorEmptyState.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
1717
import { useCallback, useRef, useState } from "react";
1818
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
1919
import { useScopedT } from "@/contexts/I18nContext";
20-
import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
20+
import {
21+
migrateProjectDataToAxcutDocument,
22+
migrateRawDocumentToCurrent,
23+
} from "@/lib/ai-edition/document/migrate";
2124
import { documentSchema } from "@/lib/ai-edition/schema";
2225
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
2326
import { nativeBridgeClient } from "@/native";
@@ -82,7 +85,7 @@ export function EditorEmptyState({
8285
const isAxcutDocument =
8386
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
8487
const doc = isAxcutDocument
85-
? documentSchema.parse(raw)
88+
? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4 → v5, then validate
8689
: migrateProjectDataToAxcutDocument(raw as never);
8790
const saved = await nativeBridgeClient.aiEdition.save(doc);
8891
if (!saved.success || !saved.document) return false;

src/components/ai-edition/NewEditorShell.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import type { EditorProjectData } from "@/components/video-editor/projectPersist
44
import { toFileUrl } from "@/components/video-editor/projectPersistence";
55
import { useScopedT } from "@/contexts/I18nContext";
66
import { useShortcuts } from "@/contexts/ShortcutsContext";
7-
import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
7+
import {
8+
migrateProjectDataToAxcutDocument,
9+
migrateRawDocumentToCurrent,
10+
} from "@/lib/ai-edition/document/migrate";
811
import {
912
applyProbedDuration,
1013
replaceTimeline as replaceTimelineOp,
@@ -520,7 +523,7 @@ export function NewEditorShell() {
520523
const isAxcutDocument =
521524
typeof raw === "object" && raw !== null && "schemaVersion" in raw && "timeline" in raw;
522525
const doc = isAxcutDocument
523-
? documentSchema.parse(raw) // validates + upgrades v3v4
526+
? documentSchema.parse(migrateRawDocumentToCurrent(raw)) // disk-load: upgrade v3/v4v5, then validate
524527
: migrateProjectDataToAxcutDocument(raw as EditorProjectData);
525528
const saved = await nativeBridgeClient.aiEdition.save(doc);
526529
if (saved.success && saved.document) {

src/lib/ai-edition/document/migrate.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { describe, expect, it } from "vitest";
22
import type { EditorProjectData } from "@/components/video-editor/projectPersistence";
33
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
4-
import { migrateAxcutDocumentToProjectData, migrateProjectDataToAxcutDocument } from "./migrate";
4+
import { documentSchema } from "../schema";
5+
import {
6+
migrateAxcutDocumentToProjectData,
7+
migrateProjectDataToAxcutDocument,
8+
migrateRawDocumentToCurrent,
9+
} from "./migrate";
510

611
function makeV2Project(overrides: Partial<EditorProjectData> = {}): EditorProjectData {
712
return {
@@ -338,3 +343,134 @@ describe("migrateAxcutDocumentToProjectData", () => {
338343
expect(doc.zoomRanges[0].focus.cy).toBe(0);
339344
});
340345
});
346+
347+
// Load-time migration helper. Replaces the `z.preprocess` chain that used to
348+
// run on every `documentSchema.parse(...)` call site. The pre-hoist chain ran
349+
// v3→v4 and v4→v5 on every parse, including in-memory parses that were
350+
// already v5; the post-hoist chain runs once at the disk (or localStorage)
351+
// read site, and the in-memory `documentSchema.parse` is a pure v6 validator.
352+
353+
describe("migrateRawDocumentToCurrent", () => {
354+
const createdAt = "2024-01-01T00:00:00.000Z";
355+
356+
function makeV3Doc(overrides: Record<string, unknown> = {}) {
357+
return {
358+
schemaVersion: 3,
359+
project: { id: "p", title: "t", createdAt, updatedAt: createdAt },
360+
assets: [
361+
{ id: "asset_1", kind: "video", label: "a1", originalPath: "/a1.mp4" },
362+
{ id: "asset_2", kind: "video", label: "a2", originalPath: "/a2.mp4" },
363+
],
364+
cameraTrack: { sourcePath: "/cam.mp4", startMs: 0, offsetMs: 0, visible: true },
365+
...overrides,
366+
};
367+
}
368+
369+
function makeV4Doc(overrides: Record<string, unknown> = {}) {
370+
return {
371+
schemaVersion: 4,
372+
project: { id: "p", title: "t", createdAt, updatedAt: createdAt },
373+
assets: [{ id: "a", kind: "video", label: "A", originalPath: "/a.mp4", cameraTrack: null }],
374+
timeline: {
375+
clips: [
376+
{
377+
id: "c1",
378+
assetId: "a",
379+
sourceStartSec: 0,
380+
sourceEndSec: 10,
381+
timelineStartSec: 0,
382+
timelineEndSec: 10,
383+
origin: "user",
384+
},
385+
],
386+
},
387+
...overrides,
388+
};
389+
}
390+
391+
it("upgrades a v3 document to v6 (cameraTrack relocated onto the primary asset)", () => {
392+
// Models the full load path: every disk-read site runs the helper, then
393+
// the schema parse fills in defaults (cameraTrack: null on non-target
394+
// assets). The helper alone is just the upgrader chain; the schema is
395+
// what produces the final v5 shape with defaults filled in.
396+
const migrated = documentSchema.parse(
397+
migrateRawDocumentToCurrent(
398+
makeV3Doc({
399+
project: {
400+
id: "p",
401+
title: "t",
402+
createdAt,
403+
updatedAt: createdAt,
404+
primaryAssetId: "asset_2",
405+
},
406+
}),
407+
),
408+
);
409+
expect(migrated.schemaVersion).toBe(6);
410+
expect((migrated as Record<string, unknown>).cameraTrack).toBeUndefined();
411+
expect(migrated.assets[0].cameraTrack).toBeNull();
412+
expect(migrated.assets[1].cameraTrack?.sourcePath).toBe("/cam.mp4");
413+
});
414+
415+
it("upgrades a v4 document to v6 (anchors modifiers onto clips)", () => {
416+
const migrated = migrateRawDocumentToCurrent(
417+
makeV4Doc({
418+
zoomRanges: [
419+
{ id: "z1", startMs: 2000, endMs: 5000, depth: 3, focus: { cx: 0.5, cy: 0.5 } },
420+
],
421+
}),
422+
) as Record<string, unknown>;
423+
expect(migrated.schemaVersion).toBe(6);
424+
const zooms = migrated.zoomRanges as Array<Record<string, unknown>>;
425+
expect(zooms).toHaveLength(1);
426+
expect(zooms[0]).toMatchObject({ id: "z1", clipId: "c1", depth: 3 });
427+
});
428+
429+
it("is a no-op for an already-current document (returns an equal value)", () => {
430+
const v5 = makeV4Doc(); // makeV4Doc's body is the v5-compatible shape
431+
const once = migrateRawDocumentToCurrent({ ...v5, schemaVersion: 6 });
432+
// ponytail: the upgrader chain checks schemaVersion and returns the input
433+
// unchanged, so the round-trip allocation is bounded to a property
434+
// comparison per upgrader — the same per-parse overhead the old
435+
// `z.preprocess` carried.
436+
expect(once).toEqual({ ...v5, schemaVersion: 6 });
437+
});
438+
439+
it("passes non-document input through unchanged (the schema is the gate, not this helper)", () => {
440+
// null, primitives, arrays — none of these are v3/v4 documents, so the
441+
// upgraders return them untouched. The downstream `documentSchema.parse`
442+
// is what rejects them via the `schemaVersion` literal.
443+
expect(migrateRawDocumentToCurrent(null)).toBe(null);
444+
expect(migrateRawDocumentToCurrent(undefined)).toBe(undefined);
445+
expect(migrateRawDocumentToCurrent(42)).toBe(42);
446+
expect(migrateRawDocumentToCurrent("not-a-doc")).toBe("not-a-doc");
447+
expect(migrateRawDocumentToCurrent([])).toEqual([]);
448+
});
449+
450+
it("passes v2 input through unchanged (the legacy migrator is a separate path)", () => {
451+
// The pre-hoist schema's `z.preprocess` also passed v2 through; the
452+
// post-hoist helper keeps the same shape so `documentSchema.parse` is
453+
// the single rejection point for unknown versions.
454+
const v2ish = { schemaVersion: 2, project: { id: "p" } };
455+
const out = migrateRawDocumentToCurrent(v2ish) as Record<string, unknown>;
456+
expect(out.schemaVersion).toBe(2);
457+
});
458+
459+
it("the upgraded v5 result round-trips through documentSchema.parse with no error", () => {
460+
// The whole point of the hoist: after `migrateRawDocumentToCurrent`
461+
// runs once at load, the in-memory parse is a pure v6 validation
462+
// step. This is the contract every load site relies on.
463+
const upgraded = migrateRawDocumentToCurrent(
464+
makeV3Doc({
465+
project: {
466+
id: "p",
467+
title: "t",
468+
createdAt,
469+
updatedAt: createdAt,
470+
primaryAssetId: "asset_1",
471+
},
472+
}),
473+
);
474+
expect(() => documentSchema.parse(upgraded)).not.toThrow();
475+
});
476+
});

src/lib/ai-edition/document/migrate.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
type AxcutTrimRange,
3030
type AxcutZoomRegion,
3131
documentSchema,
32+
migrateRawDocumentToCurrent,
3233
} from "../schema";
3334
import { createId } from "./ids";
3435

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

58+
/**
59+
* Re-exported from `../schema`, where the composer lives so the Electron main
60+
* process can import it without dragging this module's `@/`-aliased value
61+
* imports into a bundle that has no alias configured.
62+
*
63+
* v2 inputs are not handled by it — `migrateProjectDataToAxcutDocument` below
64+
* still owns the legacy EditorProjectData → AxcutDocument translation.
65+
*/
66+
export { migrateRawDocumentToCurrent };
67+
5768
function toLegacyMedia(input: ProjectMedia | undefined): ProjectMedia | null {
5869
if (!input) return null;
5970
const media: ProjectMedia = { screenVideoPath: input.screenVideoPath };
@@ -199,12 +210,12 @@ export function migrateProjectDataToAxcutDocument(
199210
const legacyEditor: AxcutLegacyEditor = input.editor ? { ...input.editor } : null;
200211

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

233-
return documentSchema.parse(draft);
244+
return documentSchema.parse(migrateRawDocumentToCurrent(draft));
234245
}
235246

236247
/**

0 commit comments

Comments
 (0)