mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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:
@@ -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\]/);
|
||||
});
|
||||
});
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user