Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--quiet` | — | off | Suppress verbose output |
| `--variables` | JSON object | — | Variable overrides merged over `data-composition-variables` defaults. Read via `window.__hyperframes.getVariables()` |
| `--variables-file` | path | — | Path to a JSON file with variable overrides (alternative to `--variables`) |
| `--strict-variables` | — | off | Fail render if any `--variables` key is undeclared or has a wrong type vs the composition's `data-composition-variables`. Without this flag, mismatches print as warnings and the render continues. |

CRF and target bitrate default to the `--quality` preset. Use `--crf` or `--video-bitrate` for fine-grained overrides; `RenderConfig.crf` and `RenderConfig.videoBitrate` accept the same overrides programmatically.

Expand Down
67 changes: 67 additions & 0 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,70 @@ describe("parseVariablesArg", () => {
expect(err.cause).toMatch(/ENOENT/);
});
});

describe("validateVariablesAgainstProject", () => {
let validateVariablesAgainstProject: typeof import("./render.js").validateVariablesAgainstProject;
let tmpDir: string;
let mkdtempSync: typeof import("node:fs").mkdtempSync;
let writeFileSync: typeof import("node:fs").writeFileSync;
let rmSync: typeof import("node:fs").rmSync;
let join: typeof import("node:path").join;
let tmpdir: typeof import("node:os").tmpdir;

beforeAll(async () => {
({ validateVariablesAgainstProject } = await import("./render.js"));
({ mkdtempSync, writeFileSync, rmSync } = await import("node:fs"));
({ join } = await import("node:path"));
({ tmpdir } = await import("node:os"));
});

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "hf-validate-vars-"));
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

function writeIndex(html: string): string {
const path = join(tmpDir, "index.html");
writeFileSync(path, html);
return path;
}

it("returns [] when the project has no data-composition-variables declarations", () => {
const indexPath = writeIndex(`<html><body><div data-composition-id="x"></div></body></html>`);
expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
});

it("returns [] when every value matches its declaration", () => {
const indexPath = writeIndex(
`<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"x"}]'><body><div data-composition-id="root"></div></body></html>`,
);
expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
});

it("flags undeclared keys", () => {
const indexPath = writeIndex(
`<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"x"}]'><body><div data-composition-id="root"></div></body></html>`,
);
expect(validateVariablesAgainstProject(indexPath, { title: "Hello", extra: 1 })).toEqual([
{ kind: "undeclared", variableId: "extra" },
]);
});

it("flags type mismatches", () => {
const indexPath = writeIndex(
`<html data-composition-variables='[{"id":"count","type":"number","label":"Count","default":0}]'><body><div data-composition-id="root"></div></body></html>`,
);
expect(validateVariablesAgainstProject(indexPath, { count: "three" })).toEqual([
{ kind: "type-mismatch", variableId: "count", expected: "number", actual: "string" },
]);
});

it("returns [] when the index file cannot be read (lint owns that diagnostic)", () => {
expect(
validateVariablesAgainstProject(join(tmpDir, "missing.html"), { title: "Hello" }),
).toEqual([]);
});
});
67 changes: 67 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ import { bytesToMb } from "../telemetry/system.js";
import { VERSION } from "../version.js";
import { isDevMode } from "../utils/env.js";
import { buildDockerRunArgs } from "../utils/dockerRunArgs.js";
import { ensureDOMParser } from "../utils/dom.js";
import type { RenderJob } from "@hyperframes/producer";
import {
extractCompositionMetadata,
validateVariables,
formatVariableValidationIssue,
type VariableValidationIssue,
} from "@hyperframes/core";

