diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index fa2bcc0ae..28a5fc7ac 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -207,6 +207,35 @@ describe("renderLocal browser GPU config", () => { expect(producerState.createdJobs[0]?.entryFile).toBeUndefined(); }); + it("forwards outputResolution to createRenderJob when --resolution is set", async () => { + await renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: 30, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "software", + hdrMode: "auto", + quiet: true, + outputResolution: "landscape-4k", + }); + + expect(producerState.createdJobs[0]?.outputResolution).toBe("landscape-4k"); + }); + + it("omits outputResolution from createRenderJob by default", async () => { + await renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: 30, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "software", + hdrMode: "auto", + quiet: true, + }); + + expect(producerState.createdJobs[0]?.outputResolution).toBeUndefined(); + }); + it("can force the CLI process to exit after a successful local render", async () => { vi.useFakeTimers(); const exit = vi diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 574f9115a..4da323eb6 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -253,6 +253,18 @@ export default defineCommand({ ); process.exit(1); } + // Reject the --resolution + --hdr combination at the CLI layer so the + // user sees the friendly errorBox before any work directories or + // ffmpeg processes spin up. The orchestrator also enforces this via + // resolveDeviceScaleFactor — defense in depth. + if (args.hdr) { + errorBox( + "Conflicting flags", + "--resolution cannot be combined with --hdr. The HDR pipeline composites at composition dimensions and does not yet support supersampling.", + "Render in two passes: HDR at composition resolution, then upscale separately with ffmpeg.", + ); + process.exit(1); + } } // ── Validate workers ────────────────────────────────────────────────── @@ -369,7 +381,11 @@ export default defineCommand({ ); console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel)); if (outputResolution) { - console.log(c.dim(" Output resolution: " + outputResolution + " (supersampled via DPR)")); + // Don't claim "supersampled" — when the composition is already at the + // target dimensions, the DPR resolves to 1 and no supersampling + // happens. We don't have the composition's dims at this point in the + // CLI, so describe the intent rather than the mechanism. + console.log(c.dim(" Output resolution: " + outputResolution)); } if (useGpu || browserGpuMode !== "software") { const gpuModes = [ diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index de8aa78d2..cdc3c705a 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -616,9 +616,11 @@ export function resolveDeviceScaleFactor(input: { ); } const target = CANVAS_DIMENSIONS[input.outputResolution]; - const widthRatio = target.width / input.compositionWidth; - const heightRatio = target.height / input.compositionHeight; - if (widthRatio !== heightRatio) { + // Aspect-ratio compare via cross-multiplication so the equality is integer- + // safe. Float division (`target.width / compositionWidth`) loses precision + // for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a + // future preset could trip a false-mismatch on otherwise valid input. + if (target.width * input.compositionHeight !== target.height * input.compositionWidth) { throw new Error( `outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` + `does not match the aspect ratio of the composition ` + @@ -626,6 +628,8 @@ export function resolveDeviceScaleFactor(input: { `Pick a preset whose orientation matches.`, ); } + // Aspect ratios match → widthRatio === heightRatio. Compute once. + const widthRatio = target.width / input.compositionWidth; if (widthRatio < 1) { throw new Error( `outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` +