diff --git a/packages/cli/src/commands/lambda/_dimensions.test.ts b/packages/cli/src/commands/lambda/_dimensions.test.ts
new file mode 100644
index 000000000..26add43fb
--- /dev/null
+++ b/packages/cli/src/commands/lambda/_dimensions.test.ts
@@ -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) =>
+ `
`;
+
+describe("warnOnDimensionMismatch", () => {
+ let dir: string;
+ let warnSpy: ReturnType;
+ 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("just a comp
");
+ warnOnDimensionMismatch({
+ projectDir: dir,
+ cliWidth: 3840,
+ cliHeight: 2160,
+ outputResolution: undefined,
+ quiet: false,
+ });
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/cli/src/commands/lambda/_dimensions.ts b/packages/cli/src/commands/lambda/_dimensions.ts
new file mode 100644
index 000000000..8aa14d20d
--- /dev/null
+++ b/packages/cli/src/commands/lambda/_dimensions.ts
@@ -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.`,
+ ),
+ );
+}
diff --git a/packages/cli/src/commands/lambda/render-batch.ts b/packages/cli/src/commands/lambda/render-batch.ts
index 50640a10c..2239bc33d 100644
--- a/packages/cli/src/commands/lambda/render-batch.ts
+++ b/packages/cli/src/commands/lambda/render-batch.ts
@@ -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 {
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
diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts
index 25f6c3625..efab3c000 100644
--- a/packages/cli/src/commands/lambda/render.ts
+++ b/packages/cli/src/commands/lambda/render.ts
@@ -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 {
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.
diff --git a/packages/cli/src/utils/compositionViewport.ts b/packages/cli/src/utils/compositionViewport.ts
index c7a840af9..9502ac23b 100644
--- a/packages/cli/src/utils/compositionViewport.ts
+++ b/packages/cli/src/utils/compositionViewport.ts
@@ -10,17 +10,27 @@ function parseViewportDimension(value: string | null): number | null {
return Math.min(parsed, MAX_VIEWPORT_DIMENSION);
}
+/**
+ * Pull `data-width` + `data-height` from the document's first composition
+ * root (the element with `data-composition-id` plus both dimension attrs
+ * — the same selector the producer uses to lay out the page). Returns
+ * `null` when no such root exists or either attr is invalid, so callers
+ * can distinguish "no declared dimensions" from "declared 1920×1080".
+ */
+export function findCompositionDimensions(html: string): { width: number; height: number } | null {
+ ensureDOMParser();
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const root = doc.querySelector("[data-composition-id][data-width][data-height]");
+ if (!root) return null;
+ const width = parseViewportDimension(root.getAttribute("data-width"));
+ const height = parseViewportDimension(root.getAttribute("data-height"));
+ if (width === null || height === null) return null;
+ return { width, height };
+}
+
export function resolveCompositionViewportFromHtml(html: string): {
width: number;
height: number;
} {
- ensureDOMParser();
- const doc = new DOMParser().parseFromString(html, "text/html");
- const root = doc.querySelector("[data-composition-id][data-width][data-height]");
- const width = parseViewportDimension(root?.getAttribute("data-width") ?? null);
- const height = parseViewportDimension(root?.getAttribute("data-height") ?? null);
- return {
- width: width ?? DEFAULT_VIEWPORT.width,
- height: height ?? DEFAULT_VIEWPORT.height,
- };
+ return findCompositionDimensions(html) ?? DEFAULT_VIEWPORT;
}