const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
Expand Down Expand Up @@ -142,6 +149,12 @@ export default defineCommand({
description:
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
},
"strict-variables": {
type: "boolean",
description:
"Fail render if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.",
default: false,
},
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
Expand Down Expand Up @@ -349,6 +362,33 @@ export default defineCommand({
// ── Resolve --variables / --variables-file ──────────────────────────
const variables = resolveVariablesArg(args.variables, args["variables-file"]);

// ── Validate --variables against data-composition-variables ─────────
const strictVariables = args["strict-variables"] ?? false;
if (variables && Object.keys(variables).length > 0) {
const issues = validateVariablesAgainstProject(project.indexPath, variables);
if (issues.length > 0) {
if (!quiet) {
console.log("");
console.log(
c.warn(
`Variable ${issues.length === 1 ? "issue" : "issues"} (${issues.length}) — values may not render as expected:`,
),
);
for (const issue of issues) {
console.log(" " + c.dim(formatVariableValidationIssue(issue)));
}
console.log("");
}
if (strictVariables) {
console.log(
c.error(" Aborting render due to variable issues (--strict-variables mode)."),
);
console.log("");
process.exit(1);
}
}
}

// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
Expand Down Expand Up @@ -509,6 +549,33 @@ export function resolveVariablesArg(
return result.value;
}

/**
* Validate `--variables` values against the project's top-level
* `data-composition-variables` declarations. Returns an empty array when
* the index has no declarations or when every key is declared with a
* matching type. Errors reading the index are silently treated as "no
* declarations" — the lint pass owns malformed-HTML diagnostics, render
* shouldn't fail just because the schema is unreadable.
*/
export function validateVariablesAgainstProject(
indexPath: string,
values: Record<string, unknown>,
): VariableValidationIssue[] {
let html: string;
try {
html = readFileSync(indexPath, "utf8");
} catch {
return [];
}
// extractCompositionMetadata uses DOMParser, which Node doesn't ship.
// Same pattern as `compositions.ts` and other CLI commands that touch
// @hyperframes/core's HTML parsers.
ensureDOMParser();
const meta = extractCompositionMetadata(html);
if (meta.variables.length === 0) return [];
return validateVariables(values, meta.variables);
}

export function resolveBrowserGpuForCli(
useDocker: boolean,
browserGpuArg: boolean | undefined,
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/core.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ export interface TimelineCompositionElement extends TimelineElementBase {
// Composition Variable Types
export type CompositionVariableType = "string" | "number" | "color" | "boolean" | "enum";

/**
* Runtime list of every valid `CompositionVariableType`. Use this anywhere
* a Set/array of valid type strings is needed (lint rules, validators).
* The `satisfies` guard turns adding a new variant to the union without
* also adding it here into a compile error.
*/
export const COMPOSITION_VARIABLE_TYPES = [
"string",
"number",
"color",
"boolean",
"enum",
] as const satisfies readonly CompositionVariableType[];

export interface CompositionVariableBase {
id: string;
type: CompositionVariableType;
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
CANVAS_DIMENSIONS,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
COMPOSITION_VARIABLE_TYPES,
isTextElement,
isMediaElement,
isCompositionElement,
Expand Down Expand Up @@ -169,6 +170,13 @@ export type { FitTextOptions, FitTextResult } from "./text/index.js";
// Runtime helpers (composition-side)
export { getVariables } from "./runtime/getVariables.js";

// Variable validation (CLI / tooling-side)
export {
validateVariables,
formatVariableValidationIssue,
type VariableValidationIssue,
} from "./runtime/validateVariables.js";

// Registry
export type {
ItemType,
Expand Down
113 changes: 113 additions & 0 deletions packages/core/src/lint/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,4 +644,117 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
});

describe("invalid_variable_values_json", () => {
it("warns when data-variable-values is unparseable JSON", () => {
const html = `<html><body>
<div data-composition-id="card-1" data-composition-src="card.html" data-variable-values='{not json'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});

it("warns when data-variable-values is a JSON array (must be an object)", () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='[1,2,3]'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
expect(finding?.message).toMatch(/must be a JSON object/);
});

it("warns when data-variable-values is a JSON string (must be an object)", () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='"hello"'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
});

it("does not warn for a valid JSON object", () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='{"title":"Hello","count":3}'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeUndefined();
});

it("does not warn when data-variable-values is absent", () => {
const html = `<html><body>
<div data-composition-src="card.html"></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeUndefined();
});
});

describe("invalid_composition_variables_declaration", () => {
it("warns when data-composition-variables is unparseable JSON", () => {
const html = `<html data-composition-variables='[{not json'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeDefined();
});

it("warns when data-composition-variables is not an array", () => {
const html = `<html data-composition-variables='{"title":"Hello"}'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeDefined();
expect(finding?.message).toMatch(/array of variable declarations/);
});

it("warns per-entry when an entry is missing required fields", () => {
const html = `<html data-composition-variables='[{"id":"ok","type":"string","label":"Ok","default":"x"},{"id":"bad"}]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const findings = result.findings.filter(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(findings.length).toBe(1);
expect(findings[0]?.message).toMatch(/\[1\]/);
expect(findings[0]?.message).toMatch(/type|label|default/);
});

it("warns when a declaration uses an unknown type", () => {
const html = `<html data-composition-variables='[{"id":"x","type":"date","label":"X","default":"y"}]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeDefined();
expect(finding?.message).toMatch(/type/);
});

it("does not warn for a fully valid declarations array", () => {
const html = `<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Hello"},
{"id":"count","type":"number","label":"Count","default":3},
{"id":"theme","type":"enum","label":"Theme","default":"light","options":[{"value":"light","label":"Light"}]}
]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeUndefined();
});

it("does not warn when data-composition-variables is absent", () => {
const html = `<html><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeUndefined();
});
});
});
Loading
Loading