From ace7af33975cf928ff547db658c05cb40fd9490e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 25 Sep 2026 16:09:44 +0200 Subject: [PATCH 1/6] fix(cli): lay the screen out at its probed size The v2 migration leaves asset.video empty and only the camera's dimensions were backfilled, so the scene laid every recording out as 1920x1080 while the export was sized off the probe. Write the probed size into the document, which every reader already takes it from. --- src/cli/CliExportRunner.tsx | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx index 9a7f1b590..66fa1d106 100644 --- a/src/cli/CliExportRunner.tsx +++ b/src/cli/CliExportRunner.tsx @@ -211,6 +211,29 @@ async function runExport(request: CliExportRequest): Promise { if (probed.durationMs > 0) { axcutDocument = applyProbedDuration(axcutDocument, primaryAssetId, probed.durationMs / 1000); } + // The screen's dimensions, for the same reason as the camera's below: the pure migration + // cannot know them, and the scene's screen box, the output frame and Auto all read them off + // `asset.video`. Without them the CLI laid every recording out as 1920x1080 while sizing the + // export from the probe, two answers to one question. + if (probed.width > 0 && probed.height > 0) { + axcutDocument = { + ...axcutDocument, + assets: axcutDocument.assets.map((asset) => + asset.id === primaryAssetId + ? { + ...asset, + video: { + codec: "unknown", + fps: 0, + ...asset.video, + width: probed.width, + height: probed.height, + }, + } + : asset, + ), + }; + } // The camera's dimensions decide the PiP's layout box, and this is the one caller the // document cannot answer for: there is no editor session here to have probed and saved @@ -247,11 +270,8 @@ async function runExport(request: CliExportRequest): Promise { // Output sizing mirrors the ExportDialog: crop-aware smallest clip on the // timeline, normalized to the document's aspect ratio. - const probedAssetDims: Record = { - [primaryAssetId]: { width: probed.width, height: probed.height }, - }; const smallestSource = - pickExtremeDims(collectEffectiveClipDims(axcutDocument, probedAssetDims), "smallest") ?? + pickExtremeDims(collectEffectiveClipDims(axcutDocument), "smallest") ?? ({ width: probed.width, height: probed.height } as Dims); const aspectRatioValue = resolveAspectRatioValue( axcutDocument, From 972f8cf306ffd055e485bd282e6fa4f815f4f18b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 25 Sep 2026 16:10:19 +0200 Subject: [PATCH 2/6] feat(format): add Auto, a frame shaped around the composition Auto sizes the output frame from what it shows instead of the other way round: the reference clip's cropped screen, the camera layout at rest (a square camera beside or under the screen), and an even padding border. Its elongation is fit * e + (1 - fit), so 0% padding hugs the composition and more padding pulls it toward square. resolveAspectRatioValue stays the single resolver for the preview, the native scene, the export dialog, captions and the CLI. The padding box moves into compositeLayout (paddedContentSize) so the preview and the scene stop carrying their own copy, and fixed formats render as before. The Format menu lists Auto first, with the size it resolves to. --- src/components/ai-edition/PreviewCanvas.tsx | 21 ++-- src/components/ai-edition/RightPanes.tsx | 32 +++++- .../video-editor/projectPersistence.ts | 6 +- src/i18n/locales/ar/settings.json | 1 + src/i18n/locales/cs/settings.json | 1 + src/i18n/locales/de/settings.json | 1 + src/i18n/locales/en/settings.json | 1 + src/i18n/locales/es/settings.json | 1 + src/i18n/locales/fr/settings.json | 1 + src/i18n/locales/it/settings.json | 1 + src/i18n/locales/ja-JP/settings.json | 1 + src/i18n/locales/ko-KR/settings.json | 1 + src/i18n/locales/pt-BR/settings.json | 1 + src/i18n/locales/ru/settings.json | 1 + src/i18n/locales/tr/settings.json | 1 + src/i18n/locales/vi/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + src/i18n/locales/zh-TW/settings.json | 1 + .../ai-edition/document/outputFormat.test.ts | 57 ++++++++++ src/lib/ai-edition/document/outputFormat.ts | 62 +++++++++-- src/lib/ai-edition/stylePresets.ts | 2 +- src/lib/compositeLayout.test.ts | 92 ++++++++++++++++ src/lib/compositeLayout.ts | 100 +++++++++++++++--- src/native/sceneDescription.test.ts | 53 +++++++++- src/native/sceneDescription.ts | 12 ++- src/utils/aspectRatioUtils.test.ts | 4 +- src/utils/aspectRatioUtils.ts | 15 ++- .../architecture/export-pipeline.md | 6 ++ .../testing/manual-e2e-checklist.md | 1 + 29 files changed, 428 insertions(+), 50 deletions(-) diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 247f7a2ab..bdb88b9d0 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -49,13 +49,14 @@ import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview import { computeCameraFullscreenRect, computeCompositeLayout, + paddedContentSize, resolveWebcamLayoutPreset, type WebcamCompositeLayout, } from "@/lib/compositeLayout"; import { classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { computeCameraFullscreenProgress } from "@/lib/zoomMath/cameraFullscreenUtils"; -import { clamp, clamp01 } from "@/utils/math"; +import { clamp01 } from "@/utils/math"; import { AnnotationLayer } from "./AnnotationLayer"; import { NativeCompositorOverlay } from "./NativeCompositorOverlay"; import styles from "./NewEditorShell.module.css"; @@ -245,16 +246,13 @@ export function PreviewCanvas(props: PreviewCanvasProps) { activeClipHasCamera, ); const mask = settings.webcamMaskShape as WebcamMaskShape; - // ponytail: padding shrinks the available content area for ALL layouts - // (PiP/dual/stack) so the screen doesn't fill the canvas edge-to-edge. - // In vertical-stack this caps the camera strip height: at padding=0 the - // camera reaches 40% of canvas; at padding=50 it falls to ~32%; - // at padding=100 the screen takes even more. - const paddingFit = clamp(1 - (clamp(settings.padding, 0, 100) / 100) * 0.4, 0.4, 1); - const maxContentSize = { - width: Math.round(frameSize.width * paddingFit), - height: Math.round(frameSize.height * paddingFit), - }; + // Padding shrinks the available content area for ALL layouts (PiP/dual/stack), + // through the same `paddedContentSize` the scene description uses. + const maxContentSize = paddedContentSize( + frameSize, + settings.padding, + settings.aspectRatio === "auto", + ); // The screen box must be fit to the CROPPED aspect ratio, not the full // source frame's — otherwise VirtualPreview's crop math (which assumes // its container is already correctly proportioned for the crop) would @@ -288,6 +286,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) { settings.webcamSizePreset, settings.webcamPosition, settings.padding, + settings.aspectRatio, ]); // Full Camera: during a cameraFullscreen region the webcam takes the whole diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 5303d55dc..57933471d 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -47,7 +47,7 @@ import { WALLPAPER_MOTIONS, type WallpaperMotion } from "@/components/video-edit import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { resolveCaptionLane } from "@/lib/ai-edition/captions/settings"; import { collapseTracksToPills, trackGroupId } from "@/lib/ai-edition/document/audioTracks"; -import { collectNativeFormats } from "@/lib/ai-edition/document/outputFormat"; +import { collectNativeFormats, pickOutputDims } from "@/lib/ai-edition/document/outputFormat"; import type { InsertSide } from "@/lib/ai-edition/document/transcript"; import type { AxcutAsset, @@ -2420,6 +2420,9 @@ export function VideoEffectsPane() { // can never disagree about what shape the footage is. Already sorted by clip count then by // pixel area, so [0] is "the shape most of this timeline is in" with no heuristic of ours. const nativeFormats = useMemo(() => (document ? collectNativeFormats(document) : []), [document]); + // What Auto resolves to right now, shown on its row: the one entry whose shape moves with + // the project (padding, camera layout, crop) has to say where it currently stands. + const autoDims = useMemo(() => (document ? pickOutputDims(document, "auto") : null), [document]); const hasTiltedZoom = (document?.zoomRanges ?? []).some((z) => z.rotationPreset != null); const [fitMenuOpen, setFitMenuOpen] = useState(false); const [ratioMenuOpen, setRatioMenuOpen] = useState(false); @@ -2545,9 +2548,11 @@ export function VideoEffectsPane() { bakes it into a concrete token once clip dimensions are known, and leaves it alone until then. The group header below is localized, so without this the two would disagree in twelve locales. */} - {settings.aspectRatio === "native" - ? ts("effects.formatOriginal") - : getAspectRatioLabel(settings.aspectRatio)} + {settings.aspectRatio === "auto" + ? ts("effects.formatAuto") + : settings.aspectRatio === "native" + ? ts("effects.formatOriginal") + : getAspectRatioLabel(settings.aspectRatio)} @@ -2559,6 +2564,25 @@ export function VideoEffectsPane() { className="w-auto border-0 bg-transparent p-0 shadow-none" >
+ {/* Auto leads: it is the one entry that is a rule rather than a shape. */} + {ASPECT_RATIO_PRESETS.map((ratio) => (