From b22252ee8b028a6fc1d9c7a5c9d2a5ff220e97ae Mon Sep 17 00:00:00 2001 From: James Date: Mon, 11 May 2026 15:54:08 +0000 Subject: [PATCH 1/4] feat(studio): collapse render resolution dropdown to Auto / 1080p / 4K MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orientation is a property of the composition, not a user choice — the backend's portrait/landscape presets are tied to the comp's authored aspect ratio. Letting users pick "1080p portrait" for a landscape composition just produces a wrong-aspect render. The dropdown now exposes three scale choices (Auto / 1080p / 4K) and maps to the correct portrait/landscape preset based on the active composition's data-width / data-height. Native setResolution(e.target.value as ResolutionPreset | "auto")} + value={scale} + onChange={(e) => setScale(e.target.value as RenderScale)} disabled={isRendering} - title={RESOLUTION_OPTIONS.find((r) => r.value === resolution)?.title} className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50" > - {RESOLUTION_OPTIONS.map((r) => ( - ))} @@ -185,7 +225,9 @@ function FormatExportButton({ )} - + From 534c70e3083ee6b98eb4ed0ba95ab6f7ad87e0d3 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 11 May 2026 15:55:38 +0000 Subject: [PATCH 2/4] fix(studio): keep dev server alive when puppeteer thumbnail launch fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in getSharedBrowser() could take down the entire Vite dev server: 1. Unhandled rejection from puppeteer.launch() — the timeout error surfaces through puppeteer's internal RxJS chain, and any uncaught path crashes the Node process. The thumbnail route's try/catch doesn't always intercept it. 2. _browserLaunchPromise was never reset on failure, so subsequent thumbnail requests reused a stale rejected promise instead of retrying. Wrap the IIFE in try/catch, return null on any failure (the thumbnail route already handles a null adapter result with a 500), and reset _browserLaunchPromise in a finally block so a transient launch failure doesn't poison the singleton. Also drop the launch timeout from puppeteer's 30s default to 10s so a wedged handshake fails fast instead of stalling every pending thumbnail. Verified locally: the dev server now logs "[Studio] puppeteer launch failed — thumbnails disabled: ..." and keeps serving the studio UI after a thumbnail request fails. --- packages/studio/vite.config.ts | 45 +++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 4ceb8764d..04fb64519 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -28,20 +28,37 @@ async function getSharedBrowser(): Promise { - const puppeteer = await import("puppeteer-core"); - const executablePath = [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/usr/bin/google-chrome", - "/usr/bin/chromium-browser", - ].find((p) => existsSync(p)); - if (!executablePath) return null; - _browser = await puppeteer.default.launch({ - headless: true, - executablePath, - args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], - }); - _browserLaunchPromise = null; - return _browser; + try { + const puppeteer = await import("puppeteer-core"); + const executablePath = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + ].find((p) => existsSync(p)); + if (!executablePath) return null; + _browser = await puppeteer.default.launch({ + headless: true, + executablePath, + // 10s is enough for any healthy local launch; the default 30s lets a + // wedged handshake stall every pending thumbnail before failing. + timeout: 10_000, + args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + }); + return _browser; + } catch (err) { + // Without this guard, a launch failure (timeout, missing libs, etc.) + // surfaces as an unhandled rejection through puppeteer's internal RxJS + // chain and crashes the Vite dev server. Log + degrade gracefully — + // the thumbnail route returns a 500 and the rest of the studio keeps + // working. + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[Studio] puppeteer launch failed — thumbnails disabled: ${msg}`); + return null; + } finally { + // Reset on every outcome so a transient failure doesn't poison the + // singleton: subsequent thumbnail requests can retry the launch. + _browserLaunchPromise = null; + } })(); return _browserLaunchPromise; } From 976ceabedcf4f43b5bbd53ca9030008da982f248 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 11 May 2026 16:01:29 +0000 Subject: [PATCH 3/4] refactor(studio): simplify dropdown helpers + use stage-size message for dims Cleanup from the /simplify pass on PR #715. - App.tsx: subscribe to the runtime's `stage-size` message (which carries authoritative width/height post-applyCompositionSizing) instead of re-parsing data-width/data-height from the iframe DOM. Drops the cross-origin try/catch, querySelector, and parseInt logic, and fires once per comp load instead of on every state/timeline tick. - App.tsx: import CompositionDimensions from RenderQueue instead of inlining the shape. - RenderQueue.tsx: replace scaleLabel() with a SCALE_LABEL record, inline the one-call formatDims helper, and trim the type comment to the WHY. --- packages/studio/src/App.tsx | 40 ++++++------------- .../src/components/renders/RenderQueue.tsx | 30 ++++++-------- 2 files changed, 25 insertions(+), 45 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index edb028495..355106233 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -11,7 +11,7 @@ import { useMountEffect } from "./hooks/useMountEffect"; import { NLELayout } from "./components/nle/NLELayout"; import { SourceEditor } from "./components/editor/SourceEditor"; import { LeftSidebar } from "./components/sidebar/LeftSidebar"; -import { RenderQueue } from "./components/renders/RenderQueue"; +import { RenderQueue, type CompositionDimensions } from "./components/renders/RenderQueue"; import { useRenderQueue } from "./components/renders/useRenderQueue"; import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player"; import { AudioWaveform } from "./player/components/AudioWaveform"; @@ -278,35 +278,21 @@ export function StudioApp() { }, [captionHasSelection, captionEditMode]); // Track the active composition's authored dimensions so the render - // dropdown can derive landscape vs portrait without asking the user. - // The runtime fires "state"/"timeline" messages after compositions load. - const [compositionDimensions, setCompositionDimensions] = useState<{ - width: number; - height: number; - } | null>(null); + // dropdown can derive landscape vs portrait. The runtime emits + // `stage-size` after `applyCompositionSizing` resolves the authoritative + // dims, so we use that instead of re-parsing the iframe DOM. + const [compositionDimensions, setCompositionDimensions] = useState( + null, + ); useMountEffect(() => { - const readDimensions = () => { - const iframe = previewIframeRef.current; - let doc: Document | null = null; - try { - doc = iframe?.contentDocument ?? null; - } catch { - return; - } - if (!doc) return; - const root = doc.querySelector("[data-composition-id]"); - const w = parseInt(root?.getAttribute("data-width") ?? "", 10); - const h = parseInt(root?.getAttribute("data-height") ?? "", 10); - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return; - setCompositionDimensions((prev) => - prev && prev.width === w && prev.height === h ? prev : { width: w, height: h }, - ); - }; const handleMessage = (e: MessageEvent) => { const data = e.data; - if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) { - readDimensions(); - } + if (data?.source !== "hf-preview" || data?.type !== "stage-size") return; + const { width, height } = data as { width: number; height: number }; + if (!(width > 0) || !(height > 0)) return; + setCompositionDimensions((prev) => + prev && prev.width === width && prev.height === height ? prev : { width, height }, + ); }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index fba196055..d264c796c 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -26,13 +26,19 @@ interface RenderQueueProps { compositionDimensions?: CompositionDimensions | null; } -// User-facing render scale. Orientation is derived from the composition's -// authored aspect ratio at render time, so the user never picks an -// orientation that mismatches their comp. +// Orientation is derived from the composition's authored aspect ratio, +// not chosen by the user — picking "1080p portrait" for a landscape comp +// would just produce a wrong-aspect render. type RenderScale = "auto" | "1080p" | "4k"; const SCALE_OPTION_ORDER: RenderScale[] = ["auto", "1080p", "4k"]; +const SCALE_LABEL: Record = { + auto: "Auto", + "1080p": "1080p", + "4k": "4K", +}; + function isPortraitComp(dims: CompositionDimensions | null | undefined): boolean { // Squares and missing dims fall through to landscape — matches the legacy // default ("landscape" was the first preset). The auto option exists for @@ -50,15 +56,6 @@ function resolveResolution( return portrait ? "portrait-4k" : "landscape-4k"; } -function scaleLabel(scale: RenderScale): string { - if (scale === "auto") return "Auto"; - if (scale === "1080p") return "1080p"; - return "4K"; -} - -// Resolved output dimensions for a given scale + composition. Mirrors -// `CANVAS_DIMENSIONS` in core for the 1080p / 4K presets; `auto` echoes the -// composition's authored dims so the user can see exactly what they'll get. function resolvedDimensions( scale: RenderScale, dims: CompositionDimensions | null | undefined, @@ -71,17 +68,14 @@ function resolvedDimensions( return portrait ? { width: 2160, height: 3840 } : { width: 3840, height: 2160 }; } -function formatDims(dims: CompositionDimensions | null): string { - if (!dims) return "?"; - return `${dims.width}×${dims.height}`; -} - function scaleOptionLabel( scale: RenderScale, dims: CompositionDimensions | null | undefined, ): string { const resolved = resolvedDimensions(scale, dims); - return resolved ? `${scaleLabel(scale)} · ${formatDims(resolved)}` : scaleLabel(scale); + return resolved + ? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}` + : SCALE_LABEL[scale]; } const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = { From aa715ca8a04da899f52ecd6d26075e3e29b647d5 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 11 May 2026 16:48:54 +0000 Subject: [PATCH 4/4] feat(core,studio,cli): add square + square-4k canvas resolutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four existing presets only cover 16:9 (landscape) and 9:16 (portrait) aspect ratios. A 1080×1080 square comp had nowhere to land at any scale: "Auto" rendered at the comp's authored 1080×1080, and picking 1080p or 4K mapped to a landscape/portrait preset whose aspect ratio mismatched, which the producer's resolveDeviceScaleFactor validator rejects with "does not match the aspect ratio of the composition". Add `square` (1080×1080) and `square-4k` (2160×2160) to CANVAS_DIMENSIONS in core. The existing `keyof typeof CANVAS_DIMENSIONS` derivation extends the `CanvasResolution` union and `VALID_CANVAS_RESOLUTIONS` array automatically, so the producer's validator, the render API route, and the CLI `--resolution` flag pick the new presets up without further changes. - core: extend CANVAS_DIMENSIONS, RESOLUTION_ALIASES, and the htmlParser to recognize `data-resolution="square|square-4k"` and to infer square from equal width/height (vs. the prior "square defaults to portrait" tie-breaker). - studio: extend the local ResolutionPreset / CANVAS_DIMENSIONS mirrors; collapse isPortraitComp into a 3-way `compAspect` helper so resolveResolution returns the square preset for square comps. - cli: update --resolution help text on `init` and `render` to mention the new presets. - tests: add square cases to renderOrchestrator's resolveDeviceScaleFactor suite (returns 1 for square→square, 2 for square→square-4k, rejects landscape preset on square comp), update the htmlParser test that previously pinned the "square→portrait" tiebreaker. --- packages/cli/src/commands/init.ts | 4 +- packages/cli/src/commands/render.ts | 4 +- packages/core/src/core.types.ts | 5 ++ packages/core/src/index.test.ts | 8 ++ packages/core/src/parsers/htmlParser.test.ts | 22 ++++-- packages/core/src/parsers/htmlParser.ts | 24 +++--- .../src/services/renderOrchestrator.test.ts | 33 ++++++++ .../src/components/renders/RenderQueue.tsx | 75 ++++++++++++++----- .../src/components/renders/useRenderQueue.ts | 10 ++- 9 files changed, 144 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6136cdaa5..c61fdbc7e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -630,7 +630,7 @@ export default defineCommand({ resolution: { type: "string", description: - "Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).", + "Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).", }, }, async run({ args }) { @@ -670,7 +670,7 @@ export default defineCommand({ console.error( c.error( `Invalid --resolution: "${args.resolution}". ` + - `Use one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`, + `Use one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (or aliases 1080p, 4k, uhd).`, ), ); process.exit(1); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index a8144c168..5e05ff1ad 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -220,7 +220,7 @@ export default defineCommand({ resolution: { type: "string", description: - "Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.", + "Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.", }, }, async run({ args }) { @@ -263,7 +263,7 @@ export default defineCommand({ if (!outputResolution) { errorBox( "Invalid resolution", - `Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`, + `Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (or aliases 1080p, 4k, uhd).`, ); process.exit(1); } diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts index a6552998b..fd3cce545 100644 --- a/packages/core/src/core.types.ts +++ b/packages/core/src/core.types.ts @@ -149,6 +149,8 @@ export const CANVAS_DIMENSIONS = { portrait: { width: 1080, height: 1920 }, "landscape-4k": { width: 3840, height: 2160 }, "portrait-4k": { width: 2160, height: 3840 }, + square: { width: 1080, height: 1080 }, + "square-4k": { width: 2160, height: 2160 }, } as const; // Single source of truth: derive the type from the table so adding a preset @@ -172,6 +174,9 @@ const RESOLUTION_ALIASES: Record = { "4k": "landscape-4k", uhd: "landscape-4k", "4k-portrait": "portrait-4k", + "1080p-square": "square", + "square-1080p": "square", + "4k-square": "square-4k", }; /** diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index bae8a15aa..2e406b5c2 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -10,6 +10,8 @@ describe("@hyperframes/core public API exports", () => { expect(core.CANVAS_DIMENSIONS.portrait).toEqual({ width: 1080, height: 1920 }); expect(core.CANVAS_DIMENSIONS["landscape-4k"]).toEqual({ width: 3840, height: 2160 }); expect(core.CANVAS_DIMENSIONS["portrait-4k"]).toEqual({ width: 2160, height: 3840 }); + expect(core.CANVAS_DIMENSIONS.square).toEqual({ width: 1080, height: 1080 }); + expect(core.CANVAS_DIMENSIONS["square-4k"]).toEqual({ width: 2160, height: 2160 }); }); it("exports VALID_CANVAS_RESOLUTIONS derived from CANVAS_DIMENSIONS", () => { @@ -18,6 +20,8 @@ describe("@hyperframes/core public API exports", () => { "portrait", "landscape-4k", "portrait-4k", + "square", + "square-4k", ]); }); @@ -27,6 +31,10 @@ describe("@hyperframes/core public API exports", () => { expect(core.normalizeResolutionFlag("1080p")).toBe("landscape"); expect(core.normalizeResolutionFlag("landscape-4k")).toBe("landscape-4k"); expect(core.normalizeResolutionFlag("UHD")).toBe("landscape-4k"); + expect(core.normalizeResolutionFlag("square")).toBe("square"); + expect(core.normalizeResolutionFlag("square-4k")).toBe("square-4k"); + expect(core.normalizeResolutionFlag("1080p-square")).toBe("square"); + expect(core.normalizeResolutionFlag("4k-square")).toBe("square-4k"); expect(core.normalizeResolutionFlag("8k")).toBeUndefined(); expect(core.normalizeResolutionFlag(undefined)).toBeUndefined(); }); diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts index b913deb5b..e02c5fd3f 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -275,10 +275,7 @@ describe("parseHtml", () => { expect(result.resolution).toBe("landscape"); }); - it("classifies square compositions as portrait by convention", () => { - // 1080×1080 has no obvious orientation. The parser collapses the tie to - // portrait — same bias the prior `w > h ? landscape : portrait` ternary - // had. Pinning so a future refactor doesn't silently flip it. + it("infers square resolution from equal width/height", () => { const html = ` @@ -290,7 +287,22 @@ describe("parseHtml", () => { `; const result = parseHtml(html); - expect(result.resolution).toBe("portrait"); + expect(result.resolution).toBe("square"); + }); + + it("infers square-4k from equal width/height ≥ 2160", () => { + const html = ` + + +
+
Hello
+
+ + + `; + const result = parseHtml(html); + + expect(result.resolution).toBe("square-4k"); }); it("extracts x, y, scale, opacity from data attributes", () => { diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index ba4c65ffb..554621cd3 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -124,7 +124,9 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null { resolutionAttr === "landscape" || resolutionAttr === "portrait" || resolutionAttr === "landscape-4k" || - resolutionAttr === "portrait-4k" + resolutionAttr === "portrait-4k" || + resolutionAttr === "square" || + resolutionAttr === "square-4k" ) { return resolutionAttr; } @@ -143,17 +145,17 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null { } function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution { - // `width === height` (square) falls into the portrait branch by convention — - // the same bias the previous `w > h ? landscape : portrait` ternary used. - // Square compositions are rare; pick portrait-as-default so we don't surprise - // the existing call sites that depend on this behavior. - const isLandscape = width > height; const longSide = Math.max(width, height); - // UHD cutoff is the long side of `landscape-4k` / `portrait-4k` (3840). A - // looser threshold (e.g. ≥ 2560) would silently misclassify QHD/1440p - // (2560×1440) as 4K, which is the wrong default for a common authoring - // resolution closer to 1080p than to UHD. Authors who genuinely want the - // 4K preset can still set `data-resolution="landscape-4k"` explicitly. + // UHD cutoff is the long side of the 4K presets (3840 for `landscape-4k` / + // `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. ≥ 2560) + // would silently misclassify QHD/1440p (2560×1440) as 4K, which is the + // wrong default for a common authoring resolution closer to 1080p than to + // UHD. Authors who genuinely want the 4K preset can still set + // `data-resolution="..."` explicitly. + if (width === height) { + return longSide >= 2160 ? "square-4k" : "square"; + } + const isLandscape = width > height; const isUhd = longSide >= 3840; if (isLandscape) return isUhd ? "landscape-4k" : "landscape"; return isUhd ? "portrait-4k" : "portrait"; diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 3dc0b6d59..344e55c03 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -839,4 +839,37 @@ describe("resolveDeviceScaleFactor", () => { }), ).toThrow(/aspect ratio|non-integer/); }); + + it("returns 1 for a square comp matching the square preset", () => { + expect( + resolveDeviceScaleFactor({ + ...defaults, + compositionWidth: 1080, + compositionHeight: 1080, + outputResolution: "square", + }), + ).toBe(1); + }); + + it("returns 2 for square 1080 → square-4k", () => { + expect( + resolveDeviceScaleFactor({ + ...defaults, + compositionWidth: 1080, + compositionHeight: 1080, + outputResolution: "square-4k", + }), + ).toBe(2); + }); + + it("rejects landscape preset on a square composition", () => { + expect(() => + resolveDeviceScaleFactor({ + ...defaults, + compositionWidth: 1080, + compositionHeight: 1080, + outputResolution: "landscape", + }), + ).toThrow(/aspect ratio/); + }); }); diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index d264c796c..ff4860e76 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -19,9 +19,9 @@ interface RenderQueueProps { ) => void; isRendering: boolean; /** - * Authored dimensions of the active composition, used to derive - * landscape vs portrait when the user picks a 1080p or 4K scale. - * `null` falls back to landscape (legacy default). + * Authored dimensions of the active composition. Used to pick the + * matching preset (landscape / portrait / square) when the user selects + * a 1080p or 4K scale. `null` falls back to landscape (legacy default). */ compositionDimensions?: CompositionDimensions | null; } @@ -39,11 +39,26 @@ const SCALE_LABEL: Record = { "4k": "4K", }; -function isPortraitComp(dims: CompositionDimensions | null | undefined): boolean { - // Squares and missing dims fall through to landscape — matches the legacy - // default ("landscape" was the first preset). The auto option exists for - // users who want exact authored dimensions. - return dims != null && dims.height > dims.width; +// Mirrors `CANVAS_DIMENSIONS` in @hyperframes/core. Studio can't import from +// the core barrel (it transitively pulls in node:fs) and the values are stable. +const CANVAS_DIMENSIONS: Record = { + landscape: { width: 1920, height: 1080 }, + portrait: { width: 1080, height: 1920 }, + "landscape-4k": { width: 3840, height: 2160 }, + "portrait-4k": { width: 2160, height: 3840 }, + square: { width: 1080, height: 1080 }, + "square-4k": { width: 2160, height: 2160 }, +}; + +type CompAspect = "landscape" | "portrait" | "square"; + +function compAspect(dims: CompositionDimensions | null | undefined): CompAspect { + // Missing dims fall through to landscape (legacy default — "landscape" was + // the first preset). Studio shows resolved dims inline, so the user can see + // when this fallback is in effect. + if (dims == null) return "landscape"; + if (dims.width === dims.height) return "square"; + return dims.height > dims.width ? "portrait" : "landscape"; } function resolveResolution( @@ -51,9 +66,13 @@ function resolveResolution( dims: CompositionDimensions | null | undefined, ): ResolutionPreset | "auto" { if (scale === "auto") return "auto"; - const portrait = isPortraitComp(dims); - if (scale === "1080p") return portrait ? "portrait" : "landscape"; - return portrait ? "portrait-4k" : "landscape-4k"; + const aspect = compAspect(dims); + if (scale === "1080p") return aspect; + return aspect === "landscape" + ? "landscape-4k" + : aspect === "portrait" + ? "portrait-4k" + : "square-4k"; } function resolvedDimensions( @@ -61,11 +80,24 @@ function resolvedDimensions( dims: CompositionDimensions | null | undefined, ): CompositionDimensions | null { if (scale === "auto") return dims ?? null; - const portrait = isPortraitComp(dims); - if (scale === "1080p") { - return portrait ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 }; - } - return portrait ? { width: 2160, height: 3840 } : { width: 3840, height: 2160 }; + const preset = resolveResolution(scale, dims); + return preset === "auto" ? null : CANVAS_DIMENSIONS[preset]; +} + +// Mirrors the producer's resolveDeviceScaleFactor validation +// (renderOrchestrator.ts:608): the chosen preset must match the comp's aspect +// ratio exactly (cross-multiplied), can't downsample, and must be an integer +// scale factor. Without this guard the user can pick a preset that throws at +// render time — e.g. 1080p on a 1080×1080 square or 1080p on a 1280×720 comp +// (1.5× isn't integer). +function scaleApplies(scale: RenderScale, dims: CompositionDimensions | null | undefined): boolean { + if (scale === "auto" || dims == null) return true; + const preset = resolveResolution(scale, dims); + if (preset === "auto") return true; + const target = CANVAS_DIMENSIONS[preset]; + if (target.width * dims.height !== target.height * dims.width) return false; + if (target.width < dims.width) return false; + return Number.isInteger(target.width / dims.width); } function scaleOptionLabel( @@ -171,6 +203,11 @@ function FormatExportButton({ const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard"); const [scale, setScale] = useState("auto"); + // If the user previously picked 1080p / 4K for a 16:9 comp and then switches + // to a square (or any non-matching) comp, fall back to "auto" without + // discarding their preference — switching back to 16:9 re-applies it. + const effectiveScale: RenderScale = scaleApplies(scale, compositionDimensions) ? scale : "auto"; + // MOV (ProRes) is a fixed-quality codec — quality selector has no effect. const showQuality = format !== "mov"; @@ -182,13 +219,13 @@ function FormatExportButton({ (feature-flag, etc.), move `rounded-l` to whichever element ends up leftmost. */}