mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* feat(registry): add texture mask PNGs for texture-mask-text component * feat(registry): add texture-mask-text CSS snippet * feat(registry): add registry-item.json for texture-mask-text * feat(registry): add texture-mask-text demo composition * feat(registry): register texture-mask-text component in manifest * style: format texture-mask-text files with oxfmt * fix: set mask-image directly on texture classes instead of via CSS custom property url() inside CSS custom properties doesn't resolve correctly with mask-image in some browsers. Move mask-image declarations to each texture class directly. * docs: add texture mask text catalog entry * test: lint texture mask text usage * fix: harden texture mask text docs and lint * fix: stabilize texture mask asset paths * fix: address texture catalog review feedback * fix: harden texture mask text instructions * docs: remove texture catalog intro copy * docs: use canonical texture preview URL * docs: use cdn texture mask assets * fix: escape catalog frontmatter safely * test: stabilize windows render cli test * test: pin texture catalog instructions
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const producerState = vi.hoisted(() => ({
|
|
createdJobs: [] as Array<Record<string, unknown>>,
|
|
resolveConfigCalls: [] as Array<Record<string, unknown>>,
|
|
}));
|
|
|
|
vi.mock("../utils/producer.js", () => ({
|
|
loadProducer: vi.fn(async () => ({
|
|
resolveConfig: vi.fn((overrides: Record<string, unknown>) => {
|
|
producerState.resolveConfigCalls.push(overrides);
|
|
return { ...overrides, resolved: true };
|
|
}),
|
|
createRenderJob: vi.fn((config: Record<string, unknown>) => {
|
|
producerState.createdJobs.push(config);
|
|
return { config, progress: 100 };
|
|
}),
|
|
executeRenderJob: vi.fn(async () => undefined),
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../telemetry/events.js", () => ({
|
|
trackRenderComplete: vi.fn(),
|
|
trackRenderError: vi.fn(),
|
|
}));
|
|
|
|
describe("renderLocal browser GPU config", () => {
|
|
const savedEnv = new Map<string, string | undefined>();
|
|
// Pre-resolve once. The first dynamic `import("./render.js")` in this file
|
|
// takes >5 s on Windows runners (cold module load) — long enough to blow
|
|
// vitest's default 5 s timeout in whichever test happens to be first. When
|
|
// that test times out, its leaked late `createRenderJob` call lands AFTER
|
|
// the next test's `beforeEach` clears `producerState.createdJobs`, shifting
|
|
// index 0 and corrupting unrelated assertions. Importing once in
|
|
// `beforeAll` keeps every test fast and isolated.
|
|
let renderLocal: typeof import("./render.js").renderLocal;
|
|
let resolveBrowserGpuForCli: typeof import("./render.js").resolveBrowserGpuForCli;
|
|
|
|
beforeAll(async () => {
|
|
({ renderLocal, resolveBrowserGpuForCli } = await import("./render.js"));
|
|
});
|
|
|
|
function setEnv(key: string, value: string) {
|
|
savedEnv.set(key, process.env[key]);
|
|
process.env[key] = value;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
producerState.createdJobs = [];
|
|
producerState.resolveConfigCalls = [];
|
|
savedEnv.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const [key, value] of savedEnv) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
vi.clearAllMocks();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("passes an explicit software override for --no-browser-gpu even when env requests hardware", async () => {
|
|
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
|
|
|
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
gpu: false,
|
|
browserGpuMode: "software",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
});
|
|
|
|
expect(producerState.resolveConfigCalls).toContainEqual({ browserGpuMode: "software" });
|
|
expect(producerState.createdJobs[0]?.producerConfig).toMatchObject({
|
|
browserGpuMode: "software",
|
|
resolved: true,
|
|
});
|
|
}, 15_000);
|
|
|
|
it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => {
|
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
gpu: false,
|
|
browserGpuMode: "auto",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
});
|
|
|
|
expect(producerState.resolveConfigCalls).toContainEqual({ browserGpuMode: "auto" });
|
|
expect(producerState.createdJobs[0]?.producerConfig).toMatchObject({
|
|
browserGpuMode: "auto",
|
|
resolved: true,
|
|
});
|
|
});
|
|
|
|
it("passes an explicit hardware override for default local browser GPU", async () => {
|
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
gpu: false,
|
|
browserGpuMode: "hardware",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
});
|
|
|
|
expect(producerState.resolveConfigCalls).toContainEqual({ browserGpuMode: "hardware" });
|
|
expect(producerState.createdJobs[0]?.producerConfig).toMatchObject({
|
|
browserGpuMode: "hardware",
|
|
resolved: true,
|
|
});
|
|
});
|
|
|
|
it("resolves browser GPU from CLI flags, Docker mode, and env fallback", () => {
|
|
// Default (no flag, no env): auto — engine probes and chooses.
|
|
expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe("auto");
|
|
// Env override
|
|
expect(resolveBrowserGpuForCli(false, undefined, "hardware")).toBe("hardware");
|
|
expect(resolveBrowserGpuForCli(false, undefined, "software")).toBe("software");
|
|
expect(resolveBrowserGpuForCli(false, undefined, "auto")).toBe("auto");
|
|
// Explicit CLI flag wins over env
|
|
expect(resolveBrowserGpuForCli(false, true, "software")).toBe("hardware");
|
|
expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe("software");
|
|
// Docker forces software regardless of flags/env
|
|
expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe("software");
|
|
expect(resolveBrowserGpuForCli(true, undefined, "auto")).toBe("software");
|
|
});
|
|
|
|
it("forwards parsed --variables payload to createRenderJob", async () => {
|
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
gpu: false,
|
|
browserGpuMode: "software",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
variables: { title: "Hello", count: 3 },
|
|
});
|
|
|
|
expect(producerState.createdJobs[0]?.variables).toEqual({ title: "Hello", count: 3 });
|
|
});
|
|
|
|
it("forwards format: png-sequence through to createRenderJob", async () => {
|
|
await renderLocal("/tmp/project", "/tmp/frames", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "png-sequence",
|
|
gpu: false,
|
|
browserGpuMode: "software",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
});
|
|
|
|
expect(producerState.createdJobs[0]?.format).toBe("png-sequence");
|
|
});
|
|
|
|
it("omits variables from createRenderJob when not provided", async () => {
|
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
gpu: false,
|
|
browserGpuMode: "software",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
});
|
|
|
|
expect(producerState.createdJobs[0]?.variables).toBeUndefined();
|
|
});
|
|
|
|
it("forwards entryFile to createRenderJob when --composition is set", async () => {
|
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
gpu: false,
|
|
browserGpuMode: "software",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
entryFile: "compositions/intro.html",
|
|
});
|
|
|
|
expect(producerState.createdJobs[0]?.entryFile).toBe("compositions/intro.html");
|
|
});
|
|
|
|
it("omits entryFile from createRenderJob when --composition is not set", async () => {
|
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
gpu: false,
|
|
browserGpuMode: "software",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
});
|
|
|
|
expect(producerState.createdJobs[0]?.entryFile).toBeUndefined();
|
|
});
|
|
|
|
it("can force the CLI process to exit after a successful local render", async () => {
|
|
vi.useFakeTimers();
|
|
const exit = vi
|
|
.spyOn(process, "exit")
|
|
.mockImplementation((code?: string | number | null): never => {
|
|
throw new Error(`process.exit:${code ?? ""}`);
|
|
});
|
|
|
|
await renderLocal("/tmp/project", "/tmp/out.mp4", {
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
gpu: false,
|
|
browserGpuMode: "hardware",
|
|
hdrMode: "auto",
|
|
quiet: true,
|
|
exitAfterComplete: true,
|
|
});
|
|
|
|
expect(exit).not.toHaveBeenCalled();
|
|
expect(() => vi.advanceTimersByTime(100)).toThrow("process.exit:0");
|
|
expect(exit).toHaveBeenCalledWith(0);
|
|
});
|
|
});
|
|
|
|
describe("parseVariablesArg", () => {
|
|
let parseVariablesArg: typeof import("./render.js").parseVariablesArg;
|
|
|
|
beforeAll(async () => {
|
|
({ parseVariablesArg } = await import("./render.js"));
|
|
});
|
|
|
|
function expectErr<T extends { kind: string }>(
|
|
result: import("./render.js").VariablesParseResult,
|
|
): T {
|
|
if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`);
|
|
return result.error as T;
|
|
}
|
|
|
|
it("returns undefined when neither flag is set", () => {
|
|
expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined });
|
|
});
|
|
|
|
it("parses inline JSON object", () => {
|
|
expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({
|
|
ok: true,
|
|
value: { title: "Hello", n: 3 },
|
|
});
|
|
});
|
|
|
|
it("parses file JSON via injected reader", () => {
|
|
const fakeReader = (path: string) => {
|
|
if (path === "vars.json") return '{"theme":"dark"}';
|
|
throw new Error("unexpected path");
|
|
};
|
|
expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({
|
|
ok: true,
|
|
value: { theme: "dark" },
|
|
});
|
|
});
|
|
|
|
it("rejects when both flags are set", () => {
|
|
const err = expectErr(parseVariablesArg('{"a":1}', "vars.json"));
|
|
expect(err).toEqual({ kind: "conflict" });
|
|
});
|
|
|
|
it("rejects unparseable JSON with a source-aware kind", () => {
|
|
expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({
|
|
kind: "parse-error",
|
|
source: "inline",
|
|
});
|
|
expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({
|
|
kind: "parse-error",
|
|
source: "file",
|
|
});
|
|
});
|
|
|
|
it("rejects non-object payloads (array, string, null, number)", () => {
|
|
for (const payload of ["[1,2]", '"hello"', "null", "42"]) {
|
|
expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" });
|
|
}
|
|
});
|
|
|
|
it("surfaces filesystem errors from --variables-file", () => {
|
|
const err = expectErr<{
|
|
kind: "read-error";
|
|
path: string;
|
|
cause: string;
|
|
}>(
|
|
parseVariablesArg(undefined, "missing.json", () => {
|
|
throw new Error("ENOENT: no such file");
|
|
}),
|
|
);
|
|
expect(err.kind).toBe("read-error");
|
|
expect(err.path).toBe("missing.json");
|
|
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([]);
|
|
});
|
|
});
|