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..42ba533966 --- /dev/null +++ b/src/lib/ai-edition/document/outputFormat.test.ts @@ -0,0 +1,229 @@ +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, + 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("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( + [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..8b01044e34 --- /dev/null +++ b/src/lib/ai-edition/document/outputFormat.ts @@ -0,0 +1,213 @@ +/** + * 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 }; + +/** + * 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. */ + 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 — 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, + aspectRatio: AspectRatio, + probedAssetDims: Record = {}, +): Dims { + const reference = referenceAssetDims(document, probedAssetDims); + 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) }; + } + return { width: toEvenPx(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(