feat(cli): warn when lambda --width/--height conflicts with composition

`--width 3840 --height 2160` against a composition with
`data-width="1920"` silently produces a 1080p output because the
runtime lays out the page at the composition's authored dimensions —
real footgun we hit during a cost-analysis sweep. Warn early and point
at `--output-resolution` (the supersampling escape hatch) so the user
doesn't burn a 30-minute render learning the override rule.

Skipped when `--output-resolution` is set (the supported supersampling
path — the user is opting in), when `--json` is set (machine consumers),
or when `index.html` isn't on disk (typical with `--site-id`).

Helper lives in a shared module so render + render-batch agree on the
parse + message. Tests cover both attribute orders, single/double
quotes, the silent paths, and the warning path. Best-effort regex over
the canonical attr shape — malformed HTML falls through to no warning
rather than blocking the render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-22 16:41:56 -04:00
committed by James Russo
co-authored by Claude Opus 4.7
parent d4384722e8
commit ec1b7e1eff
5 changed files with 189 additions and 9 deletions
@@ -0,0 +1,105 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { warnOnDimensionMismatch } from "./_dimensions.js";
const indexHtml = (width: number, height: number) =>
`<!doctype html><html><body><div data-composition-id="root" data-width="${width}" data-height="${height}"></div></body></html>`;
describe("warnOnDimensionMismatch", () => {
let dir: string;
let warnSpy: ReturnType<typeof vi.fn>;
let originalWarn: typeof console.warn;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-dim-mismatch-"));
originalWarn = console.warn;
warnSpy = vi.fn();
console.warn = warnSpy as unknown as typeof console.warn;
});
afterEach(() => {
console.warn = originalWarn;
rmSync(dir, { recursive: true, force: true });
});
function writeIndex(html: string): void {
writeFileSync(join(dir, "index.html"), html);
}
it("warns when the CLI dimensions don't match the composition", () => {
writeIndex(indexHtml(1920, 1080));
warnOnDimensionMismatch({
projectDir: dir,
cliWidth: 3840,
cliHeight: 2160,
outputResolution: undefined,
quiet: false,
});
expect(warnSpy).toHaveBeenCalledTimes(1);
const call = (warnSpy.mock.calls[0]?.[0] as string) ?? "";
expect(call).toContain("3840×2160");
expect(call).toContain("1920×1080");
expect(call).toContain("--output-resolution");
});
it("is silent when CLI and composition agree", () => {
writeIndex(indexHtml(1920, 1080));
warnOnDimensionMismatch({
projectDir: dir,
cliWidth: 1920,
cliHeight: 1080,
outputResolution: undefined,
quiet: false,
});
expect(warnSpy).not.toHaveBeenCalled();
});
it("is silent when --output-resolution is set (the supersampling path)", () => {
writeIndex(indexHtml(1920, 1080));
warnOnDimensionMismatch({
projectDir: dir,
cliWidth: 3840,
cliHeight: 2160,
outputResolution: "landscape-4k",
quiet: false,
});
expect(warnSpy).not.toHaveBeenCalled();
});
it("is silent when quiet=true (--json)", () => {
writeIndex(indexHtml(1920, 1080));
warnOnDimensionMismatch({
projectDir: dir,
cliWidth: 3840,
cliHeight: 2160,
outputResolution: undefined,
quiet: true,
});
expect(warnSpy).not.toHaveBeenCalled();
});
it("is silent when index.html is missing (typical with --site-id)", () => {
warnOnDimensionMismatch({
projectDir: dir,
cliWidth: 3840,
cliHeight: 2160,
outputResolution: undefined,
quiet: false,
});
expect(warnSpy).not.toHaveBeenCalled();
});
it("is silent when the composition has no data-composition-id root", () => {
writeIndex("<body><h1>just a comp</h1></body>");
warnOnDimensionMismatch({
projectDir: dir,
cliWidth: 3840,
cliHeight: 2160,
outputResolution: undefined,
quiet: false,
});
expect(warnSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,47 @@
/**
* Shared dimension-mismatch warning for `hyperframes lambda render` and
* `lambda render-batch`. The runtime lays the page out at the composition's
* `data-width`/`data-height`, so passing `--width 3840 --height 2160`
* against a 1920×1080 composition silently produces a 1080p output. Warn
* early and point at `--output-resolution` (the supersampling path).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { CanvasResolution } from "@hyperframes/core";
import { c } from "../../ui/colors.js";
import { findCompositionDimensions } from "../../utils/compositionViewport.js";
export interface DimensionMismatchArgs {
projectDir: string;
cliWidth: number;
cliHeight: number;
outputResolution: CanvasResolution | undefined;
/** Suppress the warning when stdout is reserved for machine output (--json). */
quiet: boolean;
}
// fallow-ignore-next-line complexity
export function warnOnDimensionMismatch(args: DimensionMismatchArgs): void {
if (args.quiet) return;
if (args.outputResolution) return;
let html: string;
try {
html = readFileSync(join(args.projectDir, "index.html"), "utf-8");
} catch {
return;
}
const composition = findCompositionDimensions(html);
if (!composition) return;
if (composition.width === args.cliWidth && composition.height === args.cliHeight) return;
console.warn(
c.warn(
`--width/--height (${args.cliWidth}×${args.cliHeight}) disagrees with the composition's ` +
`data-width/data-height (${composition.width}×${composition.height}). The runtime lays out ` +
`the page at the composition's authored dimensions, so your output will be ` +
`${composition.width}×${composition.height}, not ${args.cliWidth}×${args.cliHeight}.\n` +
` To supersample to a higher resolution, pass --output-resolution (e.g. \`--output-resolution=4k\`).\n` +
` To truly change layout dimensions, edit the composition's data-width/data-height in index.html.`,
),
);
}
@@ -36,6 +36,7 @@ import {
reportVariableIssues,
validateVariablesAgainstSchema,
} from "../../utils/variables.js";
import { warnOnDimensionMismatch } from "./_dimensions.js";
import { requireStack } from "./state.js";
// Dynamic-import the SDK so tsup keeps it out of the static-import head of
@@ -165,6 +166,14 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
process.exit(1);
}
warnOnDimensionMismatch({
projectDir,
cliWidth: args.width,
cliHeight: args.height,
outputResolution: args.outputResolution,
quiet: args.json,
});
// Pre-validate every entry's variables against the composition's
// schema. Mismatches print as warnings; strict mode aborts before any
// AWS call. Schema is loaded once and reused across entries — a 10k-row
@@ -17,6 +17,7 @@ import {
resolveVariablesArg,
validateVariablesAgainstProject,
} from "../../utils/variables.js";
import { warnOnDimensionMismatch } from "./_dimensions.js";
import { requireStack, stateFilePath } from "./state.js";
// Dynamic-import the SDK so tsup keeps it out of the static-import head of
@@ -72,6 +73,14 @@ export async function runRender(args: RenderArgs): Promise<void> {
const stack = requireStack(args.stackName);
const projectDir = resolvePath(args.projectDir);
warnOnDimensionMismatch({
projectDir,
cliWidth: args.width,
cliHeight: args.height,
outputResolution: args.outputResolution,
quiet: args.json,
});
// Resolve --variables / --variables-file using the same parser the local
// `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly
// errorBox on parse errors so callers don't have to.