Skip to content

Commit ec1b7e1

Browse files
jrusso1020claude
andcommitted
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>
1 parent d438472 commit ec1b7e1

5 files changed

Lines changed: 189 additions & 9 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { warnOnDimensionMismatch } from "./_dimensions.js";
6+
7+
const indexHtml = (width: number, height: number) =>
8+
`<!doctype html><html><body><div data-composition-id="root" data-width="${width}" data-height="${height}"></div></body></html>`;
9+
10+
describe("warnOnDimensionMismatch", () => {
11+
let dir: string;
12+
let warnSpy: ReturnType<typeof vi.fn>;
13+
let originalWarn: typeof console.warn;
14+
15+
beforeEach(() => {
16+
dir = mkdtempSync(join(tmpdir(), "hf-dim-mismatch-"));
17+
originalWarn = console.warn;
18+
warnSpy = vi.fn();
19+
console.warn = warnSpy as unknown as typeof console.warn;
20+
});
21+
22+
afterEach(() => {
23+
console.warn = originalWarn;
24+
rmSync(dir, { recursive: true, force: true });
25+
});
26+
27+
function writeIndex(html: string): void {
28+
writeFileSync(join(dir, "index.html"), html);
29+
}
30+
31+
it("warns when the CLI dimensions don't match the composition", () => {
32+
writeIndex(indexHtml(1920, 1080));
33+
warnOnDimensionMismatch({
34+
projectDir: dir,
35+
cliWidth: 3840,
36+
cliHeight: 2160,
37+
outputResolution: undefined,
38+
quiet: false,
39+
});
40+
expect(warnSpy).toHaveBeenCalledTimes(1);
41+
const call = (warnSpy.mock.calls[0]?.[0] as string) ?? "";
42+
expect(call).toContain("3840×2160");
43+
expect(call).toContain("1920×1080");
44+
expect(call).toContain("--output-resolution");
45+
});
46+
47+
it("is silent when CLI and composition agree", () => {
48+
writeIndex(indexHtml(1920, 1080));
49+
warnOnDimensionMismatch({
50+
projectDir: dir,
51+
cliWidth: 1920,
52+
cliHeight: 1080,
53+
outputResolution: undefined,
54+
quiet: false,
55+
});
56+
expect(warnSpy).not.toHaveBeenCalled();
57+
});
58+
59+
it("is silent when --output-resolution is set (the supersampling path)", () => {
60+
writeIndex(indexHtml(1920, 1080));
61+
warnOnDimensionMismatch({
62+
projectDir: dir,
63+
cliWidth: 3840,
64+
cliHeight: 2160,
65+
outputResolution: "landscape-4k",
66+
quiet: false,
67+
});
68+
expect(warnSpy).not.toHaveBeenCalled();
69+
});
70+
71+
it("is silent when quiet=true (--json)", () => {
72+
writeIndex(indexHtml(1920, 1080));
73+
warnOnDimensionMismatch({
74+
projectDir: dir,
75+
cliWidth: 3840,
76+
cliHeight: 2160,
77+
outputResolution: undefined,
78+
quiet: true,
79+
});
80+
expect(warnSpy).not.toHaveBeenCalled();
81+
});
82+
83+
it("is silent when index.html is missing (typical with --site-id)", () => {
84+
warnOnDimensionMismatch({
85+
projectDir: dir,
86+
cliWidth: 3840,
87+
cliHeight: 2160,
88+
outputResolution: undefined,
89+
quiet: false,
90+
});
91+
expect(warnSpy).not.toHaveBeenCalled();
92+
});
93+
94+
it("is silent when the composition has no data-composition-id root", () => {
95+
writeIndex("<body><h1>just a comp</h1></body>");
96+
warnOnDimensionMismatch({
97+
projectDir: dir,
98+
cliWidth: 3840,
99+
cliHeight: 2160,
100+
outputResolution: undefined,
101+
quiet: false,
102+
});
103+
expect(warnSpy).not.toHaveBeenCalled();
104+
});
105+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Shared dimension-mismatch warning for `hyperframes lambda render` and
3+
* `lambda render-batch`. The runtime lays the page out at the composition's
4+
* `data-width`/`data-height`, so passing `--width 3840 --height 2160`
5+
* against a 1920×1080 composition silently produces a 1080p output. Warn
6+
* early and point at `--output-resolution` (the supersampling path).
7+
*/
8+
9+
import { readFileSync } from "node:fs";
10+
import { join } from "node:path";
11+
import type { CanvasResolution } from "@hyperframes/core";
12+
import { c } from "../../ui/colors.js";
13+
import { findCompositionDimensions } from "../../utils/compositionViewport.js";
14+
15+
export interface DimensionMismatchArgs {
16+
projectDir: string;
17+
cliWidth: number;
18+
cliHeight: number;
19+
outputResolution: CanvasResolution | undefined;
20+
/** Suppress the warning when stdout is reserved for machine output (--json). */
21+
quiet: boolean;
22+
}
23+
24+
// fallow-ignore-next-line complexity
25+
export function warnOnDimensionMismatch(args: DimensionMismatchArgs): void {
26+
if (args.quiet) return;
27+
if (args.outputResolution) return;
28+
let html: string;
29+
try {
30+
html = readFileSync(join(args.projectDir, "index.html"), "utf-8");
31+
} catch {
32+
return;
33+
}
34+
const composition = findCompositionDimensions(html);
35+
if (!composition) return;
36+
if (composition.width === args.cliWidth && composition.height === args.cliHeight) return;
37+
console.warn(
38+
c.warn(
39+
`--width/--height (${args.cliWidth}×${args.cliHeight}) disagrees with the composition's ` +
40+
`data-width/data-height (${composition.width}×${composition.height}). The runtime lays out ` +
41+
`the page at the composition's authored dimensions, so your output will be ` +
42+
`${composition.width}×${composition.height}, not ${args.cliWidth}×${args.cliHeight}.\n` +
43+
` To supersample to a higher resolution, pass --output-resolution (e.g. \`--output-resolution=4k\`).\n` +
44+
` To truly change layout dimensions, edit the composition's data-width/data-height in index.html.`,
45+
),
46+
);
47+
}

packages/cli/src/commands/lambda/render-batch.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
reportVariableIssues,
3737
validateVariablesAgainstSchema,
3838
} from "../../utils/variables.js";
39+
import { warnOnDimensionMismatch } from "./_dimensions.js";
3940
import { requireStack } from "./state.js";
4041

4142
// 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> {
165166
process.exit(1);
166167
}
167168

