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; }