From f10369e3c5523359fb9dff7c2e26bb151dc8bf6f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 23 Jul 2026 16:31:55 +0200 Subject: [PATCH 1/2] feat(timeline): enumerate the timeline's native formats instead of one drifting "native" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ratio picker's "native" entry resolved to whichever clip had the largest pixel area, so on a multi-clip project it silently meant "the biggest clip's format" and flipped the whole project's output shape whenever a clip was added or removed — adding a 4K portrait rush switched everything to portrait with no UI feedback. The picker now enumerates the distinct native shapes of the clips actually on the timeline (deduped by reduced ratio, so 1080p and 4K are one "16:9" entry) and the user picks one explicitly. The selection is stored as a concrete "W:H" token and can no longer drift. Native shapes annotate the preset rows with an "Original" badge rather than duplicating them, so the common case (every clip same format) gains no extra row; only shapes with no preset equivalent (an ultrawide "64:27") get a row of their own. - AspectRatio opens from a closed union to a parsed "W:H" type. "native" is kept as a legacy value so saved projects still open, and resolves to the row it currently means — re-picking it self-migrates the document off it. - New lib/ai-edition/document/outputFormat.ts is the single owner of output geometry. Deletes the duplicated referenceAssetDims/pickOutputDims in sceneDescription.ts and rawReferenceSource/EXPORT_ASPECT in ExportDialog.tsx, which had to agree by construction rather than by comment. - Fixes a preview/export divergence: PreviewCanvas resolved "native" through bare getAspectRatioValue, which answers the hardcoded 16/9 fallback, so a project saved with "native" over portrait footage framed the preview 16:9 while the compositor received a portrait output. - Corrects the comment at sceneDescription.ts claiming nothing writes legacyEditor.aspectRatio: patchEditorSettings does, there is no separate editorSettings store, and ExportDialog was reading the right field all along. It now reads through getEditorSettings so it cannot drift if storage moves. - Persistence validators accept any well-formed ratio; a preset-membership test would have reset non-preset selections to 16:9 on load. Output *resolution* still follows the largest used asset — only the shape is pinned. Verified: 518 tests pass, no new tsc errors, app boots clean. --- src/components/ai-edition/Bottombar.tsx | 19 +- .../ai-edition/ExportDialog.test.ts | 3 +- src/components/ai-edition/ExportDialog.tsx | 73 ++----- src/components/ai-edition/PreviewCanvas.tsx | 13 +- src/components/ai-edition/v4/V4Timeline.tsx | 129 ++++++++++-- .../video-editor/projectPersistence.ts | 12 +- src/i18n/locales/ar/timeline.json | 1 + src/i18n/locales/en/timeline.json | 1 + src/i18n/locales/es/timeline.json | 1 + src/i18n/locales/fr/timeline.json | 1 + src/i18n/locales/it/timeline.json | 1 + src/i18n/locales/ja-JP/timeline.json | 1 + src/i18n/locales/ko-KR/timeline.json | 1 + src/i18n/locales/pt-BR/timeline.json | 1 + src/i18n/locales/ru/timeline.json | 1 + src/i18n/locales/tr/timeline.json | 1 + src/i18n/locales/vi/timeline.json | 1 + src/i18n/locales/zh-CN/timeline.json | 1 + src/i18n/locales/zh-TW/timeline.json | 1 + .../ai-edition/document/outputFormat.test.ts | 193 +++++++++++++++++ src/lib/ai-edition/document/outputFormat.ts | 196 ++++++++++++++++++ src/lib/userPreferences.ts | 18 +- src/native/sceneDescription.ts | 63 +----- src/utils/aspectRatioUtils.test.ts | 67 +++++- src/utils/aspectRatioUtils.ts | 94 ++++++--- 25 files changed, 684 insertions(+), 209 deletions(-) create mode 100644 src/lib/ai-edition/document/outputFormat.test.ts create mode 100644 src/lib/ai-edition/document/outputFormat.ts diff --git a/src/components/ai-edition/Bottombar.tsx b/src/components/ai-edition/Bottombar.tsx index 434e257bfb..e73c16dbc9 100644 --- a/src/components/ai-edition/Bottombar.tsx +++ b/src/components/ai-edition/Bottombar.tsx @@ -18,7 +18,7 @@ import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import { useTimeline } from "@/lib/ai-edition/store/useTimeline"; import { suggestZoomRegions } from "@/lib/ai-edition/store/zoomSuggestions"; import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview"; -import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils"; +import { ASPECT_RATIO_PRESETS, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; import { EditClipModal } from "./Modals"; import styles from "./NewEditorShell.module.css"; import { type Span } from "./RegionTimeline"; @@ -75,17 +75,6 @@ interface BottombarProps { timelineVariant?: "edit" | "media"; } -const RATIO_LABELS: Record = { - "16:9": "16:9", - "9:16": "9:16", - "1:1": "1:1", - "4:3": "4:3", - "4:5": "4:5", - "16:10": "16:10", - "10:16": "10:16", - native: "Original", -}; - export function Bottombar({ clips, videoSources, @@ -342,7 +331,7 @@ export function Bottombar({ aria-haspopup="menu" aria-expanded={ratioOpen} > - {RATIO_LABELS[settings.aspectRatio]} + {getAspectRatioLabel(settings.aspectRatio)} {ratioOpen && ratioMenuRect @@ -362,7 +351,7 @@ export function Bottombar({ zIndex: 1000, }} > - {ASPECT_RATIOS.map((r) => ( + {ASPECT_RATIO_PRESETS.map((r) => ( ))} , diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts index 264e855fb8..992808f218 100644 --- a/src/components/ai-edition/ExportDialog.test.ts +++ b/src/components/ai-edition/ExportDialog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; +import { collectUsedAssetDims, pickExtremeDims } from "@/lib/ai-edition/document/outputFormat"; import type { AxcutAsset, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; -import { collectEffectiveClipDims, collectUsedAssetDims, pickExtremeDims } from "./ExportDialog"; +import { collectEffectiveClipDims } from "./ExportDialog"; function asset(p: Partial & Pick): AxcutAsset { return { diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index 41542dbc4d..3a5f3f0f8a 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -13,12 +13,19 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { toFileUrl } from "@/components/video-editor/projectPersistence"; import { useScopedT } from "@/contexts/I18nContext"; +import { + collectUsedAssetDims, + type Dims, + pickExtremeDims, + resolveAspectRatioValue, +} from "@/lib/ai-edition/document/outputFormat"; import { type DocumentExportOptions, type ExportVideoCodec, exportAxcutDocument, } from "@/lib/ai-edition/exporter/documentExporter"; import type { AxcutDocument } from "@/lib/ai-edition/schema"; +import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration"; import { probeVideoDimensions } from "@/lib/ai-edition/timeline/duration"; import { @@ -38,11 +45,6 @@ import { exportMultiNative, exportNative } from "@/native"; import { nativeBridgeClient } from "@/native/client"; import type { CompositorClipInput } from "@/native/contracts"; import { buildSceneDescription, resolveVisibleClips } from "@/native/sceneDescription"; -import { - type AspectRatio, - getAspectRatioValue, - getNativeAspectRatioValue, -} from "@/utils/aspectRatioUtils"; import { ModalShell } from "./Modals"; import styles from "./NewEditorShell.module.css"; @@ -111,45 +113,6 @@ const QUALITY_OPTIONS: Array<{ { value: "source", labelKey: "exportQuality.high" }, ]; -type Dims = { width: number; height: number }; - -/** Single "pick the largest/smallest by pixel count" reducer, shared by every size - * comparison below instead of each one hand-rolling its own reduce + fallback. - * Exported for unit testing only — not part of the component's public surface. */ -export function pickExtremeDims(items: Dims[], direction: "largest" | "smallest"): Dims | null { - let best: Dims | null = null; - for (const d of items) { - if (d.width <= 0 || d.height <= 0) continue; - if (!best) { - best = d; - continue; - } - const area = d.width * d.height; - const bestArea = best.width * best.height; - if (direction === "largest" ? area > bestArea : area < bestArea) best = d; - } - return best; -} - -/** Raw (uncropped) probed dims for every asset the timeline actually uses — falls back to - * ANY asset with known dims if none of the used ones have probed yet (still loading), so the - * dialog shows *something* rather than blank tiers. Two call sites used to hand-roll this same - * fallback independently; centralized here as the one place it's implemented. - * Exported for unit testing only — not part of the component's public surface. */ -export function collectUsedAssetDims( - document: AxcutDocument, - probedAssetDims: Record, -): Dims[] { - const usedAssetIds = new Set(document.timeline.clips.map((c) => c.assetId)); - const dimsOf = (a: AxcutDocument["assets"][number]): Dims => ({ - width: a.video?.width || probedAssetDims[a.id]?.width || 0, - height: a.video?.height || probedAssetDims[a.id]?.height || 0, - }); - const used = document.assets.filter((a) => usedAssetIds.has(a.id)).map(dimsOf); - if (used.some((d) => d.width > 0 && d.height > 0)) return used; - return document.assets.map(dimsOf); -} - /** Per-CLIP effective (post-crop) pixel dims — crop is stored per-clip (`clip.cropRegion`), not * per-asset, since the same recording can be framed differently across clips, so this is the * true footprint each clip contributes to the timeline. Falls back to `collectUsedAssetDims`'s @@ -271,24 +234,16 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { ? Math.min(smallestSource.width, smallestSource.height) : null; - // Largest clip's RAW (uncropped) asset dims — deliberately separate from the crop-aware - // `referenceSource` above: this only feeds the "native" output-ASPECT-RATIO option (the - // scene's overall output shape), a different concern from a clip's own cropped pixel size, - // and changing its long-standing (uncropped) meaning isn't part of this fix. - const rawReferenceSource = useMemo( + // Aspect the export normalizes to: the timeline's selected ratio (mirrors documentExporter), + // 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. + const EXPORT_ASPECT = useMemo( () => - document ? pickExtremeDims(collectUsedAssetDims(document, probedAssetDims), "largest") : null, + resolveAspectRatioValue(document, getEditorSettings(document).aspectRatio, probedAssetDims), [document, probedAssetDims], ); - - // Aspect the export normalizes to: the timeline's selected ratio (mirrors - // documentExporter), so the sizes shown match what the export produces. - const timelineAspect = - (document?.legacyEditor as { aspectRatio?: AspectRatio } | null)?.aspectRatio ?? "16:9"; - const EXPORT_ASPECT = - timelineAspect === "native" && rawReferenceSource - ? getNativeAspectRatioValue(rawReferenceSource.width, rawReferenceSource.height) - : getAspectRatioValue(timelineAspect); // Output dimensions the export will produce for a given tier, from the (crop-aware) // SMALLEST clip on the timeline — see `smallestSource` above for why. Only "Source" // quality actually uses these as its target size; 720p/1080p target a fixed short side diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index e0a227441e..8e0c0b2ae5 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -31,6 +31,7 @@ import { type ZoomFocus, } from "@/components/video-editor/types"; import { computeCameraFullscreenProgress } from "@/components/video-editor/videoPlayback/cameraFullscreenUtils"; +import { resolveAspectRatioValue } from "@/lib/ai-edition/document/outputFormat"; import type { AxcutAnnotationRegion, AxcutClip, @@ -50,7 +51,6 @@ import { } from "@/lib/compositeLayout"; import { classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; -import { getAspectRatioValue } from "@/utils/aspectRatioUtils"; import { AnnotationLayer } from "./AnnotationLayer"; import { NativeCompositorOverlay } from "./NativeCompositorOverlay"; import styles from "./NewEditorShell.module.css"; @@ -103,7 +103,8 @@ const WEBCAM_SOURCE_SIZE = { width: 960, height: 720 }; export function PreviewCanvas(props: PreviewCanvasProps) { const { settings, setLive, commit } = useEditorSettings(); - const assets = useProjectStore((s) => s.document?.assets ?? []); + const document = useProjectStore((s) => s.document); + const assets = document?.assets ?? []; const frameRef = useRef(null); const webcamSlotRef = useRef(null); // One clock per mounted canvas, shared between the screen preview (writer) @@ -179,8 +180,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. const frameSize = useMemo(() => { - const ratio = getAspectRatioValue(settings.aspectRatio); + const ratio = resolveAspectRatioValue(document, settings.aspectRatio); const { width: containerWidth, height: containerHeight } = containerSize; if (containerWidth <= 0 || containerHeight <= 0) return { width: containerWidth, height: containerHeight }; @@ -190,7 +195,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 diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 5db5353454..9824f9105f 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -13,6 +13,7 @@ import { ZoomIn, } from "lucide-react"; import { + type CSSProperties, memo, type PointerEvent as ReactPointerEvent, useCallback, @@ -28,7 +29,9 @@ 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, referenceAssetDims } 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"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; @@ -41,7 +44,11 @@ import { } from "@/lib/ai-edition/timeline/trim-mapping"; import { buildAutoZoomSuggestions } from "@/lib/ai-edition/timeline/zoom-suggestions"; import { nativeBridgeClient } from "@/native/client"; -import { ASPECT_RATIOS } from "@/utils/aspectRatioUtils"; +import { + ASPECT_RATIO_PRESETS, + getAspectRatioLabel, + toAspectRatioToken, +} from "@/utils/aspectRatioUtils"; import { TransportBar } from "../TransportBar"; import type { VideoSource } from "../VirtualPreview"; import styles from "./EditorShellV4.module.css"; @@ -168,6 +175,28 @@ const ClipWaveform = memo(function ClipWaveform({ ); }); +/** Right-aligned hint on a ratio row. Marks the shapes that are native to the timeline's own + * clips, so "Original" annotates a concrete ratio the user can name instead of being a separate + * menu entry that resolves to a different shape depending on which clips are loaded. */ +const nativeBadgeStyle: CSSProperties = { + marginLeft: "auto", + fontSize: 10.5, + fontWeight: 500, + color: "var(--muted)", + whiteSpace: "nowrap", +}; + +/** Header for native shapes that match no preset (an ultrawide, an odd capture size) and so + * need a row of their own. */ +const aspectSectionLabelStyle: CSSProperties = { + padding: "8px 10px 4px", + fontSize: 10, + fontWeight: 600, + letterSpacing: "0.04em", + textTransform: "uppercase", + color: "var(--muted)", +}; + interface LanePill { id: string; kind: "annotation" | "speed" | "trim" | "zoom" | "cameraFullscreen"; @@ -240,6 +269,36 @@ export function V4Timeline({ shiftPx: number; } | null>(null); const { settings, set: setSettings } = useEditorSettings(); + const document = useProjectStore((s) => s.document); + // The distinct native shapes of the clips actually on the timeline. "Original" used to be a + // single menu entry that silently resolved to whichever clip had the most pixels — so adding + // a 4K portrait rush flipped the whole project to portrait with no UI feedback. Enumerating + // them instead means the user picks a shape explicitly, and what gets stored is a concrete + // "W:H" token that no longer moves when the clip list changes. + const nativeFormats = useMemo(() => (document ? collectNativeFormats(document) : []), [document]); + // Common case (every clip shares one format): no separate section, just a badge on the preset + // that already matches — no extra row, no extra decision. Only shapes with no preset + // equivalent (an ultrawide "64:27", an odd capture size) need a row of their own. + const nativeByToken = useMemo( + () => new Map(nativeFormats.map((f) => [f.token, f])), + [nativeFormats], + ); + const unlistedNativeFormats = useMemo( + () => + nativeFormats.filter((f) => !(ASPECT_RATIO_PRESETS as readonly string[]).includes(f.token)), + [nativeFormats], + ); + 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 = referenceAssetDims(document); + return toAspectRatioToken(reference.width, reference.height) ?? settings.aspectRatio; + }, [settings.aspectRatio, document]); const [aspectMenuOpen, setAspectMenuOpen] = useState(false); const [autoEnhanceOpen, setAutoEnhanceOpen] = useState(false); @@ -1059,7 +1118,7 @@ export function V4Timeline({ title={t("toolbar.aspectRatio")} aria-label={t("toolbar.aspectRatio")} > - {settings.aspectRatio} + {getAspectRatioLabel(activeToken)} @@ -1071,23 +1130,57 @@ export function V4Timeline({ >
- {ASPECT_RATIOS.map((ratio) => ( - - ))} + {ASPECT_RATIO_PRESETS.map((ratio) => { + const native = nativeByToken.get(ratio); + return ( + + ); + })} + {unlistedNativeFormats.length > 0 ? ( + <> +
{t("toolbar.original")}
+ {unlistedNativeFormats.map((format) => ( + + ))} + + ) : null}
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index d77fe242a1..1528d75d0a 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -5,7 +5,7 @@ import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@ import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper"; -import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; +import { type AspectRatio, isAspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { DEFAULT_EDITOR_APPEARANCE_SETTINGS, DEFAULT_EDITOR_LAYOUT_SETTINGS, @@ -226,11 +226,11 @@ export function resolveProjectMedia( } export function normalizeProjectEditor(editor: Partial): ProjectEditorState { - const validAspectRatios = new Set(ASPECT_RATIOS); - const normalizedAspectRatio: AspectRatio = validAspectRatios.has( - editor.aspectRatio as AspectRatio, - ) - ? (editor.aspectRatio as AspectRatio) + // Any well-formed "W:H" is valid, not just the presets — the ratio picker also stores the + // clips' own native shapes ("Original"), which can be e.g. "64:27". A membership test against + // the preset list would silently reset those projects to 16:9 on load. + const normalizedAspectRatio: AspectRatio = isAspectRatio(editor.aspectRatio) + ? editor.aspectRatio : DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio; const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset( editor.webcamLayoutPreset, diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json index b054fa247f..1ecbd4bb3f 100644 --- a/src/i18n/locales/ar/timeline.json +++ b/src/i18n/locales/ar/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "باستخدام الذكاء الاصطناعي", "comment": "تعليق", "aspectRatio": "نسبة العرض إلى الارتفاع", + "original": "الأصلي", "timelineTools": "أدوات المخطط الزمني", "arrangeClips": "ترتيب المقاطع", "arrangeClipsHint": "اسحب المقاطع أدناه لإعادة ترتيبها أو إسقاط مقاطع جديدة", diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index 18bf7c6063..afcff367aa 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "With AI", "comment": "Comment", "aspectRatio": "Aspect ratio", + "original": "Original", "timelineTools": "Timeline tools", "arrangeClips": "Arrange clips", "arrangeClipsHint": "Drag clips below to reorder or drop new ones in", diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index 1bc2fc5039..2d2b7e620b 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "Con IA", "comment": "Comentario", "aspectRatio": "Relación de aspecto", + "original": "Original", "timelineTools": "Herramientas de la línea de tiempo", "arrangeClips": "Organizar clips", "arrangeClipsHint": "Arrastra los clips de abajo para reordenarlos o suelta otros nuevos", diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index fe30d2c72b..dbe5f2f18e 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "Avec l'IA", "comment": "Commentaire", "aspectRatio": "Format", + "original": "Original", "timelineTools": "Outils de la timeline", "arrangeClips": "Organiser les clips", "arrangeClipsHint": "Glissez les clips ci-dessous pour les réorganiser ou en déposer de nouveaux", diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json index f583d50307..d96672583d 100644 --- a/src/i18n/locales/it/timeline.json +++ b/src/i18n/locales/it/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "Con l'IA", "comment": "Commento", "aspectRatio": "Proporzioni", + "original": "Originale", "timelineTools": "Strumenti della timeline", "arrangeClips": "Organizza clip", "arrangeClipsHint": "Trascina le clip qui sotto per riordinarle o rilasciane di nuove", diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json index d750c64c40..eeb81d70b1 100644 --- a/src/i18n/locales/ja-JP/timeline.json +++ b/src/i18n/locales/ja-JP/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "AIを使用", "comment": "コメント", "aspectRatio": "アスペクト比", + "original": "オリジナル", "timelineTools": "タイムラインツール", "arrangeClips": "クリップを配置", "arrangeClipsHint": "下のクリップをドラッグして並べ替えるか、新しいクリップをドロップします", diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json index 1a2cb5f068..17eb9c4753 100644 --- a/src/i18n/locales/ko-KR/timeline.json +++ b/src/i18n/locales/ko-KR/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "AI 사용", "comment": "코멘트", "aspectRatio": "화면 비율", + "original": "원본", "timelineTools": "타임라인 도구", "arrangeClips": "클립 정리", "arrangeClipsHint": "아래 클립을 드래그하여 순서를 바꾸거나 새 클립을 놓으세요", diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json index 0d7d254711..e18236dbb8 100644 --- a/src/i18n/locales/pt-BR/timeline.json +++ b/src/i18n/locales/pt-BR/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "Com IA", "comment": "Comentário", "aspectRatio": "Proporção", + "original": "Original", "timelineTools": "Ferramentas da linha do tempo", "arrangeClips": "Organizar clipes", "arrangeClipsHint": "Arraste os clipes abaixo para reordená-los ou solte novos", diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index 913a6974e9..75a64cf842 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "С помощью ИИ", "comment": "Комментарий", "aspectRatio": "Соотношение сторон", + "original": "Исходный", "timelineTools": "Инструменты таймлайна", "arrangeClips": "Упорядочить клипы", "arrangeClipsHint": "Перетащите клипы ниже, чтобы изменить порядок, или добавьте новые", diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json index 7c49cbf1a5..64a94d0ffc 100644 --- a/src/i18n/locales/tr/timeline.json +++ b/src/i18n/locales/tr/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "Yapay zeka ile", "comment": "Yorum", "aspectRatio": "En-boy oranı", + "original": "Orijinal", "timelineTools": "Zaman çizelgesi araçları", "arrangeClips": "Klipleri düzenle", "arrangeClipsHint": "Yeniden sıralamak için aşağıdaki klipleri sürükleyin veya yenilerini bırakın", diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json index 0771d1c113..56c3fe18a9 100644 --- a/src/i18n/locales/vi/timeline.json +++ b/src/i18n/locales/vi/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "Với AI", "comment": "Bình luận", "aspectRatio": "Tỷ lệ khung hình", + "original": "Gốc", "timelineTools": "Công cụ dòng thời gian", "arrangeClips": "Sắp xếp clip", "arrangeClipsHint": "Kéo các clip bên dưới để sắp xếp lại hoặc thả clip mới vào", diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index 645382a971..59e4a85604 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "使用 AI", "comment": "评论", "aspectRatio": "宽高比", + "original": "原始", "timelineTools": "时间轴工具", "arrangeClips": "排列片段", "arrangeClipsHint": "拖动下方片段以重新排序,或拖入新片段", diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index 85e444d817..42886f462e 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -70,6 +70,7 @@ "smartZoomsAndCutsHint": "使用 AI", "comment": "留言", "aspectRatio": "長寬比", + "original": "原始", "timelineTools": "時間軸工具", "arrangeClips": "排列片段", "arrangeClipsHint": "拖曳下方片段以重新排序,或拖曳新片段至此", diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts new file mode 100644 index 0000000000..2e9a104bd6 --- /dev/null +++ b/src/lib/ai-edition/document/outputFormat.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import type { AxcutAsset, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; +import { + collectNativeFormats, + pickOutputDims, + referenceAssetDims, + resolveAspectRatioValue, +} from "./outputFormat"; + +function asset(id: string, width: number, height: number): AxcutAsset { + return { + kind: "video", + id, + label: id, + originalPath: `/tmp/${id}.mp4`, + cameraTrack: null, + video: { width, height } as AxcutAsset["video"], + }; +} + +function clip(id: string, assetId: string): AxcutClip { + return { + id, + assetId, + sourceStartSec: 0, + sourceEndSec: 10, + timelineStartSec: 0, + timelineEndSec: 10, + wordRefs: [], + origin: "user", + reason: "", + }; +} + +function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument { + return { + schemaVersion: 3, + project: { + id: "proj_1", + title: "Test", + createdAt: "2026-06-26T10:00:00Z", + updatedAt: "2026-06-26T10:00:00Z", + primaryAssetId: assets[0]?.id ?? "asset_1", + }, + assets, + transcript: null, + transcripts: [], + timeline: { + clips, + gaps: [], + trimRanges: [], + muteRanges: [], + speedRanges: [], + captionRanges: [], + }, + annotations: [], + zoomRanges: [], + legacyEditor: null, + agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, + preview: { strategy: "seek", revision: 0 }, + export: { preset: "final-balanced", lastJobId: null }, + history: { revisions: [] }, + } as AxcutDocument; +} + +describe("collectNativeFormats", () => { + it("returns one entry when every clip shares a shape — the common case stays a single choice", () => { + const d = doc( + [asset("a1", 1920, 1080), asset("a2", 1280, 720)], + [clip("c1", "a1"), clip("c2", "a2")], + ); + expect(collectNativeFormats(d)).toEqual([ + { token: "16:9", ratio: 16 / 9, width: 1920, height: 1080, clipCount: 2 }, + ]); + }); + + it("dedups by SHAPE, not pixel size — 1080p and 4K are the same 16:9 entry", () => { + const d = doc( + [asset("a1", 1920, 1080), asset("a2", 3840, 2160)], + [clip("c1", "a1"), clip("c2", "a2")], + ); + const formats = collectNativeFormats(d); + expect(formats).toHaveLength(1); + expect(formats[0].token).toBe("16:9"); + // Representative dims are the largest available, for the menu's size hint. + expect(formats[0]).toMatchObject({ width: 3840, height: 2160, clipCount: 2 }); + }); + + it("enumerates each distinct shape on a mixed timeline, most-used first", () => { + const d = doc( + [asset("a1", 1920, 1080), asset("a2", 2160, 3840)], + [clip("c1", "a1"), clip("c2", "a1"), clip("c3", "a2")], + ); + expect(collectNativeFormats(d).map((f) => [f.token, f.clipCount])).toEqual([ + ["16:9", 2], + ["9:16", 1], + ]); + }); + + it("reduces non-preset shapes to their own token (ultrawide)", () => { + const d = doc([asset("a1", 2560, 1080)], [clip("c1", "a1")]); + expect(collectNativeFormats(d)[0].token).toBe("64:27"); + }); + + it("ignores clips whose asset is missing or has no probed dimensions", () => { + const d = doc([asset("a1", 0, 0)], [clip("c1", "a1"), clip("c2", "ghost")]); + expect(collectNativeFormats(d)).toEqual([]); + }); + + it("counts clips, not assets — two cuts of one recording are one format", () => { + const d = doc([asset("a1", 1920, 1080)], [clip("c1", "a1"), clip("c2", "a1")]); + expect(collectNativeFormats(d)).toHaveLength(1); + expect(collectNativeFormats(d)[0].clipCount).toBe(2); + }); + + it("only considers assets the timeline actually uses", () => { + const d = doc([asset("a1", 1920, 1080), asset("unused", 1080, 1920)], [clip("c1", "a1")]); + expect(collectNativeFormats(d).map((f) => f.token)).toEqual(["16:9"]); + }); +}); + +describe("referenceAssetDims", () => { + it("picks the largest pixel area among used assets", () => { + const d = doc( + [asset("a1", 1280, 720), asset("a2", 3840, 2160)], + [clip("c1", "a1"), clip("c2", "a2")], + ); + expect(referenceAssetDims(d)).toEqual({ width: 3840, height: 2160 }); + }); + + it("falls back to any asset with dims when no used asset has probed yet", () => { + const d = doc([asset("a1", 0, 0), asset("a2", 1280, 720)], [clip("c1", "a1")]); + expect(referenceAssetDims(d)).toEqual({ width: 1280, height: 720 }); + }); + + it("falls back to 1920x1080 for a document with no dimensions at all", () => { + expect(referenceAssetDims(doc([], []))).toEqual({ width: 1920, height: 1080 }); + }); +}); + +describe("pickOutputDims", () => { + it("derives the short side from the chosen ratio, keeping the reference long side", () => { + const d = doc([asset("a1", 1920, 1080)], [clip("c1", "a1")]); + expect(pickOutputDims(d, "16:9")).toEqual({ width: 1920, height: 1080 }); + expect(pickOutputDims(d, "9:16")).toEqual({ width: 1080, height: 1920 }); + expect(pickOutputDims(d, "1:1")).toEqual({ width: 1920, height: 1920 }); + }); + + it("accepts a concrete non-preset token picked from the Original section", () => { + const d = doc([asset("a1", 2560, 1080)], [clip("c1", "a1")]); + expect(pickOutputDims(d, "64:27")).toEqual({ width: 2560, height: 1080 }); + }); + + it("a stored shape no longer moves when a bigger clip of another shape is added", () => { + const before = doc([asset("a1", 1920, 1080)], [clip("c1", "a1")]); + const after = doc( + [asset("a1", 1920, 1080), asset("a2", 2160, 3840)], + [clip("c1", "a1"), clip("c2", "a2")], + ); + const shapeOf = (d: AxcutDocument) => { + const o = pickOutputDims(d, "16:9"); + return o.width / o.height; + }; + expect(shapeOf(after)).toBeCloseTo(shapeOf(before), 6); + // Resolution still follows the largest clip — that policy is unchanged. + expect(pickOutputDims(after, "16:9")).toEqual({ width: 3840, height: 2160 }); + }); + + it('legacy "native" still resolves to the reference asset, 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 }); + }); +}); + +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); + }); + + 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); + }); +}); diff --git a/src/lib/ai-edition/document/outputFormat.ts b/src/lib/ai-edition/document/outputFormat.ts new file mode 100644 index 0000000000..e770ca27eb --- /dev/null +++ b/src/lib/ai-edition/document/outputFormat.ts @@ -0,0 +1,196 @@ +/** + * The ONE place that decides the scene's output geometry from a document. + * + * Three call sites used to hand-roll "which asset is the reference, and what ratio does that + * make" independently — `referenceAssetDims`/`pickOutputDims` (native/sceneDescription.ts, feeds + * the D3D compositor), `rawReferenceSource`/`EXPORT_ASPECT` (ExportDialog.tsx, feeds the size + * shown next to each quality tier), and `PreviewCanvas.tsx`'s frame sizing (which didn't resolve + * "native" at all and so framed old projects 16:9 while the compositor output portrait). They + * have to agree by construction, not by comment, so they all route through here. + * + * `collectNativeFormats` is the other half: instead of silently resolving "Original" to whichever + * clip happens to be biggest — which flips the whole project's shape when a clip is added or + * removed — the picker enumerates the distinct shapes on the timeline and the user picks one, + * which is then stored as a concrete `"W:H"` token and can no longer drift. + */ + +import { + type AspectRatio, + getAspectRatioValue, + getNativeAspectRatioValue, + toAspectRatioToken, +} from "@/utils/aspectRatioUtils"; +import type { AxcutDocument } from "../schema"; + +export interface Dims { + width: number; + height: number; +} + +/** Output frame used when a document has no usable asset dimensions at all. */ +const FALLBACK_OUTPUT_DIMS: Dims = { width: 1920, height: 1080 }; + +/** One distinct native shape present on the timeline — an entry in the "Original" section. */ +export interface NativeFormat { + /** Reduced `"W:H"` token. This is what gets persisted when the user picks this entry. */ + token: AspectRatio; + /** `token`'s numeric value, so callers don't re-parse. */ + ratio: number; + /** Largest pixel dims among the clips sharing this shape. Label only — the output size + * still follows `referenceAssetDims` (see `pickOutputDims`). */ + width: number; + height: number; + /** How many timeline clips have this native shape. Drives the menu order and the + * "N clips" hint shown only when the timeline is actually mixed. */ + clipCount: number; +} + +/** Single "pick the largest/smallest by pixel count" reducer, shared by every size comparison + * instead of each one hand-rolling its own reduce + fallback. */ +export function pickExtremeDims(items: Dims[], direction: "largest" | "smallest"): Dims | null { + let best: Dims | null = null; + for (const d of items) { + if (d.width <= 0 || d.height <= 0) continue; + if (!best) { + best = d; + continue; + } + const area = d.width * d.height; + const bestArea = best.width * best.height; + if (direction === "largest" ? area > bestArea : area < bestArea) best = d; + } + return best; +} + +/** Raw (uncropped) probed dims for every asset the timeline actually uses — falls back to ANY + * asset with known dims if none of the used ones have probed yet (still loading), so callers + * show *something* rather than blank. */ +export function collectUsedAssetDims( + document: AxcutDocument, + probedAssetDims: Record = {}, +): Dims[] { + const usedAssetIds = new Set(document.timeline.clips.map((c) => c.assetId)); + const dimsOf = (a: AxcutDocument["assets"][number]): Dims => ({ + width: a.video?.width || probedAssetDims[a.id]?.width || 0, + height: a.video?.height || probedAssetDims[a.id]?.height || 0, + }); + const used = document.assets.filter((a) => usedAssetIds.has(a.id)).map(dimsOf); + if (used.some((d) => d.width > 0 && d.height > 0)) return used; + return document.assets.map(dimsOf); +} + +/** + * The asset whose pixel dimensions set the output's SIZE: largest pixel area among the assets + * the timeline uses. This is a size policy only — the output SHAPE comes from the selected + * aspect ratio and no longer moves with the clip list. + * + * Resolution still does, and since `rasterise at the output geometry` (compositor.rs + * `render_size`) that now costs something real: `output` sets the size the compositor actually + * rasterises at, not just a final rescale off a fixed 1080p target. Adding a 4K rush to a 1080p + * project therefore makes every frame rasterise at 4K. Pinning resolution the way shape is now + * pinned is a separate decision, deliberately not taken here. + */ +export function referenceAssetDims( + document: AxcutDocument, + probedAssetDims: Record = {}, +): Dims { + return ( + pickExtremeDims(collectUsedAssetDims(document, probedAssetDims), "largest") ?? + FALLBACK_OUTPUT_DIMS + ); +} + +/** + * The distinct native shapes of the clips on the timeline, most-used first. + * + * Deduped by REDUCED ratio, so 1920x1080 and 3840x2160 are the same entry (`"16:9"`) — the user + * is choosing an output shape, not a resolution. Uses each clip's raw asset dims, ignoring the + * per-clip crop: "Original" means the recording's own format, which is a different question from + * how a given clip happens to be framed. + * + * Returns `[]` for a document with no usable dimensions; a single entry is the common case and + * should be presented as a plain "Original" row with no extra chrome. + */ +export function collectNativeFormats( + document: AxcutDocument, + probedAssetDims: Record = {}, +): NativeFormat[] { + const assetById = new Map(document.assets.map((a) => [a.id, a])); + const byToken = new Map(); + + for (const clip of document.timeline.clips) { + const asset = assetById.get(clip.assetId); + if (!asset) continue; + const width = asset.video?.width || probedAssetDims[asset.id]?.width || 0; + const height = asset.video?.height || probedAssetDims[asset.id]?.height || 0; + const token = toAspectRatioToken(width, height); + if (!token) continue; + + const existing = byToken.get(token); + if (!existing) { + byToken.set(token, { + token, + ratio: width / height, + width, + height, + clipCount: 1, + }); + continue; + } + existing.clipCount += 1; + // Keep the biggest representative so the label shows the best available resolution. + if (width * height > existing.width * existing.height) { + existing.width = width; + existing.height = height; + } + } + + return [...byToken.values()].sort( + (a, b) => + b.clipCount - a.clipCount || + b.width * b.height - a.width * a.height || + a.token.localeCompare(b.token), + ); +} + +/** + * 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( + document: AxcutDocument | null | undefined, + aspectRatio: AspectRatio, + probedAssetDims: Record = {}, +): number { + if (aspectRatio !== "native") return getAspectRatioValue(aspectRatio); + if (!document) return getAspectRatioValue("native"); + const reference = referenceAssetDims(document, probedAssetDims); + return getNativeAspectRatioValue(reference.width, reference.height); +} + +/** + * The output frame's dimensions, honoring the timeline's chosen aspect ratio. + * + * `SceneDescription.output` is the compositor's rasterisation geometry: it sizes `render_size` + * (compositor.rs), which is the denominator of every normalised↔px conversion on the native side + * and the resolution frames are actually drawn at. It is no longer a target that a fixed 16:9 + * canvas gets stretched onto — that canvas, and the per-layer undistort corrections it needed, + * are gone. So this function is choosing real pixels, not a correction factor. + * + * Convention aligned on `calculateSourceDimensions` (mp4ExportSettings.ts): the reference asset's + * longest side is the base, the other side is derived from the chosen ratio. + */ +export function pickOutputDims( + document: AxcutDocument, + aspectRatio: AspectRatio, + probedAssetDims: Record = {}, +): Dims { + const reference = referenceAssetDims(document, probedAssetDims); + const ratio = resolveAspectRatioValue(document, aspectRatio, probedAssetDims); + const longSide = Math.max(reference.width, reference.height); + if (ratio >= 1) { + return { width: longSide, height: Math.round(longSide / ratio) }; + } + return { width: Math.round(longSide * ratio), height: longSide }; +} diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index f166d5e5e9..f98fb422d3 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -3,21 +3,10 @@ import { DEFAULT_EXPORT_SETTINGS, } from "@/components/video-editor/editorDefaults"; import type { ExportFormat, ExportQuality } from "@/lib/exporter"; -import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { type AspectRatio, isAspectRatio } from "@/utils/aspectRatioUtils"; const PREFS_KEY = "openscreen_user_preferences"; -const VALID_ASPECT_RATIOS: readonly string[] = [ - "16:9", - "9:16", - "1:1", - "4:3", - "4:5", - "16:10", - "10:16", - "native", -]; - export interface UserPreferences { /** Default padding % */ padding: number; @@ -79,10 +68,7 @@ export function loadUserPreferences(): UserPreferences { raw.padding <= 100 ? raw.padding : DEFAULT_PREFS.padding, - aspectRatio: - typeof raw.aspectRatio === "string" && VALID_ASPECT_RATIOS.includes(raw.aspectRatio) - ? (raw.aspectRatio as AspectRatio) - : DEFAULT_PREFS.aspectRatio, + aspectRatio: isAspectRatio(raw.aspectRatio) ? raw.aspectRatio : DEFAULT_PREFS.aspectRatio, exportQuality: raw.exportQuality === "medium" || raw.exportQuality === "good" || diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index a82e922d62..01cac44602 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -18,6 +18,7 @@ import type { CameraFullscreenRegion, SpeedRegion } from "@/components/video-editor/types"; import { DEFAULT_CROP_REGION } from "@/components/video-editor/types"; import { createId } from "@/lib/ai-edition/document/ids"; +import { pickOutputDims } from "@/lib/ai-edition/document/outputFormat"; import { resolvePlaybackSegments } from "@/lib/ai-edition/document/timeline"; import type { AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; @@ -29,8 +30,6 @@ import { resolveWebcamReactiveZoom, webcamSizeToFraction, } from "@/lib/compositeLayout"; -import type { AspectRatio } from "@/utils/aspectRatioUtils"; -import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils"; import type { CompositorClipInput } from "./contracts"; /** Background behind the screen. Parsed from `settings.wallpaper`. */ @@ -316,66 +315,6 @@ function parseWallpaper(wallpaper: string) { return { kind: "image", path: wallpaper } as const; } -/** Largest asset pixel area among the timeline's used assets; falls back to any asset with - * dims, then 1920x1080. Mirrors `referenceSource` in ExportDialog.tsx. */ -function referenceAssetDims(document: AxcutDocument): { width: number; height: number } { - const usedAssetIds = new Set(document.timeline.clips.map((c) => c.assetId)); - const consider = (w: number, h: number, best: { width: number; height: number } | null) => { - if (w > 0 && h > 0 && (!best || w * h > best.width * best.height)) { - return { width: w, height: h }; - } - return best; - }; - let best: { width: number; height: number } | null = null; - for (const a of document.assets) { - if (usedAssetIds.has(a.id)) { - best = consider(a.video?.width ?? 0, a.video?.height ?? 0, best); - } - } - if (!best) { - for (const a of document.assets) { - best = consider(a.video?.width ?? 0, a.video?.height ?? 0, best); - } - } - return best ?? { width: 1920, height: 1080 }; -} - -/** The output frame's dimensions, honoring the timeline's chosen aspect ratio. - * - * BUG corrigé : cette fonction ne retournait QUE les dimensions brutes du plus gros asset - * source (typiquement 16:9), sans jamais tenir compte du ratio réellement choisi par - * l'utilisateur. `output.width`/`height` alimente le calcul "fit" côté natif - * (`compose_frame`, compositor.rs) qui compare `output` à la résolution interne 16:9 pour - * savoir de combien corriger l'écran/la webcam avant l'étirement final — avec la valeur - * précédente (toujours ~16:9), cette correction était systématiquement un no-op, quel que - * soit le ratio affiché dans l'UI (9:16, 1:1…), d'où la déformation qui persistait. - * - * PREMIÈRE tentative fautive : lire `document.legacyEditor.aspectRatio` (comme - * `EXPORT_ASPECT` dans ExportDialog.tsx) — sauf que RIEN n'écrit jamais ce champ. Le vrai - * sélecteur UI (le dropdown de ratio, V4Timeline.tsx) appelle `setSettings({aspectRatio})`, - * qui persiste dans le store `editorSettings` (`settings.aspectRatio`, déjà résolu par - * `getEditorSettings(document)` dans `buildSceneDescription` — donc ExportDialog.tsx lit - * probablement aussi la mauvaise source, latent bug distinct à vérifier séparément). - * - * Convention alignée sur `calculateSourceDimensions` (mp4ExportSettings.ts) : le plus grand - * côté de l'asset de référence reste la base, l'autre côté est dérivé du ratio choisi. - */ -function pickOutputDims( - document: AxcutDocument, - aspectRatio: AspectRatio, -): { width: number; height: number } { - const reference = referenceAssetDims(document); - const ratio = - aspectRatio === "native" - ? getNativeAspectRatioValue(reference.width, reference.height) - : getAspectRatioValue(aspectRatio); - const longSide = Math.max(reference.width, reference.height); - if (ratio >= 1) { - return { width: longSide, height: Math.round(longSide / ratio) }; - } - return { width: Math.round(longSide * ratio), height: longSide }; -} - /** * The ONE clip list every native-facing consumer must build from — trim-narrowed * (`resolvePlaybackSegments`, so word-level cuts from the transcript editor actually reach diff --git a/src/utils/aspectRatioUtils.test.ts b/src/utils/aspectRatioUtils.test.ts index 6909f3df87..06d54a6644 100644 --- a/src/utils/aspectRatioUtils.test.ts +++ b/src/utils/aspectRatioUtils.test.ts @@ -1,8 +1,73 @@ import { describe, expect, it } from "vitest"; -import { getNativeAspectRatioValue } from "./aspectRatioUtils"; +import { + getAspectRatioValue, + getNativeAspectRatioValue, + isAspectRatio, + parseAspectRatio, + toAspectRatioToken, +} from "./aspectRatioUtils"; const FALLBACK_RATIO = 16 / 9; +describe("parseAspectRatio", () => { + it("splits a well-formed token", () => { + expect(parseAspectRatio("16:9")).toEqual({ width: 16, height: 9 }); + expect(parseAspectRatio(" 64 : 27 ")).toEqual({ width: 64, height: 27 }); + }); + + it('rejects the legacy "native" sentinel and malformed input', () => { + expect(parseAspectRatio("native")).toBeNull(); + expect(parseAspectRatio("16/9")).toBeNull(); + expect(parseAspectRatio("16:")).toBeNull(); + expect(parseAspectRatio("0:9")).toBeNull(); + expect(parseAspectRatio("-16:9")).toBeNull(); + expect(parseAspectRatio("")).toBeNull(); + }); +}); + +describe("isAspectRatio", () => { + it("accepts presets, free-form shapes and the legacy sentinel", () => { + 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", () => { + expect(isAspectRatio("widescreen")).toBe(false); + expect(isAspectRatio(16 / 9)).toBe(false); + expect(isAspectRatio(null)).toBe(false); + expect(isAspectRatio(undefined)).toBe(false); + }); +}); + +describe("toAspectRatioToken", () => { + it("reduces pixel dims to the shape they share", () => { + expect(toAspectRatioToken(1920, 1080)).toBe("16:9"); + expect(toAspectRatioToken(3840, 2160)).toBe("16:9"); + expect(toAspectRatioToken(2160, 3840)).toBe("9:16"); + expect(toAspectRatioToken(2560, 1080)).toBe("64:27"); + expect(toAspectRatioToken(1080, 1080)).toBe("1:1"); + }); + + it("returns null for unusable dimensions", () => { + expect(toAspectRatioToken(0, 1080)).toBeNull(); + expect(toAspectRatioToken(1920, -1)).toBeNull(); + expect(toAspectRatioToken(Number.NaN, 1080)).toBeNull(); + }); +}); + +describe("getAspectRatioValue", () => { + it("evaluates presets and free-form shapes alike", () => { + expect(getAspectRatioValue("16:9")).toBeCloseTo(16 / 9, 6); + 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", () => { it("returns the video ratio when no crop region is provided", () => { expect(getNativeAspectRatioValue(1920, 1080)).toBe(16 / 9); diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index 3fbdcd07c0..07d98a2074 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -1,4 +1,5 @@ -export const ASPECT_RATIOS = [ +/** The fixed shapes offered in the ratio picker, in menu order. */ +export const ASPECT_RATIO_PRESETS = [ "16:9", "9:16", "1:1", @@ -6,40 +7,79 @@ export const ASPECT_RATIOS = [ "4:5", "16:10", "10:16", - "native", ] as const; -export type AspectRatio = (typeof ASPECT_RATIOS)[number]; +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"; const NATIVE_ASPECT_RATIO_FALLBACK = 16 / 9; +/** 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; + const width = Number(match[1]); + const height = Number(match[2]); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + return { width, height }; +} + +/** 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; +} + +function greatestCommonDivisor(a: number, b: number): number { + let x = a; + let y = b; + while (y !== 0) { + const next = x % y; + x = y; + y = next; + } + return x; +} + /** - * Numeric value of an aspect ratio. "native" returns the 16/9 fallback; - * callers with source/crop context should use getNativeAspectRatioValue(). + * Pixel dimensions → the reduced `"W:H"` token that identifies their shape. This is what makes + * "distinct native formats" a small set: 1920x1080 and 3840x2160 both reduce to `"16:9"`, so a + * timeline mixing them offers ONE "Original" entry, not two. */ -export function getAspectRatioValue(aspectRatio: AspectRatio): number { - switch (aspectRatio) { - case "16:9": - return 16 / 9; - case "9:16": - return 9 / 16; - case "1:1": - return 1; - case "4:3": - return 4 / 3; - case "4:5": - return 4 / 5; - case "16:10": - return 16 / 10; - case "10:16": - return 10 / 16; - case "native": - return NATIVE_ASPECT_RATIO_FALLBACK; - default: { - const _exhaustiveCheck: never = aspectRatio; - return _exhaustiveCheck; - } +export function toAspectRatioToken(width: number, height: number): AspectRatio | null { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; } + const w = Math.round(width); + const h = Math.round(height); + if (w <= 0 || h <= 0) return null; + const divisor = greatestCommonDivisor(w, h) || 1; + return `${w / divisor}:${h / divisor}`; +} + +/** + * 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 : NATIVE_ASPECT_RATIO_FALLBACK; } export function getNativeAspectRatioValue( From 7b716f1cb3aa63bbb83e86e52f63fb757265c01e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 24 Jul 2026 18:38:58 +0200 Subject: [PATCH 2/2] fix(timeline): snap output dimensions to even pixels Enumerating the timeline's native shapes made non-preset ratios selectable, and that widened the domain `pickOutputDims` rounds over. The seven fixed presets all divide a normal capture's long side evenly (3840 goes to 2160, 2880, 2400, ...), so bare `Math.round` was safe by accident, not by design. An "Original" token is not. Taken from one clip and applied to a differently-shaped reference it lands anywhere: picking the shape of a 1366x768 capture ("683:384") while a 4K clip is the reference by pixel area gives 3840/(683/384) = 2158.946, which rounds to an odd 3840x2159. H.264's 4:2:0 chroma plane is half-resolution on both axes, so an odd axis has no valid subsampling -- the encoder rejects it or silently pads. `SceneOutput` is a bare u32 pair with no validation, so the odd value reaches the encoder. The docstring already claimed alignment with `calculateSourceDimensions` (mp4ExportSettings.ts) while omitting the part of that convention that makes its output encodable: it evens the base and then searches downward in steps of two until the derived side is even too. Snap both axes here instead, at the single choke point this module exists to be. Nearest-even moves the realised shape by a fraction of a pixel; being encodable at all wins. Covered by the concrete mixed-timeline case above plus a sweep asserting both axes stay even across every preset, three odd-capture tokens and legacy "native" -- the presets pass unclamped, which is precisely why this stayed latent, so the sweep has to include the odd shapes to mean anything. --- .../ai-edition/document/outputFormat.test.ts | 36 +++++++++++++++++++ src/lib/ai-edition/document/outputFormat.ts | 25 ++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts index 2e9a104bd6..42ba533966 100644 --- a/src/lib/ai-edition/document/outputFormat.test.ts +++ b/src/lib/ai-edition/document/outputFormat.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AxcutAsset, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; +import { ASPECT_RATIO_PRESETS, type AspectRatio } from "@/utils/aspectRatioUtils"; import { collectNativeFormats, pickOutputDims, @@ -151,6 +152,41 @@ describe("pickOutputDims", () => { expect(pickOutputDims(d, "64:27")).toEqual({ width: 2560, height: 1080 }); }); + it("never emits an odd axis — an Original token on a differently-shaped reference", () => { + // The case enumeration makes reachable: the user picks the shape of the 1366x768 clip + // ("683:384") while the 4K clip is the reference by pixel area. 3840/(683/384) = 2158.946, + // which bare rounding turns into an odd 3840x2159 — a height H.264's 4:2:0 plane cannot + // subsample. Snapping to the NEAREST even lands on 2158, not 2160. + const mixed = doc( + [asset("a1", 1366, 768), asset("a2", 3840, 2160)], + [clip("c1", "a1"), clip("c2", "a2")], + ); + expect(pickOutputDims(mixed, "683:384")).toEqual({ width: 3840, height: 2158 }); + }); + + it("keeps both axes even across every preset and odd-capture token", () => { + // Presets pass this even without the clamp (they divide a normal long side evenly), which + // is exactly why the odd case stayed latent — so the sweep has to include odd shapes too. + const d = doc( + [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", + ]; + for (const token of tokens) { + const out = pickOutputDims(d, token); + expect(out.width % 2, `width for ${token}`).toBe(0); + expect(out.height % 2, `height for ${token}`).toBe(0); + expect(out.width).toBeGreaterThanOrEqual(2); + expect(out.height).toBeGreaterThanOrEqual(2); + } + }); + it("a stored shape no longer moves when a bigger clip of another shape is added", () => { const before = doc([asset("a1", 1920, 1080)], [clip("c1", "a1")]); const after = doc( diff --git a/src/lib/ai-edition/document/outputFormat.ts b/src/lib/ai-edition/document/outputFormat.ts index e770ca27eb..8b01044e34 100644 --- a/src/lib/ai-edition/document/outputFormat.ts +++ b/src/lib/ai-edition/document/outputFormat.ts @@ -30,6 +30,20 @@ export interface Dims { /** Output frame used when a document has no usable asset dimensions at all. */ const FALLBACK_OUTPUT_DIMS: Dims = { width: 1920, height: 1080 }; +/** + * Round to the nearest even pixel, never below 2. H.264's 4:2:0 chroma plane is half-resolution + * on both axes, so an odd width or height has no valid subsampling — the encoder rejects it or + * silently pads. `calculateSourceDimensions` (mp4ExportSettings.ts) enforces this for the legacy + * export path; `output` feeds the native compositor's `render_size` and needs the same guarantee. + * + * This only started to matter once the picker could store a non-preset shape: every fixed preset + * happens to divide a normal capture's long side evenly (3840 → 2160, 2880, 2400, …), so bare + * rounding was safe by accident. An "Original" token taken from one clip and applied to a + * differently-shaped reference is not — e.g. `"683:384"` (a 1366x768 capture) against a 4K + * reference gives 3840/(683/384) = 2158.946, which bare rounding turns into an odd 3840x2159. + */ +const toEvenPx = (value: number): number => Math.max(2, Math.round(value / 2) * 2); + /** One distinct native shape present on the timeline — an entry in the "Original" section. */ export interface NativeFormat { /** Reduced `"W:H"` token. This is what gets persisted when the user picks this entry. */ @@ -179,7 +193,10 @@ export function resolveAspectRatioValue( * are gone. So this function is choosing real pixels, not a correction factor. * * Convention aligned on `calculateSourceDimensions` (mp4ExportSettings.ts): the reference asset's - * longest side is the base, the other side is derived from the chosen ratio. + * longest side is the base, the other side is derived from the chosen ratio — and both axes are + * snapped to even pixels (see `toEvenPx`), which is the part of that convention that actually + * makes the result encodable. Snapping can move the realised ratio by well under a pixel's worth + * of shape; being encodable at all takes precedence. */ export function pickOutputDims( document: AxcutDocument, @@ -188,9 +205,9 @@ export function pickOutputDims( ): Dims { const reference = referenceAssetDims(document, probedAssetDims); const ratio = resolveAspectRatioValue(document, aspectRatio, probedAssetDims); - const longSide = Math.max(reference.width, reference.height); + const longSide = toEvenPx(Math.max(reference.width, reference.height)); if (ratio >= 1) { - return { width: longSide, height: Math.round(longSide / ratio) }; + return { width: longSide, height: toEvenPx(longSide / ratio) }; } - return { width: Math.round(longSide * ratio), height: longSide }; + return { width: toEvenPx(longSide * ratio), height: longSide }; }