169+
warnOnDimensionMismatch({
170+
projectDir,
171+
cliWidth: args.width,
172+
cliHeight: args.height,
173+
outputResolution: args.outputResolution,
174+
quiet: args.json,
175+
});
176+
168177
// Pre-validate every entry's variables against the composition's
169178
// schema. Mismatches print as warnings; strict mode aborts before any
170179
// AWS call. Schema is loaded once and reused across entries — a 10k-row

packages/cli/src/commands/lambda/render.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
resolveVariablesArg,
1818
validateVariablesAgainstProject,
1919
} from "../../utils/variables.js";
20+
import { warnOnDimensionMismatch } from "./_dimensions.js";
2021
import { requireStack, stateFilePath } from "./state.js";
2122

2223
// 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> {
7273
const stack = requireStack(args.stackName);
7374
const projectDir = resolvePath(args.projectDir);
7475

76+
warnOnDimensionMismatch({
77+
projectDir,
78+
cliWidth: args.width,
79+
cliHeight: args.height,
80+
outputResolution: args.outputResolution,
81+
quiet: args.json,
82+
});
83+
7584
// Resolve --variables / --variables-file using the same parser the local
7685
// `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly
7786
// errorBox on parse errors so callers don't have to.

packages/cli/src/utils/compositionViewport.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,27 @@ function parseViewportDimension(value: string | null): number | null {
1010
return Math.min(parsed, MAX_VIEWPORT_DIMENSION);
1111
}
1212

13+
/**
14+
* Pull `data-width` + `data-height` from the document's first composition
15+
* root (the element with `data-composition-id` plus both dimension attrs
16+
* — the same selector the producer uses to lay out the page). Returns
17+
* `null` when no such root exists or either attr is invalid, so callers
18+
* can distinguish "no declared dimensions" from "declared 1920×1080".
19+
*/
20+
export function findCompositionDimensions(html: string): { width: number; height: number } | null {
21+
ensureDOMParser();
22+
const doc = new DOMParser().parseFromString(html, "text/html");
23+
const root = doc.querySelector("[data-composition-id][data-width][data-height]");
24+
if (!root) return null;
25+
const width = parseViewportDimension(root.getAttribute("data-width"));
26+
const height = parseViewportDimension(root.getAttribute("data-height"));
27+
if (width === null || height === null) return null;
28+
return { width, height };
29+
}
30+
1331
export function resolveCompositionViewportFromHtml(html: string): {
1432
width: number;
1533
height: number;
1634
} {
17-
ensureDOMParser();
18-
const doc = new DOMParser().parseFromString(html, "text/html");
19-
const root = doc.querySelector("[data-composition-id][data-width][data-height]");
20-
const width = parseViewportDimension(root?.getAttribute("data-width") ?? null);
21-
const height = parseViewportDimension(root?.getAttribute("data-height") ?? null);
22-
return {
23-
width: width ?? DEFAULT_VIEWPORT.width,
24-
height: height ?? DEFAULT_VIEWPORT.height,
25-
};
35+
return findCompositionDimensions(html) ?? DEFAULT_VIEWPORT;
2636
}

0 commit comments

Comments
 (0)