Skip to content

Commit b67ce68

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 d8851cf commit b67ce68

4 files changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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 { readCompositionDimensions, warnOnDimensionMismatch } from "./_dimensions.js";
6+
7+
describe("readCompositionDimensions", () => {
8+
it("parses width-first attribute order", () => {
9+
const html =
10+
'<!doctype html><html><body data-width="1920" data-height="1080" data-duration="5"></body></html>';
11+
expect(readCompositionDimensions(html)).toEqual({ width: 1920, height: 1080 });
12+
});
13+
14+
it("parses height-first attribute order", () => {
15+
const html =
16+
'<!doctype html><html><body data-duration="5" data-height="2160" data-width="3840"></body></html>';
17+
expect(readCompositionDimensions(html)).toEqual({ width: 3840, height: 2160 });
18+
});
19+
20+
it("tolerates single quotes", () => {
21+
const html = "<body data-width='1080' data-height='1920'></body>";
22+
expect(readCompositionDimensions(html)).toEqual({ width: 1080, height: 1920 });
23+
});
24+
25+
it("returns null when an attribute is missing", () => {
26+
expect(readCompositionDimensions('<body data-width="1920"></body>')).toBeNull();
27+
});
28+
29+
it("returns null on empty HTML", () => {
30+
expect(readCompositionDimensions("")).toBeNull();
31+
});
32+
});
33+
34+
describe("warnOnDimensionMismatch", () => {
35+
let dir: string;
36+
let warnSpy: ReturnType<typeof vi.fn>;
37+
let originalWarn: typeof console.warn;
38+
39+
beforeEach(() => {
40+
dir = mkdtempSync(join(tmpdir(), "hf-dim-mismatch-"));
41+
originalWarn = console.warn;
42+
warnSpy = vi.fn();
43+
console.warn = warnSpy as unknown as typeof console.warn;
44+
});
45+
46+
afterEach(() => {
47+
console.warn = originalWarn;
48+
rmSync(dir, { recursive: true, force: true });
49+
});
50+
51+
function writeIndex(html: string): void {
52+
writeFileSync(join(dir, "index.html"), html);
53+
}
54+
55+
it("warns when the CLI dimensions don't match the composition", () => {
56+
writeIndex('<body data-width="1920" data-height="1080"></body>');
57+
warnOnDimensionMismatch({
58+
projectDir: dir,
59+
cliWidth: 3840,
60+
cliHeight: 2160,
61+
outputResolution: undefined,
62+
quiet: false,
63+
});
64+
expect(warnSpy).toHaveBeenCalledTimes(1);
65+
const call = (warnSpy.mock.calls[0]?.[0] as string) ?? "";
66+
expect(call).toContain("3840×2160");
67+
expect(call).toContain("1920×1080");
68+
expect(call).toContain("--output-resolution");
69+
});
70+
71+
it("is silent when CLI and composition agree", () => {
72+
writeIndex('<body data-width="1920" data-height="1080"></body>');
73+
warnOnDimensionMismatch({
74+
projectDir: dir,
75+
cliWidth: 1920,
76+
cliHeight: 1080,
77+
outputResolution: undefined,
78+
quiet: false,
79+
});
80+
expect(warnSpy).not.toHaveBeenCalled();
81+
});
82+
83+
it("is silent when --output-resolution is set (the supported supersampling path)", () => {
84+
writeIndex('<body data-width="1920" data-height="1080"></body>');
85+
warnOnDimensionMismatch({
86+
projectDir: dir,
87+
cliWidth: 3840,
88+
cliHeight: 2160,
89+
outputResolution: "landscape-4k",
90+
quiet: false,
91+
});
92+
expect(warnSpy).not.toHaveBeenCalled();
93+
});
94+
95+
it("is silent when quiet=true (--json)", () => {
96+
writeIndex('<body data-width="1920" data-height="1080"></body>');
97+
warnOnDimensionMismatch({
98+
projectDir: dir,
99+
cliWidth: 3840,
100+
cliHeight: 2160,
101+
outputResolution: undefined,
102+
quiet: true,
103+
});
104+
expect(warnSpy).not.toHaveBeenCalled();
105+
});
106+
107+
it("is silent when index.html is missing (typical with --site-id)", () => {
108+
warnOnDimensionMismatch({
109+
projectDir: dir,
110+
cliWidth: 3840,
111+
cliHeight: 2160,
112+
outputResolution: undefined,
113+
quiet: false,
114+
});
115+
expect(warnSpy).not.toHaveBeenCalled();
116+
});
117+
118+
it("is silent when the composition has no data-width/data-height", () => {
119+
writeIndex("<body><h1>just a comp</h1></body>");
120+
warnOnDimensionMismatch({
121+
projectDir: dir,
122+
cliWidth: 3840,
123+
cliHeight: 2160,
124+
outputResolution: undefined,
125+
quiet: false,
126+
});
127+
expect(warnSpy).not.toHaveBeenCalled();
128+
});
129+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Shared dimension-mismatch warning for `hyperframes lambda render` and
3+
* `hyperframes lambda render-batch`. The Lambda renderer lays the page out
4+
* at the composition's authored `data-width` / `data-height` — passing
5+
* `--width 3840 --height 2160` against a composition with
6+
* `data-width="1920"` silently produces a 1080p output. Warn early and
7+
* point at `--output-resolution` (the supersampling escape hatch).
8+
*/
9+
10+
import { existsSync, readFileSync } from "node:fs";
11+
import { join } from "node:path";
12+
import type { CanvasResolution } from "@hyperframes/core";
13+
import { c } from "../../ui/colors.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+
/**
25+
* Print a warning when `--width`/`--height` disagrees with the
26+
* composition's `data-width`/`data-height`. Best-effort regex parse —
27+
* robust enough for the canonical shape that every composition in the
28+
* wild uses; malformed HTML silently falls through.
29+
*
30+
* Skipped when `--output-resolution` is set (the intended supersampling
31+
* path) or when the project dir isn't on disk (typical with --site-id
32+
* pointing at a pre-uploaded site packaged on another machine).
33+
*/
34+
// fallow-ignore-next-line complexity
35+
export function warnOnDimensionMismatch(args: DimensionMismatchArgs): void {
36+
if (args.quiet) return;
37+
if (args.outputResolution) return;
38+
const indexPath = join(args.projectDir, "index.html");
39+
if (!existsSync(indexPath)) return;
40+
const html = safeReadFile(indexPath);
41+
if (!html) return;
42+
const composition = readCompositionDimensions(html);
43+
if (!composition) return;
44+
const widthMismatch = composition.width !== args.cliWidth;
45+
const heightMismatch = composition.height !== args.cliHeight;
46+
if (!widthMismatch && !heightMismatch) return;
47+
console.warn(
48+
c.warn(
49+
`--width/--height (${args.cliWidth}×${args.cliHeight}) disagrees with the composition's ` +
50+
`data-width/data-height (${composition.width}×${composition.height}). The runtime lays out ` +
51+
`the page at the composition's authored dimensions, so your output will be ` +
52+
`${composition.width}×${composition.height}, not ${args.cliWidth}×${args.cliHeight}.\n` +
53+
` To supersample to a higher resolution, pass --output-resolution (e.g. \`--output-resolution=4k\`).\n` +
54+
` To truly change layout dimensions, edit the composition's data-width/data-height in index.html.`,
55+
),
56+
);
57+
}
58+
59+
function safeReadFile(path: string): string | null {
60+
try {
61+
return readFileSync(path, "utf-8");
62+
} catch {
63+
return null;
64+
}
65+
}
66+
67+
/**
68+
* Pull `data-width` + `data-height` from the document's first element
69+
* carrying both. Canonical shape is the outermost `<body>` or wrapper
70+
* `<div>`. Match either attribute order and any quote style (or none).
71+
* Exported so the warning helper has a unit-testable seam without going
72+
* through the I/O path.
73+
*/
74+
export function readCompositionDimensions(html: string): { width: number; height: number } | null {
75+
const widthFirst = html.match(
76+
/data-width\s*=\s*["']?(\d+)["']?[^>]*?data-height\s*=\s*["']?(\d+)["']?/,
77+
);
78+
if (widthFirst) {
79+
return { width: Number(widthFirst[1]), height: Number(widthFirst[2]) };
80+
}
81+
const heightFirst = html.match(
82+
/data-height\s*=\s*["']?(\d+)["']?[^>]*?data-width\s*=\s*["']?(\d+)["']?/,
83+
);
84+
if (heightFirst) {
85+
return { width: Number(heightFirst[2]), height: Number(heightFirst[1]) };
86+
}
87+
return null;
88+
}

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

Lines changed: 13 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,18 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
165166
process.exit(1);
166167
}
167168

169+
// Catch the --width/--height vs composition data-width/data-height
170+
// footgun once before fanning out N executions (a 1000-row batch with
171+
// the wrong dimensions burns the same per-render cost across every
172+
// row). Skipped under --json / --dry-run quiet expectations.
173+
warnOnDimensionMismatch({
174+
projectDir,
175+
cliWidth: args.width,
176+
cliHeight: args.height,
177+
outputResolution: args.outputResolution,
178+
quiet: args.json,
179+
});
180+
168181
// Pre-validate every entry's variables against the composition's
169182
// schema. Mismatches print as warnings; strict mode aborts before any
170183
// AWS call. Schema is loaded once and reused across entries — a 10k-row

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

Lines changed: 18 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,23 @@ export async function runRender(args: RenderArgs): Promise<void> {
7273
const stack = requireStack(args.stackName);
7374
const projectDir = resolvePath(args.projectDir);
7475

76+
// Cheap up-front dimension-mismatch check. The composition's
77+
// `data-width`/`data-height` attrs override `Config.width`/`Config.height`
78+
// at runtime, so passing `--width 3840 --height 2160` against a
79+
// composition authored at 1920×1080 silently produces a 1080p output —
80+
// a real footgun we hit during the cost-analysis sweep. Warn early and
81+
// point at `--output-resolution` (the supported supersampling path) so
82+
// the user doesn't burn a 30-minute render learning this. Suppressed
83+
// when `--json` is set so machine consumers can parse the manifest, and
84+
// skipped when the project dir isn't on disk (typical with --site-id).
85+
warnOnDimensionMismatch({
86+
projectDir,
87+
cliWidth: args.width,
88+
cliHeight: args.height,
89+
outputResolution: args.outputResolution,
90+
quiet: args.json,
91+
});
92+
7593
// Resolve --variables / --variables-file using the same parser the local
7694
// `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly
7795
// errorBox on parse errors so callers don't have to.

0 commit comments

Comments
 (0)