diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts
index 1a2915751..2d2ecd705 100644
--- a/electron/ai-edition/document-service.test.ts
+++ b/electron/ai-edition/document-service.test.ts
@@ -24,9 +24,9 @@ describe("DocumentService", () => {
});
describe("createProject", () => {
- it("creates a v5 doc with the given title and writes it to disk", async () => {
+ it("creates a v6 doc with the given title and writes it to disk", async () => {
const doc = await service.createProject("Demo Project");
- expect(doc.schemaVersion).toBe(5);
+ expect(doc.schemaVersion).toBe(6);
expect(doc.project.title).toBe("Demo Project");
expect(doc.project.id).toMatch(/^proj_/);
expect(doc.assets).toEqual([]);
@@ -34,7 +34,7 @@ describe("DocumentService", () => {
const filePath = path.join(tempDir, `${doc.project.id}.openscreen`);
const raw = await fs.readFile(filePath, "utf8");
expect(JSON.parse(raw)).toMatchObject({
- schemaVersion: 5,
+ schemaVersion: 6,
project: { title: "Demo Project" },
});
});
diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx
index 08e573f03..64386fab7 100644
--- a/src/components/ai-edition/WebcamOverlay.test.tsx
+++ b/src/components/ai-edition/WebcamOverlay.test.tsx
@@ -36,7 +36,7 @@ const CLIP_WITHOUT_CAMERA: AxcutClip = {
function makeDocument(): AxcutDocument {
return {
- schemaVersion: 5,
+ schemaVersion: 6,
project: {
id: "proj_test",
title: "Test",
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx
index d75da33a9..1cbab2988 100644
--- a/src/components/ai-edition/v4/V4Timeline.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.tsx
@@ -30,7 +30,7 @@ import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import { useScopedT } from "@/contexts/I18nContext";
import { useAudioPeaks } from "@/hooks/useAudioPeaks";
import { createId } from "@/lib/ai-edition/document/ids";
-import { collectNativeFormats, referenceClipDims } from "@/lib/ai-edition/document/outputFormat";
+import { collectNativeFormats } from "@/lib/ai-edition/document/outputFormat";
import type { AxcutClip } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus";
@@ -46,11 +46,7 @@ import {
} from "@/lib/ai-edition/timeline/trim-mapping";
import { buildAutoZoomSuggestions } from "@/lib/ai-edition/timeline/zoom-suggestions";
import { nativeBridgeClient } from "@/native/client";
-import {
- ASPECT_RATIO_PRESETS,
- getAspectRatioLabel,
- toAspectRatioToken,
-} from "@/utils/aspectRatioUtils";
+import { ASPECT_RATIO_PRESETS, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
import { TransportBar } from "../TransportBar";
import type { VideoSource } from "../VirtualPreview";
import styles from "./EditorShellV4.module.css";
@@ -271,16 +267,6 @@ export function V4Timeline({
// stays a pure list of fixed choices, and "which shapes are my clips" lives solely in ORIGINAL
// (no more per-preset badge, which split that one answer across two places).
const timelineIsMixed = nativeFormats.length > 1;
- // A project saved before the shapes were enumerated still stores "native". Resolve it to the
- // shape it currently means so the menu highlights a real row (and the button names a real
- // ratio) instead of showing a selection that matches nothing. Picking that row rewrites the
- // document to the concrete token — which is how those projects self-migrate off the value
- // that silently moved with the clip list.
- const activeToken = useMemo(() => {
- if (settings.aspectRatio !== "native" || !document) return settings.aspectRatio;
- const reference = referenceClipDims(document);
- return toAspectRatioToken(reference.width, reference.height) ?? settings.aspectRatio;
- }, [settings.aspectRatio, document]);
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
const [autoEnhanceOpen, setAutoEnhanceOpen] = useState(false);
@@ -1115,7 +1101,7 @@ export function V4Timeline({
title={t("toolbar.aspectRatio")}
aria-label={t("toolbar.aspectRatio")}
>
- {getAspectRatioLabel(activeToken)}
+ {getAspectRatioLabel(settings.aspectRatio)}
@@ -1134,7 +1120,7 @@ export function V4Timeline({
type="button"
key={ratio}
className={`${styles.recMenuRow}${
- ratio === activeToken ? ` ${styles.active}` : ""
+ ratio === settings.aspectRatio ? ` ${styles.active}` : ""
}`}
onClick={() => {
void setSettings({ aspectRatio: ratio });
@@ -1152,7 +1138,7 @@ export function V4Timeline({
type="button"
key={format.token}
className={`${styles.recMenuRow}${
- format.token === activeToken ? ` ${styles.active}` : ""
+ format.token === settings.aspectRatio ? ` ${styles.active}` : ""
}`}
onClick={() => {
void setSettings({ aspectRatio: format.token });
diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts
index 2b801de15..9b9b0bfcd 100644
--- a/src/lib/ai-edition/document/migrate.test.ts
+++ b/src/lib/ai-edition/document/migrate.test.ts
@@ -43,7 +43,7 @@ describe("migrateProjectDataToAxcutDocument", () => {
it("produces a v3 document with one asset and one clip from a v2 single-recording project", () => {
const doc = migrateProjectDataToAxcutDocument(makeV2Project());
- expect(doc.schemaVersion).toBe(5);
+ expect(doc.schemaVersion).toBe(6);
expect(doc.assets).toHaveLength(1);
const asset = doc.assets[0];
expect(asset.kind).toBe("video");
diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts
index 4b416a8b2..9b3767c6e 100644
--- a/src/lib/ai-edition/schema/index.test.ts
+++ b/src/lib/ai-edition/schema/index.test.ts
@@ -14,9 +14,9 @@ import {
zoomRegionSchema,
} from "./index";
-describe("axcut-schema v5", () => {
- it("uses schema version 4", () => {
- expect(axcutSchemaVersion).toBe(5);
+describe("axcut-schema v6", () => {
+ it("uses schema version 6", () => {
+ expect(axcutSchemaVersion).toBe(6);
});
it("rejects unknown schema versions", () => {
@@ -28,9 +28,9 @@ describe("axcut-schema v5", () => {
).toThrow();
});
- it("createEmptyDocument returns a valid v5 doc with empty collections", () => {
+ it("createEmptyDocument returns a valid v6 doc with empty collections", () => {
const doc = createEmptyDocument({ projectId: "proj_1", title: "Demo" });
- expect(doc.schemaVersion).toBe(5);
+ expect(doc.schemaVersion).toBe(6);
expect(doc.assets).toEqual([]);
expect(doc.timeline.clips).toEqual([]);
expect(doc.timeline.trimRanges).toEqual([]);
@@ -255,7 +255,7 @@ describe("axcut-schema v5", () => {
const doc = documentSchema.parse(
v3Doc({ project: { ...v3Doc().project, primaryAssetId: "asset_2" } }),
);
- expect(doc.schemaVersion).toBe(5);
+ expect(doc.schemaVersion).toBe(6);
expect((doc as Record).cameraTrack).toBeUndefined();
expect(doc.assets.find((a) => a.id === "asset_1")?.cameraTrack).toBeNull();
expect(doc.assets.find((a) => a.id === "asset_2")?.cameraTrack?.sourcePath).toBe("/cam.mp4");
@@ -269,7 +269,7 @@ describe("axcut-schema v5", () => {
it("is a no-op when the v3 document has no legacy cameraTrack", () => {
const doc = documentSchema.parse(v3Doc({ cameraTrack: null }));
- expect(doc.schemaVersion).toBe(5);
+ expect(doc.schemaVersion).toBe(6);
for (const asset of doc.assets) {
expect(asset.cameraTrack).toBeNull();
}
@@ -407,7 +407,7 @@ describe("v4 -> v5 clip-anchored modifier migration", () => {
],
}),
);
- expect(doc.schemaVersion).toBe(5);
+ expect(doc.schemaVersion).toBe(6);
expect(doc.zoomRanges).toHaveLength(1);
const z = doc.zoomRanges[0];
expect(z).toMatchObject({ id: "z1", clipId: "clip_a", depth: 3 });
@@ -469,7 +469,7 @@ describe("v4 -> v5 clip-anchored modifier migration", () => {
expect(doc.zoomRanges[0].clipId).toBeUndefined();
});
- it("is idempotent — re-parsing an already-v5 document changes nothing", () => {
+ it("is idempotent — re-parsing the migrated document changes nothing", () => {
const once = documentSchema.parse(
makeV4Doc({
zoomRanges: [
@@ -481,3 +481,274 @@ describe("v4 -> v5 clip-anchored modifier migration", () => {
expect(twice).toEqual(once);
});
});
+
+// --- v5 -> v6 native AspectRatio migration -----------------------------------
+// `"native"` used to be a runtime-only sentinel that resolved to the timeline's largest
+// clip. v6 makes that resolution permanent by baking the concrete `"W:H"` token into the
+// document. After this upgrader runs, no document ever contains `"native"` again — the
+// union arm in `AspectRatio` is dropped, and the runtime bridge in
+// `lib/ai-edition/document/outputFormat` is no longer needed.
+
+describe("v5 -> v6 native AspectRatio migration", () => {
+ function makeV5Doc(overrides: Record = {}) {
+ const createdAt = "2024-01-01T00:00:00.000Z";
+ return {
+ schemaVersion: 5,
+ project: { id: "p1", title: "v5-aspect", createdAt, updatedAt: createdAt },
+ assets: [
+ { id: "asset_f", kind: "video", label: "A", originalPath: "/a.mp4", cameraTrack: null },
+ ],
+ timeline: {
+ clips: [
+ {
+ id: "clip_a",
+ assetId: "asset_f",
+ sourceStartSec: 0,
+ sourceEndSec: 30,
+ timelineStartSec: 0,
+ timelineEndSec: 30,
+ origin: "user",
+ },
+ ],
+ },
+ ...overrides,
+ };
+ }
+
+ 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 },
+ },
+ ],
+ }),
+ );
+ 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 },
+ },
+ ],
+ 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");
+ });
+
+ it("leaves 'native' alone when the timeline has no clips with known dimensions", () => {
+ // 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" } }));
+ 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" } }));
+ 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" } }));
+ expect(doc.schemaVersion).toBe(6);
+ const legacy = doc.legacyEditor as Record;
+ expect(legacy.someOtherField).toBe("preserved");
+ expect(legacy.aspectRatio).toBeUndefined();
+ });
+
+ it("passes through a v5 doc with no legacyEditor at all (only the version bumps)", () => {
+ const v5 = makeV5Doc();
+ const doc = documentSchema.parse(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 twice = documentSchema.parse(once);
+ expect(twice).toEqual(once);
+ });
+
+ it("keeps 'native' when the source dimensions are not known yet (v1.7 import)", () => {
+ // The v1.7 -> v1.8 path: `{version:2, media, editor}` carries only file paths,
+ // so `migrateProjectDataToAxcutDocument` produces assets with no `video` block.
+ // Baking here would stamp a hardcoded 16:9 and persist it — permanently
+ // 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
+ },
+ ],
+ 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");
+ });
+
+ 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 },
+ },
+ ],
+ 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");
+ });
+
+ it("bakes the CROPPED dimensions, not the raw ones", () => {
+ // "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 },
+ },
+ ],
+ 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 990b32557..a10b718ab 100644
--- a/src/lib/ai-edition/schema/index.ts
+++ b/src/lib/ai-edition/schema/index.ts
@@ -11,6 +11,12 @@
// shape — runtime ops, IPC, and exporter integration land in Phase 1+.
import { z } from "zod";
+// Cycle-safe: `aspectRatioUtils` has no schema dependency, and `document/outputFormat`
+// is downstream of schema. We import the leaf tokeniser directly so the v5→v6 upgrader
+// can rewrite `"native"` without dragging in the whole output-format module.
+// Relative, not `@/`: the Electron main bundle imports this module and
+// vite-plugin-electron builds it without the root resolve.alias.
+import { toAspectRatioToken } from "../../../utils/aspectRatioUtils";
// Cycle-safe: `document/ids` only pulls `uuid`, and `timeline/timelineMap`'s
// transitive value-imports (region-ventilation, virtual-preview) import from this
// module TYPE-ONLY, so requiring them here never re-enters schema at runtime.
@@ -21,7 +27,11 @@ 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
-export const axcutSchemaVersion = 5;
+// 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.
+export const axcutSchemaVersion = 6;
export const isoDateSchema = z.string().datetime({ offset: true });
@@ -520,8 +530,113 @@ function upgradeV4DocumentToV5(raw: unknown): unknown {
};
}
+/**
+ * Largest raw asset footprint among the timeline's clips. A local copy of the
+ * `referenceClipDims` pick from `document/outputFormat` — duplicated here so the schema
+ * package doesn't import from a module that itself imports from this one (cycle), and so
+ * the upgrader doesn't need the runtime `probedAssetDims` map (it runs at load time, before
+ * any asset is probed). Crop IS applied: `"native"` resolved to the *cropped* clip
+ * dimensions at runtime (`clipEffectiveDims` → `calculateEffectiveSourceDimensions`),
+ * so ignoring it here would silently reframe every cropped project.
+ */
+/** Mirrors `atLeastEven` in mp4ExportSettings — H.264 4:2:0 rejects odd dims. */
+function atLeastEven(value: number): number {
+ return Math.max(2, Math.floor(value / 2) * 2);
+}
+
+function largestClipDims(doc: Record): { width: number; height: number } | null {
+ const timeline = (doc.timeline ?? {}) as Record;
+ const clips = Array.isArray(timeline.clips) ? timeline.clips : [];
+ const assets = Array.isArray(doc.assets) ? doc.assets : [];
+ const assetById = new Map>();
+ for (const a of assets) {
+ if (a && typeof a === "object" && typeof (a as { id?: unknown }).id === "string") {
+ assetById.set((a as { id: string }).id, a as Record);
+ }
+ }
+ let best: { width: number; height: number } | null = null;
+ let bestArea = 0;
+ for (const clip of clips) {
+ if (!clip || typeof clip !== "object") continue;
+ const c = clip as Record;
+ const assetId = c.assetId;
+ if (typeof assetId !== "string") continue;
+ const asset = assetById.get(assetId);
+ if (!asset) continue;
+ const video = (asset.video ?? {}) as Record;
+ const rawW = typeof video.width === "number" ? video.width : 0;
+ const rawH = typeof video.height === "number" ? video.height : 0;
+ if (rawW <= 0 || rawH <= 0) continue;
+ // Apply the clip's crop, exactly as `calculateEffectiveSourceDimensions`
+ // (mp4ExportSettings) does at runtime — including the snap to even pixels,
+ // so the reduced token matches the size the encoder is actually handed.
+ // "native" meant "the cropped source's shape"; ignoring crop here would
+ // bake the wrong ratio into every cropped project.
+ const crop = (c.cropRegion ?? {}) as Record;
+ const cropW = typeof crop.width === "number" && crop.width > 0 ? crop.width : 1;
+ const cropH = typeof crop.height === "number" && crop.height > 0 ? crop.height : 1;
+ const w = atLeastEven(Math.round(rawW * cropW));
+ const h = atLeastEven(Math.round(rawH * cropH));
+ const area = w * h;
+ if (area > bestArea) {
+ bestArea = area;
+ best = { width: w, height: h };
+ }
+ }
+ return best;
+}
+
+/**
+ * v5 → v6 — retire the `"native"` AspectRatio. `"native"` was a runtime-only sentinel
+ * that resolved to the timeline's largest clip; v6 makes that resolution permanent by
+ * baking the concrete `"W:H"` token into the document. The new value matches the OLD
+ * runtime answer (largest clip), so projects migrate losslessly — preview and export
+ * will keep framing the same shape they did yesterday.
+ *
+ * Falls back to `"16:9"` when the timeline has no clips with known dimensions (the same
+ * value the runtime bridge in `resolveAspectRatioValue` returned when no document was
+ * available). A document without a `legacyEditor` envelope is unchanged apart from the
+ * version bump.
+ */
+function upgradeV5DocumentToV6(raw: unknown): unknown {
+ if (!raw || typeof raw !== "object") return raw;
+ const doc = raw as Record;
+ if (doc.schemaVersion !== 5) return raw;
+
+ const legacy =
+ doc.legacyEditor && typeof doc.legacyEditor === "object" && !Array.isArray(doc.legacyEditor)
+ ? (doc.legacyEditor as Record)
+ : null;
+
+ if (!legacy || legacy.aspectRatio !== "native") {
+ return { ...doc, schemaVersion: 6 };
+ }
+
+ // OPPORTUNISTIC, never forced. Baking requires real source dimensions, and a
+ // project imported from v1.7 has none yet: `migrateProjectDataToAxcutDocument`
+ // builds its asset from `{version:2, media, editor}`, which carries only file
+ // paths — dimensions arrive later, when `useTimeline`'s probe effect writes
+ // `asset.video` back to disk.
+ //
+ // So when dimensions are unknown we leave `"native"` in place rather than
+ // stamping a hardcoded ratio. `"native"` keeps resolving dynamically at runtime
+ // (`resolveAspectRatioValue`), which IS the v1.7 behaviour, and the next load
+ // after the probe has persisted dimensions converts it for real. Forcing
+ // `"16:9"` here would permanently reframe every portrait v1.7 project that used
+ // "Native", on its first open in v1.8, with no way back.
+ const dims = largestClipDims(doc);
+ if (!dims) {
+ return { ...doc, schemaVersion: 6 };
+ }
+ return {
+ ...doc,
+ schemaVersion: 6,
+ legacyEditor: { ...legacy, aspectRatio: toAspectRatioToken(dims.width, dims.height) },
+ };
+}
+
export const documentSchema = z.preprocess(
- (raw) => upgradeV4DocumentToV5(upgradeV3DocumentToV4(raw)),
+ (raw) => upgradeV5DocumentToV6(upgradeV4DocumentToV5(upgradeV3DocumentToV4(raw))),
documentSchemaShape,
);
diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts
index 7262356e0..7fbb49129 100644
--- a/src/native/sceneDescription.test.ts
+++ b/src/native/sceneDescription.test.ts
@@ -64,7 +64,7 @@ function makeDoc(
const createdAt = "2024-01-01T00:00:00.000Z";
const baseProject = { id: "p1", title: "Test", createdAt, updatedAt: createdAt };
return {
- schemaVersion: 5,
+ schemaVersion: 6,
project: {
...baseProject,
...(overrides.project ?? {}),
diff --git a/src/utils/aspectRatioUtils.test.ts b/src/utils/aspectRatioUtils.test.ts
index 06d54a664..e1d60181d 100644
--- a/src/utils/aspectRatioUtils.test.ts
+++ b/src/utils/aspectRatioUtils.test.ts
@@ -15,8 +15,7 @@ describe("parseAspectRatio", () => {
expect(parseAspectRatio(" 64 : 27 ")).toEqual({ width: 64, height: 27 });
});
- it('rejects the legacy "native" sentinel and malformed input', () => {
- expect(parseAspectRatio("native")).toBeNull();
+ it("rejects malformed input", () => {
expect(parseAspectRatio("16/9")).toBeNull();
expect(parseAspectRatio("16:")).toBeNull();
expect(parseAspectRatio("0:9")).toBeNull();
@@ -26,10 +25,9 @@ describe("parseAspectRatio", () => {
});
describe("isAspectRatio", () => {
- it("accepts presets, free-form shapes and the legacy sentinel", () => {
+ it("accepts presets and free-form shapes", () => {
expect(isAspectRatio("16:9")).toBe(true);
expect(isAspectRatio("64:27")).toBe(true);
- expect(isAspectRatio("native")).toBe(true);
});
it("rejects anything a project file could hold that isn't a ratio", () => {
@@ -62,10 +60,6 @@ describe("getAspectRatioValue", () => {
expect(getAspectRatioValue("9:16")).toBeCloseTo(9 / 16, 6);
expect(getAspectRatioValue("64:27")).toBeCloseTo(64 / 27, 6);
});
-
- it("falls back to 16/9 for the legacy sentinel, which has no document context here", () => {
- expect(getAspectRatioValue("native")).toBeCloseTo(FALLBACK_RATIO, 6);
- });
});
describe("getNativeAspectRatioValue", () => {