mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(render): pre-flight aspect-ratio / alpha preset mismatch with actionable guidance (#1843)
Users pick an --resolution preset whose orientation/aspect ratio (or alpha/HDR mode) conflicts with the composition; the render fails deep in the compiler with a cryptic message. ~8K err / ~1K users. - New shared pure helper checkOutputResolutionCompatibility in @hyperframes/parsers — single source of truth for aspect/alpha/HDR/downsample/non-integer-scale constraints; suggests the matching-orientation, tier-preserving preset. - CLI render pre-flight aborts early (before browser/ffmpeg) with an actionable, fix-suggesting message; resolveDeviceScaleFactor delegates to the same helper for identical defense-in-depth messages. - Suggest (not auto-select); defers when dims can't be determined rather than guessing. - suggestMatchingPreset keys tier off the -4k suffix so square-family swaps (square + landscape-4k -> square-4k) aren't downgraded to HD. - render.js DOM polyfill made a lazy import; render.test cold-import beforeAll hooks given a 30s timeout to absorb CI contention. Render-reliability workstream P1-3. Success measured on PostHog dashboard 1783183. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c0c3abf0f1
commit
6be46813a2
+1
-1
@@ -126,7 +126,7 @@
|
||||
// Exported for render.test.ts (exported-for-tests pattern).
|
||||
{
|
||||
"file": "packages/cli/src/commands/render.ts",
|
||||
"exports": ["resolveBrowserGpuForCli", "renderLocal"],
|
||||
"exports": ["resolveBrowserGpuForCli", "renderLocal", "checkRenderResolutionPreflight"],
|
||||
},
|
||||
// captureCost.ts: constants and helpers consumed by the runCaptureCalibration
|
||||
// orchestration function and tests, but the entry-point graph doesn't
|
||||
|
||||
@@ -62,18 +62,19 @@ vi.mock("../browser/preflight.js", () => ({
|
||||
describe("renderLocal browser GPU config", () => {
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
// Pre-resolve once. The first dynamic `import("./render.js")` in this file
|
||||
// takes >5 s on Windows runners (cold module load) — long enough to blow
|
||||
// vitest's default 5 s timeout in whichever test happens to be first. When
|
||||
// that test times out, its leaked late `createRenderJob` call lands AFTER
|
||||
// the next test's `beforeEach` clears `producerState.createdJobs`, shifting
|
||||
// index 0 and corrupting unrelated assertions. Importing once in
|
||||
// `beforeAll` keeps every test fast and isolated.
|
||||
// cold-loads a heavy module graph (core + engine + producer, incl. linkedom)
|
||||
// and takes >5 s on Windows runners — and materially longer under the full
|
||||
// parallel monorepo test run, where it can exceed the default 10 s hook
|
||||
// timeout on a contended CI runner. Importing once in `beforeAll` keeps every
|
||||
// test fast and isolated; the explicit 30 s hook timeout absorbs cold-import
|
||||
// contention so this doesn't flake (the failure was a pre-existing
|
||||
// `Hook timed out in 10000ms`, reproducible on `main` under load).
|
||||
let renderLocal: typeof import("./render.js").renderLocal;
|
||||
let resolveBrowserGpuForCli: typeof import("./render.js").resolveBrowserGpuForCli;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ renderLocal, resolveBrowserGpuForCli } = await import("./render.js"));
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
function setEnv(key: string, value: string) {
|
||||
if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
|
||||
@@ -416,4 +417,74 @@ describe("renderLocal browser GPU config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkRenderResolutionPreflight", () => {
|
||||
let checkRenderResolutionPreflight: typeof import("./render.js").checkRenderResolutionPreflight;
|
||||
|
||||
// 30 s hook timeout: cold-importing render.js (heavy graph) can exceed the
|
||||
// default 10 s under parallel CI contention. See the note on the
|
||||
// "renderLocal browser GPU config" beforeAll above.
|
||||
beforeAll(async () => {
|
||||
({ checkRenderResolutionPreflight } = await import("./render.js"));
|
||||
}, 30_000);
|
||||
|
||||
// Dims must be read the same way the producer's compiler reads them:
|
||||
// `data-width` / `data-height` on the `[data-composition-id]` root.
|
||||
const comp = (w: number, h: number) =>
|
||||
`<html><body><div data-composition-id="root" data-width="${w}" data-height="${h}"></div></body></html>`;
|
||||
const portraitHtml = comp(1080, 1920);
|
||||
const landscapeHtml = comp(1920, 1080);
|
||||
const noModes = { alphaRequested: false, hdrRequested: false } as const;
|
||||
|
||||
it("returns undefined when no outputResolution is requested", async () => {
|
||||
expect(await checkRenderResolutionPreflight(portraitHtml, undefined, noModes)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the preset matches the composition orientation", async () => {
|
||||
expect(await checkRenderResolutionPreflight(portraitHtml, "portrait", noModes)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a suggestion when a landscape preset is used on a portrait composition", async () => {
|
||||
const message = await checkRenderResolutionPreflight(portraitHtml, "landscape", noModes);
|
||||
expect(message).toBeDefined();
|
||||
expect(message).toContain("--resolution portrait");
|
||||
});
|
||||
|
||||
it("suggests landscape for a landscape composition rendered with a portrait preset", async () => {
|
||||
const message = await checkRenderResolutionPreflight(landscapeHtml, "portrait", noModes);
|
||||
expect(message).toContain("--resolution landscape");
|
||||
});
|
||||
|
||||
it("preserves the 4K tier when suggesting a matching preset (square comp + landscape-4k → square-4k)", async () => {
|
||||
// Tier-aware suggestion is the load-bearing new behavior; square-4k is the
|
||||
// preset that only surfaces via a same-tier swap, so guard it explicitly.
|
||||
const message = await checkRenderResolutionPreflight(comp(2160, 2160), "landscape-4k", noModes);
|
||||
expect(message).toContain("--resolution square-4k");
|
||||
});
|
||||
|
||||
it("does not false-abort a landscape registry-block composition (data-width/height, no data-resolution)", async () => {
|
||||
// Regression guard: registry blocks carry data-width/height and no
|
||||
// data-resolution — a preset-snapping heuristic would misread this as
|
||||
// portrait and wrongly reject the correct --resolution landscape.
|
||||
expect(
|
||||
await checkRenderResolutionPreflight(landscapeHtml, "landscape", noModes),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flags alpha output combined with outputResolution", async () => {
|
||||
const message = await checkRenderResolutionPreflight(landscapeHtml, "landscape-4k", {
|
||||
alphaRequested: true,
|
||||
hdrRequested: false,
|
||||
});
|
||||
expect(message).toContain("alpha output");
|
||||
});
|
||||
|
||||
it("returns undefined when composition dimensions can't be determined (defers to the pipeline)", async () => {
|
||||
// No [data-composition-id] root / no data-width/height → defer, never guess.
|
||||
expect(await checkRenderResolutionPreflight("", "landscape", noModes)).toBeUndefined();
|
||||
expect(
|
||||
await checkRenderResolutionPreflight("<html><body></body></html>", "landscape", noModes),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Variables-helper tests live in `../utils/variables.test.ts`.
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
} from "@hyperframes/engine";
|
||||
import {
|
||||
normalizeResolutionFlag,
|
||||
checkOutputResolutionCompatibility,
|
||||
parseFps,
|
||||
fpsToNumber,
|
||||
fpsToFfmpegArg,
|
||||
@@ -786,6 +787,36 @@ export default defineCommand({
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pre-flight: output-resolution vs composition compatibility ────────
|
||||
// Catch a preset whose orientation/aspect ratio (or alpha/HDR mode)
|
||||
// conflicts with the composition BEFORE the browser and ffmpeg spin up —
|
||||
// otherwise this surfaces cryptically deep inside the render compiler
|
||||
// (resolveDeviceScaleFactor). Best-effort: a composition we can't read or
|
||||
// whose dimensions aren't a known preset falls through to the pipeline's
|
||||
// own defense-in-depth check rather than blocking a render we can't reason
|
||||
// about. See render-reliability workstream P1-3.
|
||||
if (outputResolution) {
|
||||
let resolutionIssue: string | undefined;
|
||||
try {
|
||||
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
|
||||
resolutionIssue = await checkRenderResolutionPreflight(
|
||||
readFileSync(renderTarget, "utf8"),
|
||||
outputResolution,
|
||||
{
|
||||
alphaRequested: format === "webm" || format === "mov" || format === "png-sequence",
|
||||
hdrRequested: args.hdr ?? false,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Unreadable file is non-fatal here — the render pipeline will surface
|
||||
// the real problem with full context.
|
||||
}
|
||||
if (resolutionIssue) {
|
||||
errorBox("Output resolution incompatible", resolutionIssue);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate HDR/SDR mutual exclusion ────────────────────────────────
|
||||
if (args.hdr && args.sdr) {
|
||||
console.error("Error: --hdr and --sdr are mutually exclusive.");
|
||||
@@ -993,6 +1024,71 @@ export function resolveBrowserGpuForCli(
|
||||
return "auto";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a composition's dimensions from the SAME source the producer's compiler
|
||||
* uses — `data-width` / `data-height` on the `[data-composition-id]` root (see
|
||||
* htmlCompiler.ts). Returns `undefined` when they can't be determined (no root,
|
||||
* missing/invalid attrs, unparseable HTML). Note the producer *defaults* a
|
||||
* missing attr to 1080; this pre-flight deliberately defers instead (returns
|
||||
* `undefined`) rather than guess a dimension the author didn't declare, so it
|
||||
* never false-aborts — the producer's defense-in-depth still catches that case.
|
||||
*
|
||||
* Deriving dims any other way (e.g. `data-resolution` or a `#stage` heuristic)
|
||||
* risks disagreeing with the actual render: most compositions (all registry
|
||||
* blocks) carry `data-width/height` and no `data-resolution`, so a parallel
|
||||
* heuristic could false-abort a valid render. `DOMParser` isn't shipped by
|
||||
* Node — the CLI polyfills it via linkedom, imported lazily so the heavy DOM
|
||||
* library stays out of `render.js`'s module-load graph (it cold-imports at
|
||||
* >5 s already; a static linkedom import tips the render test suite's import
|
||||
* hook over its timeout — see the note on `renderLocal browser GPU config`).
|
||||
*/
|
||||
async function readCompositionDimensions(
|
||||
compositionHtml: string,
|
||||
): Promise<{ width: number; height: number } | undefined> {
|
||||
try {
|
||||
const { ensureDOMParser } = await import("../utils/dom.js");
|
||||
ensureDOMParser();
|
||||
const doc = new DOMParser().parseFromString(compositionHtml, "text/html");
|
||||
const rootEl = doc.querySelector("[data-composition-id]");
|
||||
const width = parseInt(rootEl?.getAttribute("data-width") ?? "", 10);
|
||||
const height = parseInt(rootEl?.getAttribute("data-height") ?? "", 10);
|
||||
if (width > 0 && height > 0) return { width, height };
|
||||
} catch {
|
||||
// Unreadable / unparseable composition — fall through to `undefined`.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render pre-flight: return an actionable message when the chosen
|
||||
* `outputResolution` preset is incompatible with the composition's
|
||||
* orientation/aspect ratio, or with the alpha/HDR mode — or `undefined` when
|
||||
* the combination is fine (or can't be determined statically).
|
||||
*
|
||||
* Extracted (and exported) so the CLI wiring around `process.exit` stays a
|
||||
* thin adapter and the branch logic is unit-testable. See render-reliability
|
||||
* workstream P1-3.
|
||||
*/
|
||||
export async function checkRenderResolutionPreflight(
|
||||
compositionHtml: string,
|
||||
outputResolution: CanvasResolution | undefined,
|
||||
modes: { alphaRequested: boolean; hdrRequested: boolean },
|
||||
): Promise<string | undefined> {
|
||||
if (!outputResolution) return undefined;
|
||||
const dims = await readCompositionDimensions(compositionHtml);
|
||||
// Couldn't determine the composition's actual dimensions — defer to the
|
||||
// pipeline's own defense-in-depth check rather than guess.
|
||||
if (!dims) return undefined;
|
||||
const compat = checkOutputResolutionCompatibility({
|
||||
compositionWidth: dims.width,
|
||||
compositionHeight: dims.height,
|
||||
outputResolution,
|
||||
alphaRequested: modes.alphaRequested,
|
||||
hdrRequested: modes.hdrRequested,
|
||||
});
|
||||
return compat.ok ? undefined : compat.message;
|
||||
}
|
||||
|
||||
const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
|
||||
|
||||
function dockerImageTag(version: string): string {
|
||||
|
||||
@@ -179,6 +179,7 @@ export {
|
||||
CANVAS_DIMENSIONS,
|
||||
VALID_CANVAS_RESOLUTIONS,
|
||||
normalizeResolutionFlag,
|
||||
checkOutputResolutionCompatibility,
|
||||
COMPOSITION_VARIABLE_TYPES,
|
||||
TIMELINE_COLORS,
|
||||
DEFAULT_DURATIONS,
|
||||
@@ -187,3 +188,7 @@ export {
|
||||
isMediaElement,
|
||||
isCompositionElement,
|
||||
} from "@hyperframes/parsers";
|
||||
export type {
|
||||
OutputResolutionCompatibility,
|
||||
OutputResolutionIssueKind,
|
||||
} from "@hyperframes/parsers";
|
||||
|
||||
@@ -35,6 +35,8 @@ export type {
|
||||
CompositionVariable,
|
||||
CompositionSpec,
|
||||
WaveformData,
|
||||
OutputResolutionCompatibility,
|
||||
OutputResolutionIssueKind,
|
||||
} from "./core.types";
|
||||
|
||||
export type {
|
||||
@@ -53,6 +55,7 @@ export {
|
||||
CANVAS_DIMENSIONS,
|
||||
VALID_CANVAS_RESOLUTIONS,
|
||||
normalizeResolutionFlag,
|
||||
checkOutputResolutionCompatibility,
|
||||
parseFps,
|
||||
parseFpsWithDefault,
|
||||
toFps,
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./gsapParserExports.js";
|
||||
export * from "./htmlParser.js";
|
||||
export * from "./hfIds.js";
|
||||
export * from "./subCompositionValidity.js";
|
||||
export * from "./outputResolutionCompatibility.js";
|
||||
export { unrollComputedTimeline } from "./gsapUnroll.js";
|
||||
export { queryByAttr } from "./utils/cssSelector.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkOutputResolutionCompatibility } from "./outputResolutionCompatibility.js";
|
||||
|
||||
describe("checkOutputResolutionCompatibility", () => {
|
||||
it("returns ok when no outputResolution is requested", () => {
|
||||
expect(
|
||||
checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1920,
|
||||
outputResolution: undefined,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("returns ok when the preset matches the composition exactly", () => {
|
||||
expect(
|
||||
checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape",
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok when supersampling by an integer factor (same aspect)", () => {
|
||||
// 1920×1080 composition, 4K landscape preset (3840×2160) → 2× DPR.
|
||||
expect(
|
||||
checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape-4k",
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("aspect-ratio mismatch (the dominant P1-3 failure)", () => {
|
||||
it("flags a landscape preset against a portrait composition and suggests portrait", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1920,
|
||||
outputResolution: "landscape",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("aspect-mismatch");
|
||||
expect(result.suggestedResolution).toBe("portrait");
|
||||
expect(result.message).toContain("does not match the aspect ratio");
|
||||
expect(result.message).toContain("--resolution portrait");
|
||||
});
|
||||
|
||||
it("preserves the 4K tier when suggesting a swap", () => {
|
||||
// portrait composition + landscape-4k preset → suggest portrait-4k, not portrait.
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1920,
|
||||
outputResolution: "landscape-4k",
|
||||
});
|
||||
expect(result.suggestedResolution).toBe("portrait-4k");
|
||||
expect(result.message).toContain("--resolution portrait-4k");
|
||||
});
|
||||
|
||||
it("flags a portrait preset against a landscape composition and suggests landscape", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "portrait",
|
||||
});
|
||||
expect(result.suggestedResolution).toBe("landscape");
|
||||
});
|
||||
|
||||
it("does not suggest a preset for a custom aspect ratio with no preset match", () => {
|
||||
// 2000×1000 (2:1) has no matching preset → no unambiguous swap.
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 2000,
|
||||
compositionHeight: 1000,
|
||||
outputResolution: "portrait",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("aspect-mismatch");
|
||||
expect(result.suggestedResolution).toBeUndefined();
|
||||
expect(result.message).toContain("omit --resolution");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha / HDR incompatibility", () => {
|
||||
it("flags alpha output combined with outputResolution", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape-4k",
|
||||
alphaRequested: true,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("alpha-incompatible");
|
||||
expect(result.message).toContain("alpha output");
|
||||
expect(result.message).toContain("--format mp4");
|
||||
});
|
||||
|
||||
it("flags HDR combined with outputResolution", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape-4k",
|
||||
hdrRequested: true,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("hdr-incompatible");
|
||||
expect(result.message).toContain("hdrMode='force-hdr'");
|
||||
});
|
||||
|
||||
it("prioritizes HDR over aspect mismatch when both are wrong", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1920,
|
||||
outputResolution: "landscape",
|
||||
hdrRequested: true,
|
||||
});
|
||||
expect(result.kind).toBe("hdr-incompatible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scale constraints", () => {
|
||||
it("flags downsampling (preset smaller than composition)", () => {
|
||||
// 3840×2160 composition, landscape (1920×1080) preset → 0.5× DPR.
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 3840,
|
||||
compositionHeight: 2160,
|
||||
outputResolution: "landscape",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("downsampling");
|
||||
});
|
||||
|
||||
it("flags a non-integer scale factor for a same-aspect custom composition", () => {
|
||||
// 1000×1000 (square aspect) + square preset (1080×1080) → 1.08× DPR.
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1000,
|
||||
compositionHeight: 1000,
|
||||
outputResolution: "square",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("non-integer-scale");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Shared "is this `outputResolution` preset compatible with the composition?"
|
||||
* check.
|
||||
*
|
||||
* The `--resolution` render flag (a `CanvasResolution` preset) is chosen
|
||||
* independently of the composition it renders, so a portrait composition
|
||||
* (1080×1920) rendered with `--resolution landscape` (1920×1080) is a common
|
||||
* mistake — especially from AI agents that pick a preset by habit rather than
|
||||
* by inspecting the composition. Historically this surfaced as a cryptic
|
||||
* `Error` thrown deep inside the render compiler (`resolveDeviceScaleFactor`
|
||||
* in `@hyperframes/producer`), after the browser and ffmpeg had already spun
|
||||
* up, with a message that named the mismatch but not the fix.
|
||||
*
|
||||
* This module gives every consumer (the render pre-flight in the CLI, the
|
||||
* producer's `resolveDeviceScaleFactor`, and any future lint rule) a single,
|
||||
* dependency-free definition of "is this preset usable for this composition"
|
||||
* — including a suggested preset when there's an unambiguously-correct swap —
|
||||
* so the same check can run *before* a render is attempted (loud, actionable,
|
||||
* cheap) and again as defense-in-depth inside the pipeline.
|
||||
*
|
||||
* It lives in `@hyperframes/parsers` (rather than `@hyperframes/core`) because
|
||||
* both `core`/`producer` and `lint` may need it, and `lint` cannot depend on
|
||||
* `core`. The geometry it needs (`CANVAS_DIMENSIONS`) already lives here.
|
||||
*/
|
||||
|
||||
import { CANVAS_DIMENSIONS, VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "./types.js";
|
||||
|
||||
export type OutputResolutionIssueKind =
|
||||
| "hdr-incompatible"
|
||||
| "alpha-incompatible"
|
||||
| "aspect-mismatch"
|
||||
| "downsampling"
|
||||
| "non-integer-scale";
|
||||
|
||||
export interface OutputResolutionCompatibility {
|
||||
ok: boolean;
|
||||
/** Present when `ok` is false. */
|
||||
kind?: OutputResolutionIssueKind;
|
||||
/** Human-readable, actionable message suitable for direct display. */
|
||||
message?: string;
|
||||
/**
|
||||
* A preset whose orientation/aspect ratio matches the composition, when one
|
||||
* exists and is an unambiguous swap for the user's intent. Present only for
|
||||
* `aspect-mismatch`. Consumers may surface this as a suggestion ("did you
|
||||
* mean `--resolution portrait`?"); it is intentionally *not* auto-applied —
|
||||
* silently swapping a user-supplied flag changes their stated intent.
|
||||
*/
|
||||
suggestedResolution?: CanvasResolution;
|
||||
}
|
||||
|
||||
const OK: OutputResolutionCompatibility = { ok: true };
|
||||
|
||||
/**
|
||||
* Find the preset that shares the composition's aspect ratio and resolution
|
||||
* tier (HD vs 4K) as the user's chosen preset. E.g. a portrait composition
|
||||
* with `--resolution landscape-4k` suggests `portrait-4k`, not `portrait`,
|
||||
* preserving the user's intent to render at 4K while fixing the orientation.
|
||||
*
|
||||
* Returns `undefined` when no preset matches the composition's aspect ratio
|
||||
* (e.g. a custom, non-preset composition aspect ratio) — in that case there
|
||||
* is no unambiguous swap to suggest.
|
||||
*/
|
||||
function suggestMatchingPreset(
|
||||
compositionWidth: number,
|
||||
compositionHeight: number,
|
||||
chosen: CanvasResolution,
|
||||
): CanvasResolution | undefined {
|
||||
const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) => {
|
||||
const { width, height } = CANVAS_DIMENSIONS[preset];
|
||||
// Integer-safe aspect compare (cross-multiplication), matching the
|
||||
// producer's mismatch check exactly.
|
||||
return width * compositionHeight === height * compositionWidth;
|
||||
});
|
||||
if (aspectMatches.length === 0) return undefined;
|
||||
|
||||
// Prefer the aspect-matching preset in the same resolution tier as the chosen
|
||||
// one so we don't silently downgrade 4K → HD (or vice versa). Tier is keyed
|
||||
// off the `-4k` suffix rather than long-side pixels: a 4K *square* (2160×2160)
|
||||
// has a shorter long side than 4K landscape (3840), so a pixel-based compare
|
||||
// would fail to recognise `square-4k` as the 4K peer and downgrade to `square`.
|
||||
const chosenIs4k = chosen.endsWith("-4k");
|
||||
const sameTier = aspectMatches.find((preset) => preset.endsWith("-4k") === chosenIs4k);
|
||||
return sameTier ?? aspectMatches[0];
|
||||
}
|
||||
|
||||
function describeOrientation(width: number, height: number): string {
|
||||
if (width > height) return "landscape";
|
||||
if (width < height) return "portrait";
|
||||
return "square";
|
||||
}
|
||||
|
||||
/** Build the aspect-ratio-mismatch result, including a preset suggestion. */
|
||||
function buildAspectMismatch(
|
||||
compositionWidth: number,
|
||||
compositionHeight: number,
|
||||
outputResolution: CanvasResolution,
|
||||
target: { width: number; height: number },
|
||||
): OutputResolutionCompatibility {
|
||||
const suggestedResolution = suggestMatchingPreset(
|
||||
compositionWidth,
|
||||
compositionHeight,
|
||||
outputResolution,
|
||||
);
|
||||
const suggestion = suggestedResolution
|
||||
? ` The composition is ${describeOrientation(compositionWidth, compositionHeight)} — ` +
|
||||
`use --resolution ${suggestedResolution} instead.`
|
||||
: ` Pick a preset whose orientation matches, or omit --resolution to render at the composition's native dimensions.`;
|
||||
return {
|
||||
ok: false,
|
||||
kind: "aspect-mismatch",
|
||||
suggestedResolution,
|
||||
message:
|
||||
`outputResolution ${outputResolution} (${target.width}×${target.height}) ` +
|
||||
`does not match the aspect ratio of the composition ` +
|
||||
`(${compositionWidth}×${compositionHeight}).` +
|
||||
suggestion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether rendering a composition of the given dimensions with the given
|
||||
* `outputResolution` preset (and alpha/HDR modes) is supported.
|
||||
*
|
||||
* Pure and dependency-free — the single source of truth for the constraints
|
||||
* `resolveDeviceScaleFactor` enforces, so the CLI can run the exact same check
|
||||
* as a pre-flight before any browser/ffmpeg work.
|
||||
*
|
||||
* @param outputResolution The chosen preset, or `undefined` when the render
|
||||
* uses the composition's native dimensions (always compatible).
|
||||
*/
|
||||
export function checkOutputResolutionCompatibility(input: {
|
||||
compositionWidth: number;
|
||||
compositionHeight: number;
|
||||
outputResolution: CanvasResolution | undefined;
|
||||
alphaRequested?: boolean;
|
||||
hdrRequested?: boolean;
|
||||
}): OutputResolutionCompatibility {
|
||||
const { compositionWidth, compositionHeight, outputResolution } = input;
|
||||
if (!outputResolution) return OK;
|
||||
|
||||
if (input.hdrRequested) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "hdr-incompatible",
|
||||
message:
|
||||
`outputResolution cannot be combined with hdrMode='force-hdr'. ` +
|
||||
`HDR rendering composites at composition dimensions and does not yet ` +
|
||||
`support supersampling. Pick one or render in two passes.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.alphaRequested) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "alpha-incompatible",
|
||||
message:
|
||||
`outputResolution cannot be combined with alpha output (--format webm|mov|png-sequence). ` +
|
||||
`The alpha screenshot path does not yet apply deviceScaleFactor and would silently ` +
|
||||
`produce composition-resolution frames. Render alpha at composition resolution and ` +
|
||||
`upscale separately, or use --format mp4.`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = CANVAS_DIMENSIONS[outputResolution];
|
||||
// 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 * compositionHeight !== target.height * compositionWidth) {
|
||||
return buildAspectMismatch(compositionWidth, compositionHeight, outputResolution, target);
|
||||
}
|
||||
|
||||
// Aspect ratios match → widthRatio === heightRatio. Compute once.
|
||||
const widthRatio = target.width / compositionWidth;
|
||||
if (widthRatio < 1) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "downsampling",
|
||||
message:
|
||||
`outputResolution ${outputResolution} (${target.width}×${target.height}) ` +
|
||||
`is smaller than the composition (${compositionWidth}×${compositionHeight}). ` +
|
||||
`Downsampling via --resolution is not supported.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(widthRatio)) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "non-integer-scale",
|
||||
message:
|
||||
`outputResolution ${outputResolution} requires a non-integer ` +
|
||||
`device scale factor (${widthRatio}×) to upsample from ` +
|
||||
`${compositionWidth}×${compositionHeight}. ` +
|
||||
`Pick a preset that's an integer multiple, or rescale the composition.`,
|
||||
};
|
||||
}
|
||||
|
||||
return OK;
|
||||
}
|
||||
@@ -12,7 +12,11 @@
|
||||
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
|
||||
import {
|
||||
CANVAS_DIMENSIONS,
|
||||
checkOutputResolutionCompatibility,
|
||||
type CanvasResolution,
|
||||
} from "@hyperframes/core";
|
||||
import type {
|
||||
AudioElement,
|
||||
ExtractedFrames,
|
||||
@@ -94,52 +98,22 @@ export function resolveDeviceScaleFactor(input: {
|
||||
alphaRequested: boolean;
|
||||
}): number {
|
||||
if (!input.outputResolution) return 1;
|
||||
if (input.hdrRequested) {
|
||||
throw new Error(
|
||||
"outputResolution cannot be combined with hdrMode='force-hdr'. " +
|
||||
"HDR rendering composites at composition dimensions and does not yet " +
|
||||
"support supersampling. Pick one or render in two passes.",
|
||||
);
|
||||
}
|
||||
if (input.alphaRequested) {
|
||||
throw new Error(
|
||||
"outputResolution cannot be combined with alpha output (--format webm|mov|png-sequence). " +
|
||||
"The alpha screenshot path does not yet apply deviceScaleFactor and would silently " +
|
||||
"produce composition-resolution frames. Render alpha at composition resolution and " +
|
||||
"upscale separately, or use --format mp4.",
|
||||
);
|
||||
}
|
||||
// Single source of truth for the aspect/alpha/HDR/scale constraints, shared
|
||||
// with the CLI render pre-flight so both raise the identical, actionable
|
||||
// message. This is the deep defense-in-depth throw; the pre-flight aborts
|
||||
// long before this runs on the common (aspect/alpha) mistakes.
|
||||
const compat = checkOutputResolutionCompatibility({
|
||||
compositionWidth: input.compositionWidth,
|
||||
compositionHeight: input.compositionHeight,
|
||||
outputResolution: input.outputResolution,
|
||||
alphaRequested: input.alphaRequested,
|
||||
hdrRequested: input.hdrRequested,
|
||||
});
|
||||
if (!compat.ok) throw new Error(compat.message);
|
||||
|
||||
const target = CANVAS_DIMENSIONS[input.outputResolution];
|
||||
// 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 ` +
|
||||
`(${input.compositionWidth}×${input.compositionHeight}). ` +
|
||||
`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}) ` +
|
||||
`is smaller than the composition (${input.compositionWidth}×${input.compositionHeight}). ` +
|
||||
`Downsampling via --resolution is not supported.`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(widthRatio)) {
|
||||
throw new Error(
|
||||
`outputResolution ${input.outputResolution} requires a non-integer ` +
|
||||
`device scale factor (${widthRatio}×) to upsample from ` +
|
||||
`${input.compositionWidth}×${input.compositionHeight}. ` +
|
||||
`Pick a preset that's an integer multiple, or rescale the composition.`,
|
||||
);
|
||||
}
|
||||
return widthRatio;
|
||||
return target.width / input.compositionWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1313,6 +1313,14 @@ describe("resolveDeviceScaleFactor", () => {
|
||||
).toThrow(/aspect ratio/);
|
||||
});
|
||||
|
||||
it("suggests the matching-orientation preset in the aspect-mismatch message", () => {
|
||||
// Landscape composition + portrait preset → the message should point at
|
||||
// the landscape swap so the user isn't left to guess (workstream P1-3).
|
||||
expect(() => resolveDeviceScaleFactor({ ...defaults, outputResolution: "portrait" })).toThrow(
|
||||
/--resolution landscape/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects downsampling (4K composition → 1080p output)", () => {
|
||||
expect(() =>
|
||||
resolveDeviceScaleFactor({
|
||||
|
||||
Reference in New Issue
Block a user