mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
committed by
James Russo
co-authored by
Claude Opus 4.7
parent
d4384722e8
commit
ec1b7e1eff
@@ -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,
|
reportVariableIssues,
|
||||||
validateVariablesAgainstSchema,
|
validateVariablesAgainstSchema,
|
||||||
} from "../../utils/variables.js";
|
} from "../../utils/variables.js";
|
||||||
|
import { warnOnDimensionMismatch } from "./_dimensions.js";
|
||||||
import { requireStack } from "./state.js";
|
import { requireStack } from "./state.js";
|
||||||
|
|
||||||
// Dynamic-import the SDK so tsup keeps it out of the static-import head of
|
// 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);
|
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
|
// Pre-validate every entry's variables against the composition's
|
||||||
// schema. Mismatches print as warnings; strict mode aborts before any
|
// schema. Mismatches print as warnings; strict mode aborts before any
|
||||||
// AWS call. Schema is loaded once and reused across entries — a 10k-row
|
// AWS call. Schema is loaded once and reused across entries — a 10k-row
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
resolveVariablesArg,
|
resolveVariablesArg,
|
||||||
validateVariablesAgainstProject,
|
validateVariablesAgainstProject,
|
||||||
} from "../../utils/variables.js";
|
} from "../../utils/variables.js";
|
||||||
|
import { warnOnDimensionMismatch } from "./_dimensions.js";
|
||||||
import { requireStack, stateFilePath } from "./state.js";
|
import { requireStack, stateFilePath } from "./state.js";
|
||||||
|
|
||||||
// Dynamic-import the SDK so tsup keeps it out of the static-import head of
|
// 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 stack = requireStack(args.stackName);
|
||||||
const projectDir = resolvePath(args.projectDir);
|
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
|
// Resolve --variables / --variables-file using the same parser the local
|
||||||
// `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly
|
// `hyperframes render` uses. `resolveVariablesArg` exits(1) with a friendly
|
||||||
// errorBox on parse errors so callers don't have to.
|
// errorBox on parse errors so callers don't have to.
|
||||||
|
|||||||
@@ -10,17 +10,27 @@ function parseViewportDimension(value: string | null): number | null {
|
|||||||
return Math.min(parsed, MAX_VIEWPORT_DIMENSION);
|
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): {
|
export function resolveCompositionViewportFromHtml(html: string): {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
} {
|
} {
|
||||||
ensureDOMParser();
|
return findCompositionDimensions(html) ?? DEFAULT_VIEWPORT;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user