Merge pull request #2529 from heygen-com/via/resolution-portrait-fix

fix(cli): accept portrait aspects for --resolution alias flag
This commit is contained in:
Vance Ingalls
2026-07-16 01:54:55 -07:00
committed by GitHub
25 changed files with 1189 additions and 74 deletions
+53
View File
@@ -469,6 +469,29 @@
// utils/compositionServer.ts; the remaining clone is per-command logging text
// (different labels/help lines) — extracting it would over-abstract.
"packages/cli/src/commands/present.ts",
// Portrait --resolution alias fix (via/resolution-portrait-fix):
// cloudrun.ts and lambda.ts are intentionally symmetric per-adapter
// dispatchers — same subcommand surface (deploy / render / render-batch /
// progress / destroy), same argument parsers (parseFormat / parseCodec /
// parseQuality / parsePositiveInt), same wire-config shape. The 390-line
// cross-file clone is that pre-existing structural symmetry; the shared
// resolution-flag parse now lives in utils/parseOutputResolution.ts, but
// consolidating the per-adapter dispatcher body further would collapse
// two distinct SDK surfaces (AWS + GCP) into a single verb router that
// future adapters (Azure, etc.) would have to fork back out of.
// Line-shift fingerprint after adding `outputResolutionAspectAgnostic`
// threading re-flags the inherited clones.
"packages/cli/src/commands/cloudrun.ts",
"packages/cli/src/commands/lambda.ts",
// lambda/render.ts and lambda/render-batch.ts declare parallel
// RenderArgs / RenderBatchArgs interfaces (same core render knobs, with
// batch-only extras like maxConcurrent / dryRun). Extracting the shared
// subset into a base interface would force every consumer to spell out
// the intersection at every call site; the current shape is
// intent-preserving. Pre-existing dupe, re-flagged after threading the
// aspect-agnostic field through both interfaces.
"packages/cli/src/commands/lambda/render.ts",
"packages/cli/src/commands/lambda/render-batch.ts",
// skillsManifest.test.ts: parallel arrange/act/assert cases for locateInstall
// (project vs global scope, per-agent host conventions, claude-code priority).
// Each case seeds a dir then asserts the resolved location/agent; collapsing
@@ -743,6 +766,36 @@
// work on this same branch (commits 444639d75, b57b31beb, 6f2e9848c,
// eba8a0fa2), unrelated to the Grade group (Plan 5) currently landing.
"packages/studio/src/components/editor/propertyPanelSections.tsx",
// Portrait --resolution alias fix (via/resolution-portrait-fix):
// server.ts `render` (cyclo 10 / CRAP 31.6) and distributed/plan.ts
// `plan` (cyclo 33 / CRAP 36.7) are both pre-existing complexity —
// the PR only threads `outputResolutionAspectAgnostic` through the
// parseRenderOverrides/RenderInput/DistributedRenderConfig shape and
// adds one field spread inside `plan`. Neither function body gained
// branches, but the line-shift fingerprint re-flags the inherited
// complexity. The new re-target logic itself is extracted into
// `adaptAspectAgnosticResolution` in compileStage.ts to keep that
// stage's runCompileStage under the cyclo/cognitive thresholds.
"packages/producer/src/server.ts",
"packages/producer/src/services/distributed/plan.ts",
// Sibling-surface fix (PR #2529 R2): lambda.ts's top-level `run`
// (cyclo 39, CRAP 1560) is the big subcommand switch that pre-dates
// this PR. The change threads two additional variables through the
// `render` and `render-batch` branches (parsed resolution +
// aspect-agnostic flag) but adds no new branches. parseIntFlag /
// parseEnum (both cyclo 5, CRAP 30 — right at the threshold) are also
// pre-existing utility parsers; the file-level line shift after
// adding the shared parseOutputResolutionFlag call re-flags them at
// the boundary. All three findings are inherited complexity, not new
// branches introduced by the aspect-agnostic threading.
"packages/cli/src/commands/lambda.ts",
// Sibling-surface fix (PR #2529 R2): lambda/render.ts's
// `waitForCompletion` (cyclo 11, CRAP 37.1) is the pre-existing SFN
// progress-poll loop. This PR only adds `outputResolutionAspectAgnostic`
// to `RenderArgs` + a two-line extraction (`buildLambdaRenderConfig`);
// `waitForCompletion` is untouched. Line-shift fingerprint re-flags
// the inherited complexity.
"packages/cli/src/commands/lambda/render.ts",
],
},
}
@@ -0,0 +1,66 @@
/**
* Boundary test for the `hyperframes cloudrun render{,-batch}` wire config.
* Pins that the aspect-agnostic flag reaches `SerializableDistributedRenderConfig`
* so the Cloud Run worker's compile stage can remap `landscape` → `portrait`.
*
* The parse helper itself is covered at `../utils/parseOutputResolution.test.ts`;
* we only re-check the entrypoint composition here.
*/
import { describe, expect, it } from "vitest";
import { buildRenderConfig } from "./cloudrun.js";
describe("cloudrun wire config — aspect-agnostic threading", () => {
const baseArgs: Record<string, unknown> = {
format: "mp4",
codec: undefined,
quality: undefined,
"chunk-size": undefined,
"max-parallel-chunks": undefined,
"target-chunk-frames": undefined,
};
it("threads outputResolutionAspectAgnostic=true through the wire config for portrait 1080p", () => {
// The exact bug shape: portrait comp + `--output-resolution 1080p`.
// Before the fix, the alias signal never reached the wire; Cloud Run
// then hit the same portrait rejection this PR set out to eliminate.
const config = buildRenderConfig(
{ ...baseArgs, "output-resolution": "1080p" },
30,
1080,
1920,
undefined,
);
expect(config.outputResolution).toBe("landscape");
expect(config.outputResolutionAspectAgnostic).toBe(true);
});
it("keeps the aspect-agnostic key absent when the flag is a canonical preset", () => {
// Sparse-wire invariant: don't broadcast `false` for the common path —
// the compile-stage remap only fires on `true`.
const config = buildRenderConfig(
{ ...baseArgs, "output-resolution": "portrait-4k" },
30,
2160,
3840,
undefined,
);
expect(config.outputResolution).toBe("portrait-4k");
expect(config).not.toHaveProperty("outputResolutionAspectAgnostic");
});
it("omits both resolution fields when --output-resolution is unset", () => {
const config = buildRenderConfig(baseArgs, 30, 1920, 1080, undefined);
expect(config).not.toHaveProperty("outputResolution");
expect(config).not.toHaveProperty("outputResolutionAspectAgnostic");
});
it("preserves the surface-labeled strict-throw contract on unknown values", () => {
// Sanity guard: the local delegate stays wired to the shared helper's
// throw semantics rather than silently downgrading to undefined. Full
// input-space coverage lives at `../utils/parseOutputResolution.test.ts`.
expect(() =>
buildRenderConfig({ "output-resolution": "8k" }, 30, 1920, 1080, undefined),
).toThrow(/\[cloudrun render\]/);
});
});
+28 -15
View File
@@ -18,11 +18,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { defineCommand } from "citty";
import {
type CanvasResolution,
normalizeResolutionFlag,
VALID_CANVAS_RESOLUTIONS,
} from "@hyperframes/core";
import { type CanvasResolution } from "@hyperframes/core";
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import {
@@ -774,13 +771,21 @@ function runDestroy(args: Record<string, unknown>): void {
* separately (it differs per batch entry). Mirrors the local `hyperframes
* render` flag surface so the two stay consistent.
*/
function buildRenderConfig(
/**
* Exported for unit-test coverage of the aspect-agnostic wire shape — the
* portrait-1080p sibling-surface regression that shipped in v0.7.60 landed
* here because this builder dropped the tier-alias signal on the floor.
*/
export function buildRenderConfig(
args: Record<string, unknown>,
fps: number,
width: number,
height: number,
variables: Record<string, unknown> | undefined,
): Record<string, unknown> {
const { outputResolution, outputResolutionAspectAgnostic } = parseOutputResolution(
args["output-resolution"],
);
return stripUndefined({
fps,
width,
@@ -791,7 +796,11 @@ function buildRenderConfig(
chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"),
maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"),
targetChunkFrames: parsePositiveInt(args["target-chunk-frames"], "--target-chunk-frames"),
outputResolution: parseOutputResolution(args["output-resolution"]),
outputResolution,
// Set only when true so the wire shape stays sparse for the common
// canonical-preset path (matches how the flag flows through
// `SerializableDistributedRenderConfig` from every other emitter).
outputResolutionAspectAgnostic: outputResolutionAspectAgnostic ? true : undefined,
variables,
});
}
@@ -823,14 +832,18 @@ function resolveAndValidateVariables(
return variables;
}
function parseOutputResolution(raw: unknown): CanvasResolution | undefined {
if (raw == null || raw === "") return undefined;
const normalized = normalizeResolutionFlag(String(raw));
if (normalized) return normalized;
throw new Error(
`[cloudrun render] --output-resolution must be one of ${VALID_CANVAS_RESOLUTIONS.join("|")} ` +
`(or an alias: 1080p, 4k, uhd, hd, …); got ${String(raw)}`,
);
/**
* Cloud Run flavor of the shared {@link parseOutputResolutionFlag} — carries
* the aspect-agnostic signal through so `SerializableDistributedRenderConfig`
* can trigger the compile-stage remap. The runtime work lives in the shared
* util; wire-config-level coverage lives at `cloudrun.test.ts`, and full
* input-space coverage at `../utils/parseOutputResolution.test.ts`.
*/
function parseOutputResolution(raw: unknown): {
outputResolution: CanvasResolution | undefined;
outputResolutionAspectAgnostic: boolean;
} {
return parseOutputResolutionFlag(raw, { surfaceLabel: "[cloudrun render]" });
}
// ── parse helpers ─────────────────────────────────────────────────────────
+27 -15
View File
@@ -10,11 +10,8 @@
import { defineCommand } from "citty";
import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk";
import {
type CanvasResolution,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
} from "@hyperframes/core";
import { type CanvasResolution } from "@hyperframes/core";
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { readAllowedCompositionFpsFromDir } from "../utils/compositionFps.js";
@@ -307,6 +304,7 @@ export default defineCommand({
process.exit(1);
}
const { runRender } = await import("./lambda/render.js");
const renderResolution = parseOutputResolution(args["output-resolution"]);
await runRender({
projectDir,
stackName,
@@ -314,7 +312,8 @@ export default defineCommand({
fps: fpsRaw,
width,
height,
outputResolution: parseOutputResolution(args["output-resolution"]),
outputResolution: renderResolution.outputResolution,
outputResolutionAspectAgnostic: renderResolution.outputResolutionAspectAgnostic,
format: parseFormat(args.format),
codec: parseCodec(args.codec),
quality: parseQuality(args.quality),
@@ -362,6 +361,7 @@ export default defineCommand({
process.exit(1);
}
const { runRenderBatch } = await import("./lambda/render-batch.js");
const batchResolution = parseOutputResolution(args["output-resolution"]);
await runRenderBatch({
projectDir,
stackName,
@@ -370,7 +370,8 @@ export default defineCommand({
fps: fpsRaw,
width,
height,
outputResolution: parseOutputResolution(args["output-resolution"]),
outputResolution: batchResolution.outputResolution,
outputResolutionAspectAgnostic: batchResolution.outputResolutionAspectAgnostic,
format: parseFormat(args.format),
codec: parseCodec(args.codec),
quality: parseQuality(args.quality),
@@ -481,12 +482,23 @@ const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined =>
const parseChromeSource = (raw: unknown): (typeof CHROME_SOURCES)[number] =>
parseEnum(raw, CHROME_SOURCES, "[lambda deploy] --chrome-source", "sparticuz")!;
function parseOutputResolution(raw: unknown): CanvasResolution | undefined {
if (raw == null || raw === "") return undefined;
const normalized = normalizeResolutionFlag(String(raw));
if (normalized) return normalized;
throw new Error(
`[lambda render] --output-resolution must be one of ${VALID_CANVAS_RESOLUTIONS.join("|")} ` +
`(or an alias: 1080p, 4k, uhd, hd, 1080p-portrait, portrait-1080p, 4k-portrait, 1080p-square, square-1080p, 4k-square); got ${String(raw)}`,
);
/**
* Lambda flavor of the shared {@link parseOutputResolutionFlag} — same wire
* contract as the Cloud Run counterpart. Runtime work lives in the shared
* util; wire-config-level coverage lives at `./lambda/render.test.ts` /
* `./lambda/render-batch.test.ts`, and full input-space coverage at
* `../utils/parseOutputResolution.test.ts`.
*/
function parseOutputResolution(raw: unknown): {
outputResolution: CanvasResolution | undefined;
outputResolutionAspectAgnostic: boolean;
} {
return parseOutputResolutionFlag(raw, {
surfaceLabel: "[lambda render]",
// The Lambda `--output-resolution` help text advertises the full alias
// list (tier-only + orientation-suffixed) — keep the error message
// faithful to that surface's docs.
aliasHint:
"1080p, 4k, uhd, hd, 1080p-portrait, portrait-1080p, 4k-portrait, 1080p-square, square-1080p, 4k-square",
});
}
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseBatchFile, runWithConcurrencyLimit } from "./render-batch.js";
import {
buildLambdaBatchRenderConfig,
parseBatchFile,
runWithConcurrencyLimit,
type RenderBatchArgs,
} from "./render-batch.js";
let tmpDir: string;
@@ -137,3 +142,38 @@ describe("parseBatchFile", () => {
expectExitOne('{"outputKey":"renders/a.mp4","variables":[1,2,3]}\n');
});
});
// See `../cloudrun.test.ts` / `./render.test.ts` for the sibling wire-config
// coverage. Repeating it at every entrypoint is deliberate: cross-scaffold
// drift is exactly what shipped PR #2529 R2 CHANGES_REQUESTED.
describe("buildLambdaBatchRenderConfig — aspect-agnostic wire threading", () => {
const baseArgs: RenderBatchArgs = {
projectDir: "/tmp/hf-batch",
stackName: "hf-test",
batch: "/tmp/batch.jsonl",
fps: 30,
width: 1080,
height: 1920,
format: "mp4",
json: false,
};
it("threads outputResolutionAspectAgnostic=true through for portrait 1080p", () => {
// The batch entrypoint fans out N Step Functions executions from a
// single wire config, so a dropped alias flag multiplies into N broken
// renders — pin it here.
const config = buildLambdaBatchRenderConfig({
...baseArgs,
outputResolution: "landscape",
outputResolutionAspectAgnostic: true,
});
expect(config.outputResolution).toBe("landscape");
expect(config.outputResolutionAspectAgnostic).toBe(true);
});
it("keeps the aspect-agnostic key absent when the flag is a canonical preset", () => {
const config = buildLambdaBatchRenderConfig({ ...baseArgs, outputResolution: "portrait-4k" });
expect(config.outputResolution).toBe("portrait-4k");
expect(config.outputResolutionAspectAgnostic).toBeUndefined();
});
});
@@ -65,6 +65,13 @@ export interface RenderBatchArgs {
height: number;
/** See {@link RenderArgs.outputResolution}. */
outputResolution?: CanvasResolution;
/**
* See {@link RenderArgs.outputResolutionAspectAgnostic}. Threaded through
* `SerializableDistributedRenderConfig` so the Lambda worker's compile
* stage remaps aspect-agnostic aliases (`1080p` / `hd` / `4k` / `uhd`) to
* the composition's orientation — same regression class as the local CLI.
*/
outputResolutionAspectAgnostic?: boolean;
format: DistributedFormat;
codec?: "h264" | "h265";
quality?: "draft" | "standard" | "high";
@@ -209,19 +216,7 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
process.exit(1);
}
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
outputResolution: args.outputResolution,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
};
const config: SerializableDistributedRenderConfig = buildLambdaBatchRenderConfig(args);
// Deploy the site once and reuse it across every entry. --site-id and
// --dry-run both skip the deploy via a synthesised handle.
@@ -350,6 +345,34 @@ function makePlaceholderSiteHandle(siteId: string, bucketName: string): SiteHand
};
}
/**
* Build the shared wire {@link SerializableDistributedRenderConfig} used
* for every entry in a `hyperframes lambda render-batch` run. Extracted for
* boundary-test coverage of the aspect-agnostic flag threading (per-entry
* `variables` are still overlayed at dispatch time inside {@link runRenderBatch}).
*/
export function buildLambdaBatchRenderConfig(
args: RenderBatchArgs,
): SerializableDistributedRenderConfig {
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
outputResolution: args.outputResolution,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
};
if (args.outputResolutionAspectAgnostic) {
config.outputResolutionAspectAgnostic = true;
}
return config;
}
/**
* Read the JSONL batch file and return one parsed entry per non-blank
* line. Reads the whole file into memory — fine for typical batch sizes,
@@ -0,0 +1,57 @@
/**
* Boundary tests for the wire config emitted by `hyperframes lambda render`.
* Pins that the aspect-agnostic resolution flag survives all the way into
* `SerializableDistributedRenderConfig`, which is what the Lambda worker's
* compile stage reads before remapping `landscape` → `portrait` for an
* aspect-agnostic alias like `--output-resolution 1080p`.
*
* See `../cloudrun.test.ts` for the parseOutputResolution counterpart and
* `../render.test.ts` for the local-CLI preflight guardrails.
*/
import { describe, expect, it } from "vitest";
import { buildLambdaRenderConfig, type RenderArgs } from "./render.js";
const BASE_ARGS: RenderArgs = {
projectDir: "/tmp/hf-project",
stackName: "hf-test",
fps: 30,
width: 1080,
height: 1920,
format: "mp4",
json: false,
wait: false,
waitIntervalMs: 5000,
};
describe("buildLambdaRenderConfig — aspect-agnostic wire threading", () => {
it("threads outputResolutionAspectAgnostic=true through for portrait 1080p", () => {
// The exact bug shape: portrait 1080×1920 comp + `--output-resolution 1080p`.
// Before the fix, the aspect-agnostic flag never reached the wire, so
// the Lambda worker saw the explicit `landscape` preset and rejected
// the portrait comp — reproducing the local-CLI regression on the
// distributed path.
const config = buildLambdaRenderConfig(
{ ...BASE_ARGS, outputResolution: "landscape", outputResolutionAspectAgnostic: true },
undefined,
);
expect(config.outputResolution).toBe("landscape");
expect(config.outputResolutionAspectAgnostic).toBe(true);
});
it("keeps the aspect-agnostic key absent when the flag is a canonical preset", () => {
// Sparse-wire invariant: only forward `true`; canonical presets stay
// strict and don't need the compile-stage remap.
const config = buildLambdaRenderConfig(
{ ...BASE_ARGS, outputResolution: "portrait-4k" },
undefined,
);
expect(config.outputResolution).toBe("portrait-4k");
expect(config.outputResolutionAspectAgnostic).toBeUndefined();
});
it("carries variables verbatim through the wire config", () => {
const config = buildLambdaRenderConfig(BASE_ARGS, { alice: "hello" });
expect(config.variables).toEqual({ alice: "hello" });
});
});
+43 -14
View File
@@ -42,6 +42,15 @@ export interface RenderArgs {
* from a 1920×1080 layout uses DPR 2, etc.
*/
outputResolution?: CanvasResolution;
/**
* True when {@link outputResolution} was normalized from an aspect-agnostic
* alias (`1080p` / `hd` / `4k` / `uhd`). Threaded through
* `SerializableDistributedRenderConfig` so the Lambda worker's compile stage
* remaps `landscape` → `portrait` / `square` when the composition's
* orientation demands it. Dropping this field is what shipped the
* portrait-1080p regression at the sibling entrypoints (see PR #2529).
*/
outputResolutionAspectAgnostic?: boolean;
format: DistributedFormat;
codec?: "h264" | "h265";
quality?: "draft" | "standard" | "high";
@@ -114,20 +123,7 @@ export async function runRender(args: RenderArgs): Promise<void> {
}
}
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
outputResolution: args.outputResolution,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
variables,
};
const config: SerializableDistributedRenderConfig = buildLambdaRenderConfig(args, variables);
// When the caller passes only --site-id, synthesise the minimum-shape
// SiteHandle pointing at the deterministic content-addressed key. The
@@ -233,3 +229,36 @@ async function waitForCompletion(
function sleep(ms: number): Promise<void> {
return new Promise((res) => setTimeout(res, ms));
}
/**
* Build the wire {@link SerializableDistributedRenderConfig} for
* `hyperframes lambda render`. Extracted for boundary-test coverage of the
* aspect-agnostic flag threading — the field this helper preserves is what
* lets the Lambda worker's compile stage remap `landscape` → `portrait` on
* an aspect-agnostic alias (`1080p` / `hd` / `4k` / `uhd`).
*/
export function buildLambdaRenderConfig(
args: RenderArgs,
variables: Record<string, unknown> | undefined,
): SerializableDistributedRenderConfig {
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
outputResolution: args.outputResolution,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
variables,
};
// Keep the wire shape sparse when the alias flag wasn't set — matches how
// `SerializableDistributedRenderConfig` is emitted at every other adapter.
if (args.outputResolutionAspectAgnostic) {
config.outputResolutionAspectAgnostic = true;
}
return config;
}
+116
View File
@@ -1170,6 +1170,122 @@ describe("checkRenderResolutionPreflight", () => {
await checkRenderResolutionPreflight("<html><body></body></html>", "landscape", noModes),
).toBeUndefined();
});
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) name a
// resolution tier without pinning an orientation. When the flag is
// aspect-agnostic the pre-flight must NOT block on an aspect-ratio mismatch —
// the compile stage adapts the preset to the composition's orientation
// downstream (see `outputResolutionAspectAgnostic` on RenderConfig).
// Field signal ts=1784176662 (darwin/arm64, CLI 0.7.59):
// "--resolution 1080p rejects a 1080x1920 portrait comp"
describe("aspect-agnostic (--resolution 1080p / hd / 4k / uhd)", () => {
const agnostic = { ...noModes, aspectAgnostic: true } as const;
it("clears a landscape preset on a portrait composition (the field-signal scenario)", async () => {
// The bug: --resolution 1080p normalized to `landscape` (1920×1080),
// then errored on a 1080×1920 portrait comp with "Output resolution
// incompatible." With aspectAgnostic=true the pre-flight steps aside
// and the compile stage re-maps landscape → portrait.
expect(
await checkRenderResolutionPreflight(portraitHtml, "landscape", agnostic),
).toBeUndefined();
});
it("clears a landscape-4k preset on a portrait composition (4K tier)", async () => {
// `--resolution 4k` → normalized `landscape-4k`. Portrait comp is fine
// when aspect-agnostic.
expect(
await checkRenderResolutionPreflight(portraitHtml, "landscape-4k", agnostic),
).toBeUndefined();
});
it("clears a landscape preset on a square composition", async () => {
// aspect > 1 → landscape, aspect = 1 → square. Both self-heal.
expect(
await checkRenderResolutionPreflight(comp(1080, 1080), "landscape", agnostic),
).toBeUndefined();
});
it("still flags alpha + aspect-agnostic (orientation isn't the issue)", async () => {
// alpha-incompatible is orthogonal to aspect: the alpha capture path
// can't apply deviceScaleFactor regardless of orientation. The
// aspect-agnostic downgrade must NOT swallow this.
const result = await checkRenderResolutionPreflight(portraitHtml, "landscape", {
aspectAgnostic: true,
alphaRequested: true,
hdrRequested: false,
});
expect(result?.kind).toBe("alpha-incompatible");
});
it("still flags HDR + aspect-agnostic", async () => {
const result = await checkRenderResolutionPreflight(landscapeHtml, "landscape", {
aspectAgnostic: true,
alphaRequested: false,
hdrRequested: true,
});
expect(result?.kind).toBe("hdr-incompatible");
});
it("still flags downsampling + aspect-agnostic (same-orientation, smaller preset)", async () => {
// 3840×2160 comp with `--resolution 1080p` → `landscape` (1920×1080).
// Same orientation, but tier smaller than comp — user asked for a
// downsample. That's a real incompatibility, not an orientation swap.
const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", agnostic);
expect(result?.kind).toBe("downsampling");
});
it("does NOT auto-clear when the flag was explicit (orientation-locked preset stays strict)", async () => {
// The negative case: `--resolution landscape` on a portrait comp — the
// user explicitly asked for landscape orientation, and the mismatch is
// a genuine mistake. Pre-flight must still block with the actionable
// "did you mean --resolution portrait?" suggestion.
const result = await checkRenderResolutionPreflight(portraitHtml, "landscape", noModes);
expect(result?.kind).toBe("aspect-mismatch");
expect(result?.message).toContain("--resolution portrait");
});
// Rames Δ2 on PR #2529: the earlier "downgrade aspect-mismatch to
// undefined" preflight cleared *un-remapped* mismatches, so two input
// classes below regressed from an early actionable error to a late throw
// deep in `resolveDeviceScaleFactor` (browser + ffmpeg already up).
// The fix computes the *effective* preset via `suggestMatchingPreset`
// (mirroring the compile stage) and re-checks against that, so only
// genuinely-fixable mismatches clear early.
it("blocks IG 4:5 (non-preset aspect) early with an aspect-aware message", async () => {
// 1080×1350 is a 4:5 portrait — no canonical preset shares that aspect,
// so `suggestMatchingPreset` returns undefined and `adaptAspectAgnosticResolution`
// keeps the original preset. Before the fix, aspect-agnostic downgraded
// the mismatch here to undefined; now the preflight surfaces it early.
const result = await checkRenderResolutionPreflight(comp(1080, 1350), "landscape", agnostic);
expect(result?.kind).toBe("aspect-mismatch");
// No sibling preset to suggest → message falls back to the "pick a preset
// whose orientation matches" hint (see `buildAspectMismatch` in
// `@hyperframes/parsers/outputResolutionCompatibility`).
expect(result?.message).toMatch(/preset whose orientation matches|omit --resolution/i);
});
it("blocks a portrait-4K comp + `1080p` downsample early (orientation-flip masks the tier gap)", async () => {
// 2160×3840 (portrait 4K) + `--resolution 1080p` — the compile stage
// remaps `landscape` → `portrait` (1080×1920), and *then* the preset
// is smaller than the composition. The un-remapped preflight let this
// slip through as an aspect-mismatch downgrade; the remap-then-check
// catches the real failure — downsampling — early.
const result = await checkRenderResolutionPreflight(comp(2160, 3840), "landscape", agnostic);
expect(result?.kind).toBe("downsampling");
});
it("blocks a portrait 720p comp + `1080p` non-integer upscale early (orientation-flip masks the fractional DPR)", async () => {
// 720×1280 (portrait 720p) + `--resolution 1080p` — remap `landscape`
// → `portrait` (1080×1920). widthRatio = 1080 / 720 = 1.5, which
// `resolveDeviceScaleFactor` rejects. Surfacing it in preflight beats
// failing after Chrome + ffmpeg spin up. Same class as Miga's
// important note on PR #2529.
const result = await checkRenderResolutionPreflight(comp(720, 1280), "landscape", agnostic);
expect(result?.kind).toBe("non-integer-scale");
});
});
});
describe("render fps arg definition", () => {
+75 -3
View File
@@ -95,7 +95,9 @@ import {
} from "@hyperframes/engine";
import {
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
checkOutputResolutionCompatibility,
suggestMatchingPreset,
parseFps,
fpsToNumber,
fpsToFfmpegArg,
@@ -486,6 +488,15 @@ export default defineCommand({
// ── Validate resolution ────────────────────────────────────────────────
let outputResolution: CanvasResolution | undefined;
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) name
// a resolution *tier* without pinning an orientation. Historically they
// all normalize to a `landscape` preset, which rejects portrait/square
// compositions at `resolveDeviceScaleFactor` time. Track the raw-input
// shape so the compile stage can re-map the preset to the composition's
// orientation (see `outputResolutionAspectAgnostic` on RenderConfig).
// Explicit orientation-bearing aliases (`1080p-portrait`, `4k-square`, …)
// and canonical presets (`landscape`, `portrait`, …) stay strict.
let outputResolutionAspectAgnostic = false;
if (args.resolution !== undefined) {
outputResolution = normalizeResolutionFlag(args.resolution);
if (!outputResolution) {
@@ -496,6 +507,7 @@ export default defineCommand({
);
process.exit(1);
}
outputResolutionAspectAgnostic = isAspectAgnosticResolutionAlias(args.resolution);
// 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
@@ -865,6 +877,7 @@ export default defineCommand({
{
alphaRequested: format === "webm" || format === "mov" || format === "png-sequence",
hdrRequested: args.hdr ?? false,
aspectAgnostic: outputResolutionAspectAgnostic,
},
);
} catch {
@@ -911,6 +924,8 @@ export default defineCommand({
browserPath,
entryFile,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
@@ -978,6 +993,8 @@ export default defineCommand({
variables,
entryFile,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
pageNavigationTimeoutMs,
@@ -1007,6 +1024,8 @@ export default defineCommand({
variables,
entryFile,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
@@ -1061,6 +1080,20 @@ interface RenderOptions {
exitAfterComplete?: boolean;
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` came from an aspect-agnostic alias
* (`--resolution 1080p` / `hd` / `4k` / `uhd`). The compile stage adapts
* the preset to the composition's orientation instead of rejecting
* portrait/square comps as an aspect-ratio mismatch.
*/
outputResolutionAspectAgnostic?: boolean;
/**
* Raw `--resolution` string as typed by the user. Preserved so Docker mode
* can forward the pre-normalized flag to the in-container CLI, which
* re-runs the aspect-agnostic detection on its own side otherwise we'd
* lose the "1080p was ambiguous" signal at the process boundary.
*/
outputResolutionRaw?: string;
pageSideCompositing?: boolean;
/** EXPERIMENTAL. drawElementImage frame capture (--experimental-fast-capture). */
experimentalFastCapture?: boolean;
@@ -1170,21 +1203,51 @@ async function readCompositionDimensions(
* 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.
*
* `aspectAgnostic` reflects whether `outputResolution` was normalized from an
* aspect-agnostic alias like `--resolution 1080p` / `hd` / `4k` / `uhd`.
* When true, an aspect-ratio mismatch is *not* an error at the CLI layer:
* the compile stage will re-map the preset to the composition's orientation
* (a portrait 1080×1920 composition with `--resolution 1080p` renders at
* 1080×1920, not 1920×1080). Alpha / HDR / downsampling / non-integer-scale
* checks still block, because those failures are not orientation-fixable.
*/
export async function checkRenderResolutionPreflight(
compositionHtml: string,
outputResolution: CanvasResolution | undefined,
modes: { alphaRequested: boolean; hdrRequested: boolean },
modes: { alphaRequested: boolean; hdrRequested: boolean; aspectAgnostic?: boolean },
): Promise<{ message: string; kind: OutputResolutionIssueKind } | 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;
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`)
// don't nail an orientation — the compile stage remaps them to the
// composition's orientation via `adaptAspectAgnosticResolution` +
// `suggestMatchingPreset`. Mirror that remap here BEFORE running the
// compatibility check so the early rejection reflects the *effective*
// preset the pipeline will actually use.
//
// Doing this pre-check (rather than post-hoc "downgrade aspect-mismatch")
// is what makes the following cases fail early with an aspect-aware
// message instead of throwing deep in the compile stage after Chrome +
// ffmpeg have already spun up (Rames Δ2 on PR #2529):
//
// - Non-preset aspect (e.g. IG 4:5 1080×1350): no sibling preset
// matches → `suggestMatchingPreset` returns `undefined` → we keep the
// original preset and surface the aspect-mismatch normally.
// - Orientation-flip + tier-too-small (portrait-4K comp 2160×3840 +
// `--resolution 1080p`): remaps `landscape` → `portrait` (1080×1920),
// re-check catches the downsample early with a clear message.
const effective =
modes.aspectAgnostic === true
? (suggestMatchingPreset(dims.width, dims.height, outputResolution) ?? outputResolution)
: outputResolution;
const compat = checkOutputResolutionCompatibility({
compositionWidth: dims.width,
compositionHeight: dims.height,
outputResolution,
outputResolution: effective,
alphaRequested: modes.alphaRequested,
hdrRequested: modes.hdrRequested,
});
@@ -1386,7 +1449,15 @@ async function renderDocker(
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
// Forward the RAW `--resolution` flag (falling back to the canonical
// preset name when raw wasn't captured, e.g. programmatic callers).
// The in-container CLI re-runs `normalizeResolutionFlag` +
// `isAspectAgnosticResolutionAlias`, so aspect-agnostic aliases
// (`1080p`, `hd`, `4k`, `uhd`) retain their orientation-adaptive
// behavior inside Docker; passing the normalized `landscape` preset
// would silently lose that signal at the process boundary and
// reject portrait/square comps.
outputResolution: options.outputResolutionRaw ?? options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
debug: options.debug,
bestEffort: options.bestEffort,
@@ -1551,6 +1622,7 @@ export async function renderLocal(
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
outputResolutionAspectAgnostic: options.outputResolutionAspectAgnostic,
debug: options.debug,
strictness: options.bestEffort === false ? "strict" : "best-effort",
});
@@ -377,6 +377,26 @@ describe("buildDockerRunArgs", () => {
expect(args[idx + 1]).toBe("landscape-4k");
});
it("forwards the RAW aspect-agnostic alias `1080p` verbatim (does not pre-normalize to `landscape`)", () => {
// Miga R2 important note on PR #2529: Docker correctness now depends on
// forwarding the raw alias string, not the canonical preset — the
// in-container CLI re-runs `normalizeResolutionFlag` +
// `isAspectAgnosticResolutionAlias` so aspect-agnostic aliases keep
// their orientation-adaptive behavior. A future refactor that
// silently substitutes the normalized preset here would restore the
// portrait-only Docker failure this test pins against.
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, outputResolution: "1080p" },
});
const idx = args.indexOf("--resolution");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe("1080p");
// Belt-and-braces: the normalized preset name must NOT slip in as a
// second value that would confuse citty parsing on the container side.
expect(args).not.toContain("landscape");
});
it("omits --resolution when outputResolution is not set", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--resolution");
@@ -0,0 +1,105 @@
/**
* Boundary tests for the shared `parseOutputResolutionFlag` helper. Every
* distributed entrypoint (`hyperframes cloudrun render{,-batch}`,
* `hyperframes lambda render{,-batch}`) delegates to this one function
* covering it here (rather than at each surface) makes the sibling-surface
* regression this PR fixes unreachable by construction: any future surface
* that calls `parseOutputResolutionFlag` inherits the correct alias
* threading. The wire-config-level tests at
* `../commands/cloudrun.test.ts` / `../commands/lambda/render.test.ts` /
* `../commands/lambda/render-batch.test.ts` still exercise the
* per-entrypoint composition so the plumbing stays end-to-end covered.
*/
import { describe, expect, it } from "vitest";
import { parseOutputResolutionFlag } from "./parseOutputResolution.js";
const CLOUDRUN = { surfaceLabel: "[cloudrun render]" } as const;
const LAMBDA = {
surfaceLabel: "[lambda render]",
aliasHint:
"1080p, 4k, uhd, hd, 1080p-portrait, portrait-1080p, 4k-portrait, 1080p-square, square-1080p, 4k-square",
} as const;
describe("parseOutputResolutionFlag", () => {
it.each([undefined, "", null])(
"returns undefined + false when the flag is omitted (raw=%s)",
(raw) => {
expect(parseOutputResolutionFlag(raw, CLOUDRUN)).toEqual({
outputResolution: undefined,
outputResolutionAspectAgnostic: false,
});
},
);
it.each(["landscape", "portrait-4k", "square", "square-4k"])(
"normalizes canonical preset %s with aspect-agnostic=false",
(preset) => {
const { outputResolution, outputResolutionAspectAgnostic } = parseOutputResolutionFlag(
preset,
CLOUDRUN,
);
expect(outputResolution).toBe(preset);
expect(outputResolutionAspectAgnostic).toBe(false);
},
);
// The blocker path from Miga's R2 review: without this pair, a portrait
// composition with `--output-resolution 1080p` reaches the compile stage
// as the explicit `landscape` preset and rejects with the original
// aspect-mismatch instead of remapping to `portrait`.
it.each(["1080p", "hd", "4k", "uhd"])(
"flags aspect-agnostic tier alias %s so the compile stage can remap orientation",
(alias) => {
const { outputResolution, outputResolutionAspectAgnostic } = parseOutputResolutionFlag(
alias,
CLOUDRUN,
);
expect(outputResolutionAspectAgnostic).toBe(true);
expect(outputResolution).toBeDefined();
},
);
it.each(["1080p-portrait", "portrait-1080p", "1080p-square", "4k-portrait", "4k-square"])(
"does NOT flag orientation-suffixed alias %s as aspect-agnostic",
(alias) => {
// The user picked an orientation — respect it, don't silently swap.
const { outputResolutionAspectAgnostic } = parseOutputResolutionFlag(alias, CLOUDRUN);
expect(outputResolutionAspectAgnostic).toBe(false);
},
);
it("treats input case-insensitively (1080P, UHD, HD, 4K all pass)", () => {
for (const alias of ["1080P", "UHD", "HD", "4K"]) {
expect(parseOutputResolutionFlag(alias, CLOUDRUN).outputResolutionAspectAgnostic).toBe(true);
}
});
it("throws with the caller-supplied surface label on unknown values", () => {
// The two surfaces MUST use the same underlying helper (see PR #2529 —
// divergent copies is exactly the cross-scaffold drift class this
// consolidation prevents), but each stakes its own label so debugging
// still points at the right verb.
expect(() => parseOutputResolutionFlag("8k", CLOUDRUN)).toThrow(/\[cloudrun render\]/);
expect(() => parseOutputResolutionFlag("8k", LAMBDA)).toThrow(/\[lambda render\]/);
});
it("appends the caller-supplied aliasHint to the error text (so the message stays surface-accurate)", () => {
// Lambda advertises the full orientation-suffixed alias list in help
// text; the error message must match that surface for the user's
// "did you mean?" search to land on real docs.
const err = getThrown(() => parseOutputResolutionFlag("8k", LAMBDA));
expect(err.message).toContain("1080p-portrait");
expect(err.message).toContain("4k-portrait");
});
});
function getThrown(fn: () => void): Error {
try {
fn();
} catch (e) {
if (e instanceof Error) return e;
throw new Error(`Non-Error thrown: ${String(e)}`);
}
throw new Error("Expected fn to throw, but it did not");
}
@@ -0,0 +1,67 @@
/**
* Shared `--output-resolution` / `--resolution` normalizer for the distributed
* render entrypoints (`hyperframes cloudrun render{,-batch}`, `hyperframes
* lambda render{,-batch}`) plus the local `hyperframes render` command.
*
* The one field this helper carries that the previous per-surface copies
* were dropping is `outputResolutionAspectAgnostic`: `true` when the raw
* flag was a tier-only alias (`1080p` / `hd` / `4k` / `uhd`). Passing it
* through into `SerializableDistributedRenderConfig` is what lets the
* remote worker's compile stage remap `landscape` `portrait` when the
* composition demands it. Dropping the flag at any single entrypoint
* reproduces the portrait-1080p regression this helper prevents (see
* PR #2529 R2 CHANGES_REQUESTED and the sibling-surface enumeration in
* Miga + Rames's reviews).
*
* The strict-throw contract (unknown values raise instead of silently
* degrading to `outputResolution: undefined`) is preserved so a typo like
* `--output-resolution 8k` fails fast rather than falling back to
* composition dimensions.
*/
import { type CanvasResolution, resolveResolutionFlagPair } from "@hyperframes/core";
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/core";
/**
* Free-text prefix the thrown error is scoped to (e.g. `"[cloudrun render]"`,
* `"[lambda render]"`). Kept as a caller-supplied string rather than a
* fixed enum so future surfaces (Studio Server, an SDK wrapper, ) can
* opt in without editing this file.
*/
export interface OutputResolutionParseOptions {
surfaceLabel: string;
/**
* Optional per-surface hint appended to the error message. Defaults to a
* generic tier-alias hint; the Lambda surface exposes additional
* orientation-suffixed aliases the CLI accepts (`1080p-portrait`, `4k-portrait`,
* ) pass a custom hint to keep the error text faithful.
*/
aliasHint?: string;
}
/**
* Parse the user-supplied resolution flag into the pair the distributed
* wire config needs. Returns `{ outputResolution: undefined,
* outputResolutionAspectAgnostic: false }` when the flag is absent so the
* caller can spread the result unconditionally.
*
* Throws (not exits) on an unknown value CLI callers wrap that in their
* own errorBox / process.exit; SDK callers surface the error to their own
* user.
*/
export function parseOutputResolutionFlag(
raw: unknown,
options: OutputResolutionParseOptions,
): { outputResolution: CanvasResolution | undefined; outputResolutionAspectAgnostic: boolean } {
if (raw == null || raw === "") {
return { outputResolution: undefined, outputResolutionAspectAgnostic: false };
}
const asString = String(raw);
const { outputResolution, outputResolutionAspectAgnostic } = resolveResolutionFlagPair(asString);
if (outputResolution) return { outputResolution, outputResolutionAspectAgnostic };
const aliasHint = options.aliasHint ?? "1080p, 4k, uhd, hd, …";
throw new Error(
`${options.surfaceLabel} --output-resolution must be one of ${VALID_CANVAS_RESOLUTIONS.join("|")} ` +
`(or an alias: ${aliasHint}); got ${asString}`,
);
}
+3
View File
@@ -179,7 +179,10 @@ export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
resolveResolutionFlagPair,
checkOutputResolutionCompatibility,
suggestMatchingPreset,
COMPOSITION_VARIABLE_TYPES,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
+60
View File
@@ -39,6 +39,66 @@ describe("@hyperframes/core public API exports", () => {
expect(core.normalizeResolutionFlag(undefined)).toBeUndefined();
});
it("exports isAspectAgnosticResolutionAlias for tier-only aliases", () => {
// Tier-only aliases → true (orientation follows the composition).
expect(core.isAspectAgnosticResolutionAlias("1080p")).toBe(true);
expect(core.isAspectAgnosticResolutionAlias("hd")).toBe(true);
expect(core.isAspectAgnosticResolutionAlias("4k")).toBe(true);
expect(core.isAspectAgnosticResolutionAlias("uhd")).toBe(true);
// Case-insensitive.
expect(core.isAspectAgnosticResolutionAlias("1080P")).toBe(true);
expect(core.isAspectAgnosticResolutionAlias("UHD")).toBe(true);
// Orientation-suffixed aliases → false (user picked an orientation).
expect(core.isAspectAgnosticResolutionAlias("1080p-portrait")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("portrait-1080p")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("4k-square")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("1080p-square")).toBe(false);
// Canonical presets → false.
expect(core.isAspectAgnosticResolutionAlias("landscape")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("portrait")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("landscape-4k")).toBe(false);
// Unknown / empty / undefined → false.
expect(core.isAspectAgnosticResolutionAlias("8k")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias(undefined)).toBe(false);
});
it("exports resolveResolutionFlagPair — the pair every distributed entrypoint must forward", () => {
// The single source of truth every distributed adapter reads
// (`hyperframes cloudrun render`, `hyperframes lambda render`,
// `hyperframes lambda render-batch`). Divergent copies across those
// callers is what shipped the portrait-1080p failure this helper
// exists to prevent (PR #2529). Case-insensitive on the raw input.
expect(core.resolveResolutionFlagPair("1080p")).toEqual({
outputResolution: "landscape",
outputResolutionAspectAgnostic: true,
});
expect(core.resolveResolutionFlagPair("4K")).toEqual({
outputResolution: "landscape-4k",
outputResolutionAspectAgnostic: true,
});
// Canonical preset — aspect-agnostic stays false.
expect(core.resolveResolutionFlagPair("portrait")).toEqual({
outputResolution: "portrait",
outputResolutionAspectAgnostic: false,
});
// Orientation-suffixed alias — aspect-agnostic stays false.
expect(core.resolveResolutionFlagPair("1080p-portrait")).toEqual({
outputResolution: "portrait",
outputResolutionAspectAgnostic: false,
});
// Unknown / empty / undefined → both fields degrade cleanly so
// callers can produce their own "invalid" UX.
expect(core.resolveResolutionFlagPair("8k")).toEqual({
outputResolution: undefined,
outputResolutionAspectAgnostic: false,
});
expect(core.resolveResolutionFlagPair(undefined)).toEqual({
outputResolution: undefined,
outputResolutionAspectAgnostic: false,
});
});
it("exports TIMELINE_COLORS", () => {
expect(core.TIMELINE_COLORS).toBeDefined();
expect(core.TIMELINE_COLORS.video).toBeDefined();
+3
View File
@@ -55,7 +55,10 @@ export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
resolveResolutionFlagPair,
checkOutputResolutionCompatibility,
suggestMatchingPreset,
parseFps,
parseFpsWithDefault,
toFps,
@@ -59,8 +59,13 @@ const OK: OutputResolutionCompatibility = { ok: true };
* 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.
*
* Exported so that consumers with permission to auto-apply the swap (see the
* `--resolution 1080p` aspect-agnostic path in the CLI/producer) can share the
* same sibling-lookup logic as this module's user-facing "did you mean?" hint,
* without duplicating the tier-preserving fallback rules.
*/
function suggestMatchingPreset(
export function suggestMatchingPreset(
compositionWidth: number,
compositionHeight: number,
chosen: CanvasResolution,
+74
View File
@@ -51,6 +51,27 @@ const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
"4k-square": "square-4k",
};
/**
* Aliases that name a resolution *tier* (1080p / 4K) without also nailing an
* orientation. Historically they all normalize to the `landscape` preset
* (`normalizeResolutionFlag("1080p") === "landscape"`), which then rejects
* portrait / square compositions with a cryptic "aspect ratio does not match"
* error deep inside the render pipeline. Consumers that know the composition's
* dimensions can consult this set to decide whether to auto-adapt the preset's
* orientation instead see `adaptAspectAgnosticResolution` in the compile
* stage (`@hyperframes/producer`) and `suggestMatchingPreset` here.
*
* Orientation-suffixed aliases (`1080p-portrait`, `4k-square`, ) are absent
* on purpose: the user *did* pick an orientation, and honoring it is important
* for the "you asked for landscape but composed portrait" error to still fire.
*/
const ASPECT_AGNOSTIC_RESOLUTION_ALIASES: ReadonlySet<string> = new Set([
"1080p",
"hd",
"4k",
"uhd",
]);
/**
* Map a user-facing resolution string (canonical name or alias) to a
* `CanvasResolution`. Returns undefined for unknown values so callers
@@ -65,6 +86,59 @@ export function normalizeResolutionFlag(input: string | undefined): CanvasResolu
return RESOLUTION_ALIASES[lowered];
}
/**
* True when `input` names a resolution *tier* without nailing an orientation
* (`1080p`, `hd`, `4k`, `uhd`). Case-insensitive.
*
* The `--resolution` CLI flag treats these as "target this size; keep the
* composition's orientation" a portrait 1080×1920 comp with `--resolution
* 1080p` should land at 1080×1920, not blow up on aspect mismatch. Explicit
* canonical presets (`landscape`, `portrait`, ) and orientation-suffixed
* aliases (`1080p-portrait`) stay strict the user picked an orientation.
*
* Consumers pair this signal with the composition's dimensions (in a
* follow-up pass after HTML parse) to pick the right preset via
* `suggestMatchingPreset` see `adaptAspectAgnosticResolution` in the
* compile stage (`@hyperframes/producer`) for the canonical remap.
*/
export function isAspectAgnosticResolutionAlias(input: string | undefined): boolean {
if (!input) return false;
return ASPECT_AGNOSTIC_RESOLUTION_ALIASES.has(input.toLowerCase());
}
/**
* Public-boundary helper: given a raw `--resolution` / `--output-resolution`
* flag value, return the pair every distributed render entrypoint needs to
* forward end-to-end so the compile stage can adapt aspect-agnostic aliases
* to the composition's orientation:
*
* - `outputResolution`: normalized {@link CanvasResolution} (or `undefined`
* for unknown values callers own their invalid-input UX).
* - `outputResolutionAspectAgnostic`: `true` when the raw input was a
* tier-only alias (`1080p` / `hd` / `4k` / `uhd`). Passes through to
* `DistributedRenderConfig.outputResolutionAspectAgnostic` so the compile
* stage remaps `landscape` `portrait` / `square` when the composition
* dimensions demand it.
*
* Exported to centralize the two-step pattern (`normalizeResolutionFlag` +
* `isAspectAgnosticResolutionAlias`) that would otherwise be duplicated at
* every entrypoint that emits a `DistributedRenderConfig` (`hyperframes
* cloudrun render`, `hyperframes lambda render` / `render-batch`, the local
* CLI). Divergence between those callers is what shipped the portrait-1080p
* regression this helper prevents from recurring.
*/
export interface ResolvedResolutionFlag {
outputResolution: CanvasResolution | undefined;
outputResolutionAspectAgnostic: boolean;
}
export function resolveResolutionFlagPair(input: string | undefined): ResolvedResolutionFlag {
return {
outputResolution: normalizeResolutionFlag(input),
outputResolutionAspectAgnostic: isAspectAgnosticResolutionAlias(input),
};
}
export interface TimelineElementBase {
id: string;
type: TimelineElementType;
+32 -8
View File
@@ -43,7 +43,12 @@ import { isVideoFrameFormat } from "@hyperframes/engine";
import { resolveRenderPaths } from "./utils/paths.js";
import { defaultLogger, type ProducerLogger } from "./logger.js";
import { Semaphore } from "./utils/semaphore.js";
import { parseFps, normalizeResolutionFlag, type CanvasResolution } from "@hyperframes/core";
import {
parseFps,
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
type CanvasResolution,
} from "@hyperframes/core";
// ---------------------------------------------------------------------------
// Types
@@ -93,9 +98,17 @@ interface RenderInput {
* Output resolution preset (e.g. `landscape-4k`). Drives the same
* `resolveDeviceScaleFactor` supersampling path the local CLI uses Chrome
* renders at a higher devicePixelRatio so the captured screenshot lands at
* the requested dimensions. Aspect ratio must match the composition.
* the requested dimensions. Aspect ratio must match the composition unless
* `outputResolutionAspectAgnostic` is set (see below).
*/
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` was normalized from an aspect-agnostic alias
* (`1080p`, `hd`, `4k`, `uhd`). The compile stage will adapt the preset to
* the composition's orientation instead of rejecting portrait/square
* compositions as an aspect-ratio mismatch.
*/
outputResolutionAspectAgnostic?: boolean;
}
interface PreparedRenderInput {
@@ -151,7 +164,8 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
? body.videoFrameFormat
: undefined;
const { variables, outputResolution } = parseRenderOverrides(body);
const { variables, outputResolution, outputResolutionAspectAgnostic } =
parseRenderOverrides(body);
return {
outputPath,
@@ -165,6 +179,7 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
format,
variables,
outputResolution,
outputResolutionAspectAgnostic,
videoFrameFormat,
};
}
@@ -182,15 +197,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
function parseRenderOverrides(body: Record<string, unknown>): {
variables?: Record<string, unknown>;
outputResolution?: CanvasResolution;
outputResolutionAspectAgnostic?: boolean;
} {
// Only forward a plain JSON object. Arrays / primitives / null → undefined.
const variables = isPlainObject(body.variables) ? body.variables : undefined;
// Accept canonical presets and aliases ("4k", "landscape-4k", …).
const outputResolution =
typeof body.outputResolution === "string"
? normalizeResolutionFlag(body.outputResolution)
: undefined;
return { variables, outputResolution };
const rawOutputResolution =
typeof body.outputResolution === "string" ? body.outputResolution : undefined;
const outputResolution = rawOutputResolution
? normalizeResolutionFlag(rawOutputResolution)
: undefined;
// Preserve the "raw shape was tier-only" signal so the compile stage can
// adapt the preset to the composition's orientation. Set only when
// normalization succeeded — a bad string doesn't need the flag.
const outputResolutionAspectAgnostic = outputResolution
? isAspectAgnosticResolutionAlias(rawOutputResolution)
: undefined;
return { variables, outputResolution, outputResolutionAspectAgnostic };
}
/**
@@ -210,6 +233,7 @@ function buildRenderJobConfig(input: RenderInput, log: ProducerLogger) {
entryFile: input.entryFile,
variables: input.variables,
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
videoFrameFormat: input.videoFrameFormat,
logger: log,
};
@@ -128,6 +128,12 @@ export interface DistributedRenderConfig {
videoFrameFormat?: VideoFrameFormat;
/** Output resolution preset; engages Chrome `deviceScaleFactor` supersampling. */
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` was normalized from an aspect-agnostic alias
* (`1080p`, `hd`, `4k`, `uhd`) the compile stage re-targets the preset
* to the composition's orientation.
*/
outputResolutionAspectAgnostic?: boolean;
/**
* Frames per chunk. When explicitly set, that value is used and
@@ -762,6 +768,7 @@ export async function plan(
bitrate: config.bitrate,
videoFrameFormat: config.videoFrameFormat,
outputResolution: config.outputResolution,
outputResolutionAspectAgnostic: config.outputResolutionAspectAgnostic,
// HDR is banned in distributed mode. force-sdr keeps the
// extract / encoder paths off the HDR branches entirely.
hdrMode: config.hdrMode ?? "force-sdr",
@@ -99,6 +99,7 @@ export interface SyntheticRenderJobInput {
bitrate?: string;
videoFrameFormat?: VideoFrameFormat;
outputResolution?: RenderConfig["outputResolution"];
outputResolutionAspectAgnostic?: RenderConfig["outputResolutionAspectAgnostic"];
hdrMode: RenderConfig["hdrMode"];
strictness?: RenderConfig["strictness"];
entryFile: string;
@@ -120,6 +121,7 @@ export function buildSyntheticRenderJob(input: SyntheticRenderJobInput): RenderJ
videoBitrate: input.bitrate,
videoFrameFormat: input.videoFrameFormat,
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
// Distributed mode hard-pins to software GPU. The plan-time validator
// refuses to fan out otherwise.
useGpu: false,
@@ -22,7 +22,12 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { EngineConfig } from "@hyperframes/engine";
import { runCompileStage, type CompileStageInput } from "./compileStage.js";
import type { CanvasResolution } from "@hyperframes/core";
import {
runCompileStage,
type CompileStageInput,
type CompileStageResult,
} from "./compileStage.js";
import type { RenderJob } from "../../renderOrchestrator.js";
const noopLog = {
@@ -70,12 +75,13 @@ function createCfg(overrides: Partial<EngineConfig> = {}): EngineConfig {
};
}
function createJob(): RenderJob {
function createJob(overrides: Partial<RenderJob["config"]> = {}): RenderJob {
return {
id: "test-job",
config: {
fps: { num: 30, den: 1 },
quality: "standard",
...overrides,
},
status: "queued",
progress: 0,
@@ -106,6 +112,20 @@ const IFRAME_HTML = `<!doctype html>
</body>
</html>`;
// Portrait/square/landscape fixture template — same shape as PLAIN_HTML but
// with caller-supplied composition dimensions. Consumed by the aspect-agnostic
// re-map tests below (and reachable from any sibling describe block, unlike a
// helper scoped inside one describe).
const orientedHtml = (w: number, h: number): string => `<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<div data-composition-id="root" data-width="${w}" data-height="${h}" data-duration="1">
<p>oriented composition</p>
</div>
</body>
</html>`;
interface CompileFixture {
workDir: string;
htmlPath: string;
@@ -211,3 +231,153 @@ describe("runCompileStage — forceScreenshot snapshot", () => {
}
});
});
/**
* Tests for the aspect-agnostic --resolution re-map in `runCompileStage`.
*
* Field signal ts=1784176662 (darwin/arm64, CLI 0.7.59):
* "--resolution 1080p rejects a 1080x1920 portrait comp — render at native."
*
* `--resolution 1080p` / `hd` / `4k` / `uhd` name a resolution *tier* without
* pinning an orientation. `normalizeResolutionFlag` maps them all to a
* landscape preset (backwards compat), and the CLI/server layers then flag
* the raw input as aspect-agnostic on `RenderConfig`. The compile stage
* consults that flag before `resolveDeviceScaleFactor` if the composition's
* orientation differs from the preset's, the preset is re-targeted to the
* matching sibling in the same tier (HD HD, 4K 4K). Explicit
* orientation-bearing presets stay strict.
*/
describe("runCompileStage — aspect-agnostic --resolution re-map", () => {
let fixture: CompileFixture | null = null;
afterEach(() => {
fixture?.cleanup();
fixture = null;
});
async function runResolutionCase(input: {
compWidth: number;
compHeight: number;
outputResolution: CanvasResolution;
aspectAgnostic: boolean;
}): Promise<CompileStageResult> {
fixture = setupFixture(orientedHtml(input.compWidth, input.compHeight));
const projectDir = join(fixture.workDir, "project");
const cfg = createCfg();
const stageInput: CompileStageInput = {
projectDir,
workDir: fixture.workDir,
htmlPath: fixture.htmlPath,
entryFile: "index.html",
job: createJob({
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.aspectAgnostic,
}),
cfg,
needsAlpha: false,
log: noopLog,
assertNotAborted: () => {},
};
return runCompileStage(stageInput);
}
// ─── Positive branches: aspect-agnostic auto-flip ──────────────────────
it("landscape composition + aspect-agnostic `landscape` preset → no re-map, DPR=1", async () => {
// Sanity: the flag was ambiguous but the composition IS landscape, so
// the preset already matches — nothing to flip.
const result = await runResolutionCase({
compWidth: 1920,
compHeight: 1080,
outputResolution: "landscape",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(1);
expect(result.outputWidth).toBe(1920);
expect(result.outputHeight).toBe(1080);
});
it("portrait composition + aspect-agnostic `landscape` preset → re-maps to portrait, DPR=1 (field signal scenario)", async () => {
// The reporter's exact case: --resolution 1080p (normalized landscape)
// on a 1080×1920 portrait comp. Previously threw "aspect ratio does not
// match"; now re-maps to portrait (1080×1920) and DPR resolves to 1.
const result = await runResolutionCase({
compWidth: 1080,
compHeight: 1920,
outputResolution: "landscape",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(1);
expect(result.outputWidth).toBe(1080);
expect(result.outputHeight).toBe(1920);
});
it("square composition + aspect-agnostic `landscape` preset → re-maps to square, DPR=1", async () => {
// --resolution 1080p on a 1080×1080 square comp → renders at 1080×1080.
const result = await runResolutionCase({
compWidth: 1080,
compHeight: 1080,
outputResolution: "landscape",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(1);
expect(result.outputWidth).toBe(1080);
expect(result.outputHeight).toBe(1080);
});
it("portrait composition + aspect-agnostic `landscape-4k` preset → re-maps to portrait-4k, preserves 4K tier", async () => {
// `--resolution 4k` (normalized landscape-4k) on a portrait comp: the
// re-map picks portrait-4k (same tier) rather than downgrading to
// portrait (HD). 1080×1920 comp × 2 = 2160×3840 (portrait-4k).
const result = await runResolutionCase({
compWidth: 1080,
compHeight: 1920,
outputResolution: "landscape-4k",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(2);
expect(result.outputWidth).toBe(2160);
expect(result.outputHeight).toBe(3840);
});
it("square composition + aspect-agnostic `landscape-4k` preset → re-maps to square-4k, preserves 4K tier", async () => {
const result = await runResolutionCase({
compWidth: 1080,
compHeight: 1080,
outputResolution: "landscape-4k",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(2);
expect(result.outputWidth).toBe(2160);
expect(result.outputHeight).toBe(2160);
});
// ─── Negative branch: explicit preset stays strict ─────────────────────
it("portrait composition + explicit `landscape` preset (NOT aspect-agnostic) still throws aspect-mismatch", async () => {
// The user typed `--resolution landscape` explicitly, not an alias.
// Their stated intent is landscape orientation, which the renderer
// cannot produce from a portrait composition (deviceScaleFactor can't
// change aspect). Keep the actionable error rather than silently
// swapping orientation.
await expect(
runResolutionCase({
compWidth: 1080,
compHeight: 1920,
outputResolution: "landscape",
aspectAgnostic: false,
}),
).rejects.toThrow(/aspect ratio|--resolution portrait/i);
});
it("portrait composition + explicit `landscape-4k` preset (NOT aspect-agnostic) still throws", async () => {
await expect(
runResolutionCase({
compWidth: 1080,
compHeight: 1920,
outputResolution: "landscape-4k",
aspectAgnostic: false,
}),
).rejects.toThrow(/aspect ratio|--resolution portrait/i);
});
});
@@ -37,6 +37,7 @@
import { join } from "node:path";
import type { EngineConfig } from "@hyperframes/engine";
import { suggestMatchingPreset, type CanvasResolution } from "@hyperframes/core";
import type { CompiledComposition } from "../../htmlCompiler.js";
import { compileForRender } from "../../htmlCompiler.js";
import type { ProducerLogger } from "../../../logger.js";
@@ -109,6 +110,41 @@ export interface CompileStageResult {
deCompileGate?: string;
}
/**
* Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) all
* normalize to a landscape preset up-front (see `normalizeResolutionFlag`),
* which was historically fine because 16:9 was the only shipped orientation.
* Once portrait + square presets landed, that early normalization started
* rejecting portrait/square compositions with a cryptic "aspect ratio does
* not match" from `resolveDeviceScaleFactor` a common enough hit that a
* field report (CLI 0.7.59) surfaced it. When the flag was aspect-agnostic,
* re-target the preset to the sibling that matches the composition's
* orientation while preserving the tier (HD vs 4K). Explicit
* orientation-bearing presets stay strict a `--resolution portrait` on a
* landscape composition still errors, honoring the user's stated intent.
*
* Extracted so `runCompileStage` keeps its complexity envelope tight; the
* two-branch shape lives here.
*/
function adaptAspectAgnosticResolution(
requested: CanvasResolution | undefined,
aspectAgnostic: boolean | undefined,
width: number,
height: number,
log: ProducerLogger,
): CanvasResolution | undefined {
if (!requested || !aspectAgnostic) return requested;
const flipped = suggestMatchingPreset(width, height, requested);
if (!flipped || flipped === requested) return requested;
log.info("Adapted aspect-agnostic --resolution to composition orientation", {
compositionWidth: width,
compositionHeight: height,
requestedResolution: requested,
effectiveResolution: flipped,
});
return flipped;
}
export async function runCompileStage(input: CompileStageInput): Promise<CompileStageResult> {
const {
projectDir,
@@ -273,10 +309,17 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
height: compiled.height,
};
const { width, height } = composition;
const effectiveResolution = adaptAspectAgnosticResolution(
job.config.outputResolution,
job.config.outputResolutionAspectAgnostic,
width,
height,
log,
);
const deviceScaleFactor = resolveDeviceScaleFactor({
compositionWidth: width,
compositionHeight: height,
outputResolution: job.config.outputResolution,
outputResolution: effectiveResolution,
hdrRequested: job.config.hdrMode === "force-hdr",
alphaRequested: needsAlpha,
});
@@ -286,7 +329,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
log.info("Supersampling composition via deviceScaleFactor", {
compositionWidth: width,
compositionHeight: height,
outputResolution: job.config.outputResolution,
outputResolution: effectiveResolution,
outputWidth,
outputHeight,
deviceScaleFactor,
@@ -344,6 +344,21 @@ export interface RenderConfig {
* HDR constraints.
*/
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` was normalized from an aspect-agnostic alias
* (`1080p`, `hd`, `4k`, `uhd`) rather than a preset that names its own
* orientation (`landscape`, `portrait`, `1080p-portrait`, ). Set by the
* CLI + server layers via `isAspectAgnosticResolutionAlias(rawInput)` at
* flag/body parse time.
*
* When true, the compile stage adapts the preset to the composition's
* orientation before calling `resolveDeviceScaleFactor` a portrait
* 1080×1920 composition with `--resolution 1080p` (normalized to
* `landscape`) is re-mapped to `portrait`, honoring the user's intent
* ("render at 1080p") without forcing them to know the aspect-suffixed
* alias (`1080p-portrait`). Explicit orientation presets stay strict.
*/
outputResolutionAspectAgnostic?: boolean;
}
export interface RenderPerfSummary {
@@ -101,6 +101,42 @@ describe("POST /projects/:id/render — outputResolution forwarding", () => {
}
});
// Contract pin per PR #2529 R2 review: the HTTP route accepts canonical
// presets only, not the CLI-side tier aliases (`1080p` / `hd` / `4k` /
// `uhd`, `landscape-4k` is already canonical). Miga asked that Studio
// Server's contract stay explicit here rather than silently extending it.
// Aliases fall through to the same `undefined` sink as any unknown value,
// which means the render falls back to composition dimensions — a safe,
// deliberate no-op rather than a portrait-1080p failure mode.
it.each(["1080p", "4k", "hd", "uhd"])(
"does NOT accept the CLI-side tier alias %s (canonical-only contract)",
async (alias) => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
resolution: alias,
}),
});
expect(res.status).toBe(200);
// Alias falls through to undefined → render uses composition dims.
// If Studio Server ever extends its contract to accept aliases,
// it must also plumb `outputResolutionAspectAgnostic` alongside so
// the compile stage can remap orientation; today the surface is
// deliberately narrow.
expect(spy.mock.calls[0][0].outputResolution).toBeUndefined();
} finally {
cleanup();
}
},
);
it("accepts each canonical preset value", async () => {
for (const preset of VALID_CANVAS_RESOLUTIONS) {
const spy = vi.fn();