diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 5951c9090..04e3b763c 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -29,9 +29,9 @@ describe("DocumentService", () => { }); describe("createProject", () => { - it("creates a v7 doc with the given title and writes it to disk", async () => { + it("creates a v8 doc with the given title and writes it to disk", async () => { const doc = await service.createProject("Demo Project"); - expect(doc.schemaVersion).toBe(7); + expect(doc.schemaVersion).toBe(8); expect(doc.project.title).toBe("Demo Project"); expect(doc.project.id).toMatch(/^proj_/); expect(doc.assets).toEqual([]); 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, diff --git a/src/cli/CliRecordRunner.tsx b/src/cli/CliRecordRunner.tsx index be04ab043..b7325120b 100644 --- a/src/cli/CliRecordRunner.tsx +++ b/src/cli/CliRecordRunner.tsx @@ -9,6 +9,7 @@ import { } from "@/components/video-editor/projectPersistence"; import { useScreenRecorder } from "@/hooks/useScreenRecorder"; import type { CliRecordRequest } from "@/lib/cliContracts"; +import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; type Phase = "init" | "recording" | "stopping" | "done"; @@ -91,7 +92,8 @@ function buildDefaultProject(session: { ...(session.webcamVideoPath ? { webcamVideoPath: session.webcamVideoPath } : {}), ...(session.cursorCaptureMode ? { cursorCaptureMode: session.cursorCaptureMode } : {}), }, - editor: normalizeProjectEditor({}), + // A new project: it takes the current default format rather than the v2 reading of none. + editor: normalizeProjectEditor({ aspectRatio: DEFAULT_PROJECT_APPEARANCE.aspectRatio }), }; } diff --git a/src/components/ai-edition/CaptionsPane.gating.test.tsx b/src/components/ai-edition/CaptionsPane.gating.test.tsx index 5eee1b595..6c45674b5 100644 --- a/src/components/ai-edition/CaptionsPane.gating.test.tsx +++ b/src/components/ai-edition/CaptionsPane.gating.test.tsx @@ -23,7 +23,7 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); function documentWith(asset: AxcutAsset): AxcutDocument { return { - schemaVersion: 7, + schemaVersion: 8, project: { id: "proj_1", title: "Test", diff --git a/src/components/ai-edition/CaptionsPane.placement.test.tsx b/src/components/ai-edition/CaptionsPane.placement.test.tsx index f1ab095ac..c6b890ec3 100644 --- a/src/components/ai-edition/CaptionsPane.placement.test.tsx +++ b/src/components/ai-edition/CaptionsPane.placement.test.tsx @@ -33,7 +33,7 @@ const ASSET: AxcutAsset = { function documentWith(captions: Record): AxcutDocument { return { - schemaVersion: 7, + schemaVersion: 8, project: { id: "proj_1", title: "Test", diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx index be7f130b7..8eeb18137 100644 --- a/src/components/ai-edition/EditorEmptyState.test.tsx +++ b/src/components/ai-edition/EditorEmptyState.test.tsx @@ -33,7 +33,7 @@ const sampleDoc = vi.hoisted( // imports are initialised, so reading the constant here throws // "Cannot access ... before initialization". The typecheck gate catches it // if it ever falls behind the schema. - schemaVersion: 7, + schemaVersion: 8, project: { id: "proj_test", title: "Test", 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) => (