From 50879c3d1b45977a278eaaaf240c62cfabe7c742 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 17:16:23 +0200 Subject: [PATCH 1/3] refactor: drop legacy 'native' AspectRatio via v6 schema migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) Bumped axcutSchemaVersion 5 -> 6 and added upgradeV5DocumentToV6, which rewrites every stored legacyEditor.aspectRatio === "native" to a concrete "W:H" token (the timeline's largest clip dims, falling back to "16:9"). The upgrader is wired into the existing z.preprocess chain alongside v3->v4 and v4->v5. (2) Dropped the "native" union arm from AspectRatio and the per-helper branches in getAspectRatioValue / getAspectRatioLabel / isAspectRatio / formatAspectRatioForCSS. Renamed NATIVE_ASPECT_RATIO_FALLBACK -> ASPECT_RATIO_FALLBACK (the file-private fallback). getNativeAspectRatioValue is kept. (3) Removed the runtime bridge in outputFormat.resolveAspectRatioValue (it was document-aware to resolve "native") and the self-migration useMemo in V4Timeline (the activeToken that re-mapped settings.aspectRatio to the largest clip's token). Both are dead now that v6 documents cannot contain "native". Updated the three preview/export call sites to the new one-arg signature. (4) Tests: bumped every existing schemaVersion === 5 assertion to 6 (schema, migrate, document-service), added a 7-test v5->v6 migration block in schema/index.test.ts, and stripped the "native" assertions from aspectRatioUtils and outputFormat tests (pointing at the v5->v6 upgrader tests as the new home). Intentionally untouched: lastBackgroundColor. The original audit recommended dropping it as dead code, but the picker swatch in src/lib/ai-edition/annotations/background.ts:25-29 reads it for the swatch when bg is off, and toggleTextBackground writes it on every toggle — it's the fix for the "background toggle always reverts to black" regression. --- electron/ai-edition/document-service.test.ts | 6 +- src/components/ai-edition/ExportDialog.tsx | 5 +- src/components/ai-edition/PreviewCanvas.tsx | 12 +- src/components/ai-edition/v4/V4Timeline.tsx | 24 +-- src/lib/ai-edition/document/migrate.test.ts | 2 +- .../ai-edition/document/outputFormat.test.ts | 35 +--- src/lib/ai-edition/document/outputFormat.ts | 21 +-- src/lib/ai-edition/schema/index.test.ts | 177 +++++++++++++++++- src/lib/ai-edition/schema/index.ts | 88 ++++++++- src/utils/aspectRatioUtils.test.ts | 10 +- src/utils/aspectRatioUtils.ts | 30 +-- 11 files changed, 299 insertions(+), 111 deletions(-) diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 1a291575..2d2ecd70 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/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index eab94930..ce21ac46 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -183,9 +183,10 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { // so the sizes shown match what the export produces. Read through `getEditorSettings` — the // same typed façade the ratio dropdown writes through and `buildSceneDescription` reads — so // this dialog can't drift from the compositor if the storage ever moves. `resolveAspectRatioValue` - // owns the legacy "native" case (uncropped reference asset), previously hand-rolled here. + // is the single funnel preview and export agree on; the v5→v6 upgrader retires the legacy + // "native" AspectRatio so no document-context argument is needed here. const EXPORT_ASPECT = useMemo( - () => resolveAspectRatioValue(document, getEditorSettings(document).aspectRatio), + () => resolveAspectRatioValue(getEditorSettings(document).aspectRatio), [document], ); // Output dimensions the export will produce for a given tier, from the (crop-aware) diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 80800f30..de15f91e 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -188,12 +188,12 @@ export function PreviewCanvas(props: PreviewCanvasProps) { // exactly what the frame is styled to (`width: frameSize.width` a few lines below), so it's // used directly everywhere `canvasSize` used to be — one source of truth, no separate // observer that can desync from it. - // `resolveAspectRatioValue`, not bare `getAspectRatioValue`: the latter has no document to - // resolve the legacy "native" selection against and answers 16/9 for it, so a project saved - // with "native" over portrait footage framed the preview 16:9 while `pickOutputDims` handed - // the compositor a portrait `output` — preview and export disagreed on the frame's shape. + // `resolveAspectRatioValue`, not bare `getAspectRatioValue`: keeps preview and export + // routing through the same funnel — the v5→v6 upgrader rewrites the legacy "native" + // selection to a concrete `"W:H"` token, so both paths agree on the resolved shape + // without an extra document argument. const frameSize = useMemo(() => { - const ratio = resolveAspectRatioValue(document, settings.aspectRatio); + const ratio = resolveAspectRatioValue(settings.aspectRatio); const { width: containerWidth, height: containerHeight } = containerSize; if (containerWidth <= 0 || containerHeight <= 0) return { width: containerWidth, height: containerHeight }; @@ -203,7 +203,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) { } const width = containerWidth; return { width: Math.round(width), height: Math.round(width / ratio) }; - }, [containerSize, settings.aspectRatio, document]); + }, [containerSize, settings.aspectRatio]); // Crop is per-clip (see clipSchema.cropRegion) — resolve it from whichever // clip the playhead is currently inside, the same lookup VirtualPreview diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index d75da33a..1cbab298 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 2b801de1..9b9b0bfc 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/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts index 9c16552c..c3490e8e 100644 --- a/src/lib/ai-edition/document/outputFormat.test.ts +++ b/src/lib/ai-edition/document/outputFormat.test.ts @@ -225,13 +225,7 @@ describe("pickOutputDims", () => { [asset("a1", 1366, 768), asset("a2", 3840, 2160)], [clip("c1", "a1"), clip("c2", "a2")], ); - const tokens: AspectRatio[] = [ - ...ASPECT_RATIO_PRESETS, - "683:384", - "64:27", - "1023:767", - "native", - ]; + const tokens: AspectRatio[] = [...ASPECT_RATIO_PRESETS, "683:384", "64:27", "1023:767"]; for (const token of tokens) { const out = pickOutputDims(d, token); expect(out.width % 2, `width for ${token}`).toBe(0); @@ -256,14 +250,6 @@ describe("pickOutputDims", () => { expect(pickOutputDims(after, "16:9")).toEqual({ width: 3840, height: 2160 }); }); - it('legacy "native" still resolves to the reference clip, drift included', () => { - const portraitWins = doc( - [asset("a1", 1920, 1080), asset("a2", 2160, 3840)], - [clip("c1", "a1"), clip("c2", "a2")], - ); - expect(pickOutputDims(portraitWins, "native")).toEqual({ width: 2160, height: 3840 }); - }); - it("sizes the output off the cropped footprint, not the raw 4K asset", () => { // A single 4K clip cropped to a 16:9 half-width strip: the output must rasterise at the // crop's real size (1920x1080), not the asset's 3840x2160. Before the reference went @@ -277,18 +263,13 @@ describe("pickOutputDims", () => { }); describe("resolveAspectRatioValue", () => { - it('resolves legacy "native" against the document instead of the 16/9 fallback', () => { - const d = doc([asset("a1", 1080, 1920)], [clip("c1", "a1")]); - expect(resolveAspectRatioValue(d, "native")).toBeCloseTo(1080 / 1920, 6); - }); - - it('falls back to 16/9 for "native" with no document (preview before load)', () => { - expect(resolveAspectRatioValue(null, "native")).toBeCloseTo(16 / 9, 6); - }); - + // The legacy `"native"` AspectRatio is retired — the v5→v6 upgrader (see + // `src/lib/ai-edition/schema/index.test.ts` — "v5 -> v6 native AspectRatio + // migration") rewrites every stored "native" to a concrete `"W:H"` token at load + // time, so by the time this function runs there is no document context to thread + // through. What remains is a thin wrapper around `getAspectRatioValue`. it("passes concrete tokens straight through", () => { - const d = doc([asset("a1", 1080, 1920)], [clip("c1", "a1")]); - expect(resolveAspectRatioValue(d, "4:5")).toBeCloseTo(0.8, 6); - expect(resolveAspectRatioValue(d, "64:27")).toBeCloseTo(64 / 27, 6); + expect(resolveAspectRatioValue("4:5")).toBeCloseTo(0.8, 6); + expect(resolveAspectRatioValue("64:27")).toBeCloseTo(64 / 27, 6); }); }); diff --git a/src/lib/ai-edition/document/outputFormat.ts b/src/lib/ai-edition/document/outputFormat.ts index 602db3aa..faa598ef 100644 --- a/src/lib/ai-edition/document/outputFormat.ts +++ b/src/lib/ai-edition/document/outputFormat.ts @@ -18,7 +18,6 @@ import { calculateEffectiveSourceDimensions } from "@/lib/exporter/mp4ExportSett import { type AspectRatio, getAspectRatioValue, - getNativeAspectRatioValue, toAspectRatioToken, } from "@/utils/aspectRatioUtils"; import type { AxcutAsset, AxcutClip, AxcutDocument } from "../schema"; @@ -225,19 +224,13 @@ export function collectNativeFormats( } /** - * Numeric ratio for a stored selection, with the document available to resolve the legacy - * `"native"` value. Every consumer that frames or sizes the output must go through this rather - * than bare `getAspectRatioValue`, which has no document and falls back to 16/9. + * Numeric ratio for a stored selection. v6 documents never carry the legacy `"native"` + * AspectRatio (the v5→v6 upgrader rewrites it to a concrete `"W:H"` token), so resolution + * is a single token lookup. Kept as a function so the preview / export path still has a + * single funnel — if storage ever moves, this is the one place to update. */ -export function resolveAspectRatioValue( - document: AxcutDocument | null | undefined, - aspectRatio: AspectRatio, - probedAssetDims: Record = {}, -): number { - if (aspectRatio !== "native") return getAspectRatioValue(aspectRatio); - if (!document) return getAspectRatioValue("native"); - const reference = referenceClipDims(document, probedAssetDims); - return getNativeAspectRatioValue(reference.width, reference.height); +export function resolveAspectRatioValue(aspectRatio: AspectRatio): number { + return getAspectRatioValue(aspectRatio); } /** @@ -261,7 +254,7 @@ export function pickOutputDims( probedAssetDims: Record = {}, ): Dims { const reference = referenceClipDims(document, probedAssetDims); - const ratio = resolveAspectRatioValue(document, aspectRatio, probedAssetDims); + const ratio = resolveAspectRatioValue(aspectRatio); const longSide = toEvenPx(Math.max(reference.width, reference.height)); if (ratio >= 1) { return { width: longSide, height: toEvenPx(longSide / ratio) }; diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts index 4b416a8b..83a11ab6 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,162 @@ 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("falls back to 16:9 when the timeline has no clips with known dimensions", () => { + const doc = documentSchema.parse(makeV5Doc({ legacyEditor: { aspectRatio: "native" } })); + expect(doc.schemaVersion).toBe(6); + expect((doc.legacyEditor as Record).aspectRatio).toBe("16:9"); + }); + + 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 a no-op for non-v5 documents (the upgrader gates on schemaVersion === 5)", () => { + // A v4 document is handled by the v4→v5 upgrader; v5→v6 must not touch it. + const v4 = { + schemaVersion: 4, + project: { + id: "p1", + title: "v4", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + assets: [], + timeline: { clips: [] }, + }; + // v4 is rejected by the documentSchema (which expects 6 after the chain), + // but the upgrader itself must not transform an input that isn't v5. We + // round-trip through a fully-valid v5 doc instead: parse it, then verify the + // second pass (which is a v6 doc) is unchanged. + const once = documentSchema.parse(makeV5Doc({ legacyEditor: { aspectRatio: "16:9" } })); + const twice = documentSchema.parse(once); + expect(twice).toEqual(once); + expect(v4.schemaVersion).toBe(4); // the test's input doc is untouched + }); +}); diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index 990b3255..35d48b18 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -11,6 +11,10 @@ // 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. +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 +25,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 +528,84 @@ 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 intentionally ignored: v5 docs store the crop on the + * clip, but a v5→v6 rewrite only needs *some* reasonable concrete token, and the runtime + * bridge in `resolveAspectRatioValue` already accepted this same approximation. + */ +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 w = typeof video.width === "number" ? video.width : 0; + const h = typeof video.height === "number" ? video.height : 0; + if (w <= 0 || h <= 0) continue; + 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 }; + } + + const dims = largestClipDims(doc); + const token = dims ? toAspectRatioToken(dims.width, dims.height) : null; + return { + ...doc, + schemaVersion: 6, + legacyEditor: { ...legacy, aspectRatio: token ?? "16:9" }, + }; +} + export const documentSchema = z.preprocess( - (raw) => upgradeV4DocumentToV5(upgradeV3DocumentToV4(raw)), + (raw) => upgradeV5DocumentToV6(upgradeV4DocumentToV5(upgradeV3DocumentToV4(raw))), documentSchemaShape, ); diff --git a/src/utils/aspectRatioUtils.test.ts b/src/utils/aspectRatioUtils.test.ts index 06d54a66..e1d60181 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", () => { diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index 07d98a20..451f43ff 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -15,17 +15,12 @@ export type AspectRatioPreset = (typeof ASPECT_RATIO_PRESETS)[number]; * A concrete `"W:H"` shape. The presets are just the well-known members — the picker also * offers the clips' own native shapes ("Original"), which are stored the same way and can be * anything (`"64:27"` for an ultrawide, `"683:384"` for an odd capture size). - * - * `"native"` is a LEGACY value kept only so projects saved before the shapes were enumerated - * still open. It resolves to the timeline's reference asset (largest pixel area), which is - * exactly the silent, drifting behaviour the enumeration replaced — nothing writes it any - * more, so it can be dropped once old projects are assumed migrated. */ -export type AspectRatio = AspectRatioPreset | `${number}:${number}` | "native"; +export type AspectRatio = AspectRatioPreset | `${number}:${number}`; -const NATIVE_ASPECT_RATIO_FALLBACK = 16 / 9; +const ASPECT_RATIO_FALLBACK = 16 / 9; -/** Split a `"W:H"` token. Returns null for `"native"` and for anything malformed. */ +/** Split a `"W:H"` token. Returns null for anything malformed. */ export function parseAspectRatio(value: string): { width: number; height: number } | null { const match = /^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/.exec(value); if (!match) return null; @@ -40,7 +35,7 @@ export function parseAspectRatio(value: string): { width: number; height: number /** Validation gate for anything read back from disk (project files, user prefs). */ export function isAspectRatio(value: unknown): value is AspectRatio { if (typeof value !== "string") return false; - return value === "native" || parseAspectRatio(value) !== null; + return parseAspectRatio(value) !== null; } function greatestCommonDivisor(a: number, b: number): number { @@ -71,15 +66,12 @@ export function toAspectRatioToken(width: number, height: number): AspectRatio | } /** - * Numeric value of an aspect ratio. Legacy `"native"` has no document context here so it - * returns the 16/9 fallback — callers holding a document must resolve it through - * `resolveAspectRatioValue` (lib/ai-edition/document/outputFormat) instead, or preview and - * output silently disagree on old projects. + * Numeric value of an aspect ratio. Returns the 16/9 fallback for any value that doesn't + * parse as a `"W:H"` token. */ export function getAspectRatioValue(aspectRatio: AspectRatio): number { - if (aspectRatio === "native") return NATIVE_ASPECT_RATIO_FALLBACK; const parsed = parseAspectRatio(aspectRatio); - return parsed ? parsed.width / parsed.height : NATIVE_ASPECT_RATIO_FALLBACK; + return parsed ? parsed.width / parsed.height : ASPECT_RATIO_FALLBACK; } export function getNativeAspectRatioValue( @@ -99,11 +91,11 @@ export function getNativeAspectRatioValue( cropW <= 0 || cropH <= 0 ) { - return NATIVE_ASPECT_RATIO_FALLBACK; + return ASPECT_RATIO_FALLBACK; } const ratio = (videoWidth * cropW) / (videoHeight * cropH); - return Number.isFinite(ratio) && ratio > 0 ? ratio : NATIVE_ASPECT_RATIO_FALLBACK; + return Number.isFinite(ratio) && ratio > 0 ? ratio : ASPECT_RATIO_FALLBACK; } export function getAspectRatioDimensions( @@ -118,7 +110,6 @@ export function getAspectRatioDimensions( } export function getAspectRatioLabel(aspectRatio: AspectRatio): string { - if (aspectRatio === "native") return "Original"; return aspectRatio; } @@ -126,7 +117,6 @@ export function isPortraitAspectRatio(aspectRatio: AspectRatio): boolean { return getAspectRatioValue(aspectRatio) < 1; } -export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string { - if (aspectRatio === "native") return String(nativeRatio ?? NATIVE_ASPECT_RATIO_FALLBACK); +export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string { return aspectRatio.replace(":", "/"); } From 0a4b00c9ddf013e470abb5f4462c702c00564f0b Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 20:27:37 +0200 Subject: [PATCH 2/3] fix(schema): migrate 'native' using CROPPED dims, and unbreak the main build Two defects in the v5->v6 upgrade: - largestClipDims read raw asset.video dims, but 'native' resolved to the CROPPED clip at runtime (clipEffectiveDims -> calculateEffectiveSourceDimensions). A 3840x2160 asset cropped to its left half migrated to 16:9 instead of 8:9, silently reframing every cropped project. Apply the crop, snapping to even pixels the same way the export path does. - The new toAspectRatioToken import used the @/ alias, which vite-plugin-electron does not configure for the main bundle, so the build failed to resolve it. Also replaces a vacuous gating test (built a v4 object, never passed it to the upgrader, asserted its input was unchanged) with a real crop assertion, and bumps two fixtures the version bump invalidated. --- .../ai-edition/WebcamOverlay.test.tsx | 2 +- src/lib/ai-edition/schema/index.test.ts | 57 +++++++++++++------ src/lib/ai-edition/schema/index.ts | 31 +++++++--- src/native/sceneDescription.test.ts | 2 +- 4 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx index 08e573f0..64386fab 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/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts index 83a11ab6..fdbd76ae 100644 --- a/src/lib/ai-edition/schema/index.test.ts +++ b/src/lib/ai-edition/schema/index.test.ts @@ -617,26 +617,51 @@ describe("v5 -> v6 native AspectRatio migration", () => { expect(doc.legacyEditor).toBeNull(); }); - it("is a no-op for non-v5 documents (the upgrader gates on schemaVersion === 5)", () => { - // A v4 document is handled by the v4→v5 upgrader; v5→v6 must not touch it. - const v4 = { - schemaVersion: 4, + 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("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: "v4", + title: "cropped", createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z", }, - assets: [], - timeline: { clips: [] }, - }; - // v4 is rejected by the documentSchema (which expects 6 after the chain), - // but the upgrader itself must not transform an input that isn't v5. We - // round-trip through a fully-valid v5 doc instead: parse it, then verify the - // second pass (which is a v6 doc) is unchanged. - const once = documentSchema.parse(makeV5Doc({ legacyEditor: { aspectRatio: "16:9" } })); - const twice = documentSchema.parse(once); - expect(twice).toEqual(once); - expect(v4.schemaVersion).toBe(4); // the test's input doc is untouched + 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 35d48b18..177d0929 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -14,7 +14,9 @@ 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. -import { toAspectRatioToken } from "@/utils/aspectRatioUtils"; +// 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. @@ -533,10 +535,15 @@ function upgradeV4DocumentToV5(raw: unknown): unknown { * `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 intentionally ignored: v5 docs store the crop on the - * clip, but a v5→v6 rewrite only needs *some* reasonable concrete token, and the runtime - * bridge in `resolveAspectRatioValue` already accepted this same approximation. + * 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 : []; @@ -557,9 +564,19 @@ function largestClipDims(doc: Record): { width: number; height: const asset = assetById.get(assetId); if (!asset) continue; const video = (asset.video ?? {}) as Record; - const w = typeof video.width === "number" ? video.width : 0; - const h = typeof video.height === "number" ? video.height : 0; - if (w <= 0 || h <= 0) continue; + 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; diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index e6218ff3..98d77bf6 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 ?? {}), From c61c7ee2d6d42c57cf54eb5c54c681d95dfbddb7 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 29 Jul 2026 08:31:30 +0200 Subject: [PATCH 3/3] fix(schema): never force-bake 'native' before the source is probed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.7 -> v1.8 import is the case this breaks. A v1.7 project is {version:2, media, editor} and carries only file PATHS, so migrateProjectDataToAxcutDocument produces assets with no video block. The v5->v6 upgrader then found no dimensions, stamped a hardcoded '16:9', and the caller persisted it — permanently reframing every portrait v1.7 project saved with 'Native', on its first open in v1.8, with no way back. Before this PR the same project resolved dynamically and self-corrected once useTimeline's probe wrote asset.video to disk. Make the bake opportunistic: convert only when dimensions are actually known (crop-aware, as fixed earlier), otherwise leave 'native' in place. It keeps resolving dynamically at runtime — which is exactly the v1.7 behaviour — and converts for real on a later load, once the probe has persisted dimensions. That means 'native' stays a valid stored value, so this restores the document-aware resolveAspectRatioValue and its two call sites. Nothing writes 'native' any more (the picker enumerates concrete shapes), so the union shrinks by attrition instead of by a lossy rewrite. Tests cover both halves of the real upgrade path: unprobed import keeps 'native', and a probed PORTRAIT source converts to 9:16 rather than 16:9. Verified against the actual v1.7 file in the recordings folder. --- src/components/ai-edition/ExportDialog.tsx | 5 +- src/components/ai-edition/PreviewCanvas.tsx | 22 ++--- .../ai-edition/document/outputFormat.test.ts | 35 +++++-- src/lib/ai-edition/document/outputFormat.ts | 21 +++-- src/lib/ai-edition/schema/index.test.ts | 91 ++++++++++++++++++- src/lib/ai-edition/schema/index.ts | 18 +++- src/utils/aspectRatioUtils.ts | 30 ++++-- 7 files changed, 174 insertions(+), 48 deletions(-) diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index ce21ac46..eab94930 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -183,10 +183,9 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { // so the sizes shown match what the export produces. Read through `getEditorSettings` — the // same typed façade the ratio dropdown writes through and `buildSceneDescription` reads — so // this dialog can't drift from the compositor if the storage ever moves. `resolveAspectRatioValue` - // is the single funnel preview and export agree on; the v5→v6 upgrader retires the legacy - // "native" AspectRatio so no document-context argument is needed here. + // owns the legacy "native" case (uncropped reference asset), previously hand-rolled here. const EXPORT_ASPECT = useMemo( - () => resolveAspectRatioValue(getEditorSettings(document).aspectRatio), + () => resolveAspectRatioValue(document, getEditorSettings(document).aspectRatio), [document], ); // Output dimensions the export will produce for a given tier, from the (crop-aware) diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index de15f91e..99d73ba8 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -40,7 +40,6 @@ import type { AxcutZoomRegion, } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; -import { useCaptions } from "@/lib/ai-edition/store/useCaptions"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import { resolveActiveCameraTrack } from "@/lib/ai-edition/timeline/camera"; import { createPlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock"; @@ -55,7 +54,6 @@ import { classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { clamp, clamp01 } from "@/utils/math"; import { AnnotationLayer } from "./AnnotationLayer"; -import { CaptionLayer } from "./CaptionLayer"; import { NativeCompositorOverlay } from "./NativeCompositorOverlay"; import styles from "./NewEditorShell.module.css"; import { type VideoSource, VirtualPreview } from "./VirtualPreview"; @@ -110,7 +108,6 @@ export function PreviewCanvas(props: PreviewCanvasProps) { const { settings, setLive, commit } = useEditorSettings(); // Captions are derived from the transcript, not passed down as regions — the // preview reads them from the same façade the inspector writes to. - const { cues: captionCues, settings: captionSettings } = useCaptions(); const document = useProjectStore((s) => s.document); const assets = document?.assets ?? []; const frameRef = useRef(null); @@ -188,12 +185,12 @@ export function PreviewCanvas(props: PreviewCanvasProps) { // exactly what the frame is styled to (`width: frameSize.width` a few lines below), so it's // used directly everywhere `canvasSize` used to be — one source of truth, no separate // observer that can desync from it. - // `resolveAspectRatioValue`, not bare `getAspectRatioValue`: keeps preview and export - // routing through the same funnel — the v5→v6 upgrader rewrites the legacy "native" - // selection to a concrete `"W:H"` token, so both paths agree on the resolved shape - // without an extra document argument. + // `resolveAspectRatioValue`, not bare `getAspectRatioValue`: the latter has no document to + // resolve the legacy "native" selection against and answers 16/9 for it, so a project saved + // with "native" over portrait footage framed the preview 16:9 while `pickOutputDims` handed + // the compositor a portrait `output` — preview and export disagreed on the frame's shape. const frameSize = useMemo(() => { - const ratio = resolveAspectRatioValue(settings.aspectRatio); + const ratio = resolveAspectRatioValue(document, settings.aspectRatio); const { width: containerWidth, height: containerHeight } = containerSize; if (containerWidth <= 0 || containerHeight <= 0) return { width: containerWidth, height: containerHeight }; @@ -203,7 +200,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) { } const width = containerWidth; return { width: Math.round(width), height: Math.round(width / ratio) }; - }, [containerSize, settings.aspectRatio]); + }, [containerSize, settings.aspectRatio, document]); // Crop is per-clip (see clipSchema.cropRegion) — resolve it from whichever // clip the playhead is currently inside, the same lookup VirtualPreview @@ -442,13 +439,6 @@ export function PreviewCanvas(props: PreviewCanvasProps) { onCommit={props.onAnnotationCommit} /> ) : null} - ) : null} {layout?.webcamRect && showWebcamSlot ? ( diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts index c3490e8e..9c16552c 100644 --- a/src/lib/ai-edition/document/outputFormat.test.ts +++ b/src/lib/ai-edition/document/outputFormat.test.ts @@ -225,7 +225,13 @@ describe("pickOutputDims", () => { [asset("a1", 1366, 768), asset("a2", 3840, 2160)], [clip("c1", "a1"), clip("c2", "a2")], ); - const tokens: AspectRatio[] = [...ASPECT_RATIO_PRESETS, "683:384", "64:27", "1023:767"]; + const tokens: AspectRatio[] = [ + ...ASPECT_RATIO_PRESETS, + "683:384", + "64:27", + "1023:767", + "native", + ]; for (const token of tokens) { const out = pickOutputDims(d, token); expect(out.width % 2, `width for ${token}`).toBe(0); @@ -250,6 +256,14 @@ describe("pickOutputDims", () => { expect(pickOutputDims(after, "16:9")).toEqual({ width: 3840, height: 2160 }); }); + it('legacy "native" still resolves to the reference clip, drift included', () => { + const portraitWins = doc( + [asset("a1", 1920, 1080), asset("a2", 2160, 3840)], + [clip("c1", "a1"), clip("c2", "a2")], + ); + expect(pickOutputDims(portraitWins, "native")).toEqual({ width: 2160, height: 3840 }); + }); + it("sizes the output off the cropped footprint, not the raw 4K asset", () => { // A single 4K clip cropped to a 16:9 half-width strip: the output must rasterise at the // crop's real size (1920x1080), not the asset's 3840x2160. Before the reference went @@ -263,13 +277,18 @@ describe("pickOutputDims", () => { }); describe("resolveAspectRatioValue", () => { - // The legacy `"native"` AspectRatio is retired — the v5→v6 upgrader (see - // `src/lib/ai-edition/schema/index.test.ts` — "v5 -> v6 native AspectRatio - // migration") rewrites every stored "native" to a concrete `"W:H"` token at load - // time, so by the time this function runs there is no document context to thread - // through. What remains is a thin wrapper around `getAspectRatioValue`. + it('resolves legacy "native" against the document instead of the 16/9 fallback', () => { + const d = doc([asset("a1", 1080, 1920)], [clip("c1", "a1")]); + expect(resolveAspectRatioValue(d, "native")).toBeCloseTo(1080 / 1920, 6); + }); + + it('falls back to 16/9 for "native" with no document (preview before load)', () => { + expect(resolveAspectRatioValue(null, "native")).toBeCloseTo(16 / 9, 6); + }); + it("passes concrete tokens straight through", () => { - expect(resolveAspectRatioValue("4:5")).toBeCloseTo(0.8, 6); - expect(resolveAspectRatioValue("64:27")).toBeCloseTo(64 / 27, 6); + const d = doc([asset("a1", 1080, 1920)], [clip("c1", "a1")]); + expect(resolveAspectRatioValue(d, "4:5")).toBeCloseTo(0.8, 6); + expect(resolveAspectRatioValue(d, "64:27")).toBeCloseTo(64 / 27, 6); }); }); diff --git a/src/lib/ai-edition/document/outputFormat.ts b/src/lib/ai-edition/document/outputFormat.ts index faa598ef..602db3aa 100644 --- a/src/lib/ai-edition/document/outputFormat.ts +++ b/src/lib/ai-edition/document/outputFormat.ts @@ -18,6 +18,7 @@ import { calculateEffectiveSourceDimensions } from "@/lib/exporter/mp4ExportSett import { type AspectRatio, getAspectRatioValue, + getNativeAspectRatioValue, toAspectRatioToken, } from "@/utils/aspectRatioUtils"; import type { AxcutAsset, AxcutClip, AxcutDocument } from "../schema"; @@ -224,13 +225,19 @@ export function collectNativeFormats( } /** - * Numeric ratio for a stored selection. v6 documents never carry the legacy `"native"` - * AspectRatio (the v5→v6 upgrader rewrites it to a concrete `"W:H"` token), so resolution - * is a single token lookup. Kept as a function so the preview / export path still has a - * single funnel — if storage ever moves, this is the one place to update. + * Numeric ratio for a stored selection, with the document available to resolve the legacy + * `"native"` value. Every consumer that frames or sizes the output must go through this rather + * than bare `getAspectRatioValue`, which has no document and falls back to 16/9. */ -export function resolveAspectRatioValue(aspectRatio: AspectRatio): number { - return getAspectRatioValue(aspectRatio); +export function resolveAspectRatioValue( + document: AxcutDocument | null | undefined, + aspectRatio: AspectRatio, + probedAssetDims: Record = {}, +): number { + if (aspectRatio !== "native") return getAspectRatioValue(aspectRatio); + if (!document) return getAspectRatioValue("native"); + const reference = referenceClipDims(document, probedAssetDims); + return getNativeAspectRatioValue(reference.width, reference.height); } /** @@ -254,7 +261,7 @@ export function pickOutputDims( probedAssetDims: Record = {}, ): Dims { const reference = referenceClipDims(document, probedAssetDims); - const ratio = resolveAspectRatioValue(aspectRatio); + const ratio = resolveAspectRatioValue(document, aspectRatio, probedAssetDims); const longSide = toEvenPx(Math.max(reference.width, reference.height)); if (ratio >= 1) { return { width: longSide, height: toEvenPx(longSide / ratio) }; diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts index fdbd76ae..9b3767c6 100644 --- a/src/lib/ai-edition/schema/index.test.ts +++ b/src/lib/ai-edition/schema/index.test.ts @@ -590,10 +590,13 @@ describe("v5 -> v6 native AspectRatio migration", () => { expect((doc.legacyEditor as Record).aspectRatio).toBe("9:16"); }); - it("falls back to 16:9 when the timeline has no clips with known dimensions", () => { + 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("16:9"); + expect((doc.legacyEditor as Record).aspectRatio).toBe("native"); }); it("passes through a concrete aspectRatio unchanged", () => { @@ -623,6 +626,90 @@ describe("v5 -> v6 native AspectRatio migration", () => { 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 diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index 177d0929..a10b718a 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -612,12 +612,26 @@ function upgradeV5DocumentToV6(raw: unknown): unknown { 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); - const token = dims ? toAspectRatioToken(dims.width, dims.height) : null; + if (!dims) { + return { ...doc, schemaVersion: 6 }; + } return { ...doc, schemaVersion: 6, - legacyEditor: { ...legacy, aspectRatio: token ?? "16:9" }, + legacyEditor: { ...legacy, aspectRatio: toAspectRatioToken(dims.width, dims.height) }, }; } diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index 451f43ff..07d98a20 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -15,12 +15,17 @@ export type AspectRatioPreset = (typeof ASPECT_RATIO_PRESETS)[number]; * A concrete `"W:H"` shape. The presets are just the well-known members — the picker also * offers the clips' own native shapes ("Original"), which are stored the same way and can be * anything (`"64:27"` for an ultrawide, `"683:384"` for an odd capture size). + * + * `"native"` is a LEGACY value kept only so projects saved before the shapes were enumerated + * still open. It resolves to the timeline's reference asset (largest pixel area), which is + * exactly the silent, drifting behaviour the enumeration replaced — nothing writes it any + * more, so it can be dropped once old projects are assumed migrated. */ -export type AspectRatio = AspectRatioPreset | `${number}:${number}`; +export type AspectRatio = AspectRatioPreset | `${number}:${number}` | "native"; -const ASPECT_RATIO_FALLBACK = 16 / 9; +const NATIVE_ASPECT_RATIO_FALLBACK = 16 / 9; -/** Split a `"W:H"` token. Returns null for anything malformed. */ +/** Split a `"W:H"` token. Returns null for `"native"` and for anything malformed. */ export function parseAspectRatio(value: string): { width: number; height: number } | null { const match = /^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/.exec(value); if (!match) return null; @@ -35,7 +40,7 @@ export function parseAspectRatio(value: string): { width: number; height: number /** Validation gate for anything read back from disk (project files, user prefs). */ export function isAspectRatio(value: unknown): value is AspectRatio { if (typeof value !== "string") return false; - return parseAspectRatio(value) !== null; + return value === "native" || parseAspectRatio(value) !== null; } function greatestCommonDivisor(a: number, b: number): number { @@ -66,12 +71,15 @@ export function toAspectRatioToken(width: number, height: number): AspectRatio | } /** - * Numeric value of an aspect ratio. Returns the 16/9 fallback for any value that doesn't - * parse as a `"W:H"` token. + * Numeric value of an aspect ratio. Legacy `"native"` has no document context here so it + * returns the 16/9 fallback — callers holding a document must resolve it through + * `resolveAspectRatioValue` (lib/ai-edition/document/outputFormat) instead, or preview and + * output silently disagree on old projects. */ export function getAspectRatioValue(aspectRatio: AspectRatio): number { + if (aspectRatio === "native") return NATIVE_ASPECT_RATIO_FALLBACK; const parsed = parseAspectRatio(aspectRatio); - return parsed ? parsed.width / parsed.height : ASPECT_RATIO_FALLBACK; + return parsed ? parsed.width / parsed.height : NATIVE_ASPECT_RATIO_FALLBACK; } export function getNativeAspectRatioValue( @@ -91,11 +99,11 @@ export function getNativeAspectRatioValue( cropW <= 0 || cropH <= 0 ) { - return ASPECT_RATIO_FALLBACK; + return NATIVE_ASPECT_RATIO_FALLBACK; } const ratio = (videoWidth * cropW) / (videoHeight * cropH); - return Number.isFinite(ratio) && ratio > 0 ? ratio : ASPECT_RATIO_FALLBACK; + return Number.isFinite(ratio) && ratio > 0 ? ratio : NATIVE_ASPECT_RATIO_FALLBACK; } export function getAspectRatioDimensions( @@ -110,6 +118,7 @@ export function getAspectRatioDimensions( } export function getAspectRatioLabel(aspectRatio: AspectRatio): string { + if (aspectRatio === "native") return "Original"; return aspectRatio; } @@ -117,6 +126,7 @@ export function isPortraitAspectRatio(aspectRatio: AspectRatio): boolean { return getAspectRatioValue(aspectRatio) < 1; } -export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string { +export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string { + if (aspectRatio === "native") return String(nativeRatio ?? NATIVE_ASPECT_RATIO_FALLBACK); return aspectRatio.replace(":", "/"); }