mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join } from "node:path";
|
|
import { runCommand } from "citty";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
|
|
getHfColorGradingCapabilities,
|
|
} from "@hyperframes/core";
|
|
import {
|
|
applyMediaTreatmentToHtml,
|
|
getMediaTreatmentCapabilityDetail,
|
|
getMediaTreatmentCapabilityOverview,
|
|
mediaTreatmentCommand,
|
|
resolveMediaTreatmentSource,
|
|
} from "./media-treatment.js";
|
|
import { CliRuntimeError } from "../utils/commandResult.js";
|
|
|
|
const VIDEO = `<!doctype html><html><body><video id="hero" src="hero.mp4"></video></body></html>`;
|
|
|
|
describe("applyMediaTreatmentToHtml", () => {
|
|
it("provides a concise first-hop overview of the complete treatment surface", () => {
|
|
const overview = getMediaTreatmentCapabilityOverview();
|
|
|
|
expect(overview.families.find(({ id }) => id === "correction")).not.toHaveProperty("items");
|
|
expect(overview.families.find(({ id }) => id === "grading")).toMatchObject({
|
|
label: "Color Grading",
|
|
});
|
|
expect(overview.families.find(({ id }) => id === "art")).not.toHaveProperty("items");
|
|
expect(overview.families.find(({ id }) => id === "overlays")).toMatchObject({
|
|
owner: "registry",
|
|
});
|
|
expect(overview.families.some(({ id }) => id === "looks" || id === "treatments")).toBe(false);
|
|
const discoveredEffects = ["essentials", "retro-glitch", "print", "art"].flatMap((family) => {
|
|
const detail = getMediaTreatmentCapabilityDetail(family);
|
|
return (
|
|
typeof detail === "object" &&
|
|
detail !== null &&
|
|
"effects" in detail &&
|
|
Array.isArray(detail.effects)
|
|
? detail.effects
|
|
: []
|
|
).map((effect) =>
|
|
typeof effect === "object" && effect && "id" in effect ? effect.id : null,
|
|
);
|
|
});
|
|
expect(discoveredEffects.sort()).toEqual([...HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS].sort());
|
|
expect(JSON.stringify(overview).length).toBeLessThan(3_000);
|
|
});
|
|
|
|
it("returns focused controls and apply data for one capability", () => {
|
|
expect(getMediaTreatmentCapabilityDetail("kuwahara")).toMatchObject({
|
|
id: "kuwahara",
|
|
family: "art",
|
|
renderLane: "multipass",
|
|
apply: { effects: { kuwahara: 1 } },
|
|
animation: {
|
|
property: expect.objectContaining({ path: "effects.kuwahara" }),
|
|
initial: expect.stringContaining("--hf-color-grading-kuwahara"),
|
|
tween: expect.stringContaining("timeline.to"),
|
|
},
|
|
});
|
|
expect(getMediaTreatmentCapabilityDetail("retro-glitch")).toMatchObject({
|
|
id: "retro-glitch",
|
|
effects: expect.arrayContaining([expect.objectContaining({ id: "chromaBleed" })]),
|
|
});
|
|
expect(getMediaTreatmentCapabilityDetail("deep-sea")).toMatchObject({
|
|
id: "deep-sea",
|
|
apply: { palette: expect.arrayContaining(["#0a1628"]) },
|
|
});
|
|
expect(getMediaTreatmentCapabilityDetail("exposure")).toMatchObject({
|
|
id: "exposure",
|
|
family: "correction",
|
|
animation: {
|
|
property: expect.objectContaining({ path: "adjust.exposure" }),
|
|
},
|
|
});
|
|
expect(getMediaTreatmentCapabilityDetail("vignette")).toMatchObject({
|
|
id: "vignette",
|
|
family: "finishing",
|
|
control: expect.objectContaining({ key: "vignette" }),
|
|
});
|
|
expect(getMediaTreatmentCapabilityDetail("wheels")).toMatchObject({
|
|
contract: expect.objectContaining({ zones: ["shadows", "midtones", "highlights"] }),
|
|
});
|
|
expect(getMediaTreatmentCapabilityDetail("curves")).toMatchObject({
|
|
contract: expect.objectContaining({ channels: ["master", "red", "green", "blue"] }),
|
|
});
|
|
const hueCurves = getMediaTreatmentCapabilityDetail("hue-curves");
|
|
const serializedHueCurves = JSON.stringify(hueCurves);
|
|
expect(serializedHueCurves).toContain('"maxPoints":16');
|
|
expect(serializedHueCurves).toContain('"key":"hueVsHue"');
|
|
expect(serializedHueCurves).toContain('"key":"hueVsSaturation"');
|
|
expect(serializedHueCurves).toContain('"key":"hueVsLuma"');
|
|
expect(getMediaTreatmentCapabilityDetail("secondary")).toMatchObject({
|
|
contract: expect.objectContaining({
|
|
max: 4,
|
|
saturation: expect.objectContaining({ relation: "min < max" }),
|
|
luma: expect.objectContaining({ relation: "min < max" }),
|
|
}),
|
|
});
|
|
expect(getMediaTreatmentCapabilityDetail("grading")).toMatchObject({
|
|
order: [
|
|
"adjust",
|
|
"wheels",
|
|
"curves",
|
|
"hueCurves",
|
|
"secondaries",
|
|
"lut",
|
|
"details",
|
|
"effects",
|
|
],
|
|
});
|
|
expect(getMediaTreatmentCapabilityDetail("scopes")).toMatchObject({
|
|
command: expect.stringContaining("--analyze"),
|
|
});
|
|
});
|
|
|
|
it("rejects unknown capability lookups", () => {
|
|
expect(() => getMediaTreatmentCapabilityDetail("make-it-cinematic")).toThrow(
|
|
/Unknown media-treatment capability/,
|
|
);
|
|
expect(() => getMediaTreatmentCapabilityDetail("__proto__")).toThrow(
|
|
/Unknown media-treatment capability/,
|
|
);
|
|
});
|
|
|
|
it("exposes enough canonical metadata to assemble a custom treatment", () => {
|
|
const capabilities = getHfColorGradingCapabilities();
|
|
|
|
expect(capabilities.targetTags).toEqual(["img", "video"]);
|
|
expect(capabilities.effects.find(({ key }) => key === "kuwahara")?.apply).toMatchObject({
|
|
kuwahara: 1,
|
|
kuwaharaRadius: 1 / 7,
|
|
});
|
|
expect(capabilities.animatable.find(({ path }) => path === "effects.blur")?.name).toBe(
|
|
"--hf-color-grading-blur",
|
|
);
|
|
});
|
|
|
|
it("normalizes and persists a grading payload on real media", () => {
|
|
const result = applyMediaTreatmentToHtml(VIDEO, {
|
|
selector: "#hero",
|
|
grading: { preset: "warm-daylight", intensity: 0.8 },
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.tag).toBe("video");
|
|
expect(result.value).toContain('"preset":"warm-daylight"');
|
|
expect(result.value).toContain('"intensity":0.8');
|
|
expect(result.html).toContain("data-color-grading=");
|
|
});
|
|
|
|
it("normalizes and persists advanced grading on real media", () => {
|
|
const result = applyMediaTreatmentToHtml(VIDEO, {
|
|
selector: "#hero",
|
|
grading: {
|
|
wheels: { shadows: { hue: 205, amount: 0.08, level: 0.02 } },
|
|
curves: {
|
|
master: [
|
|
[0, 0],
|
|
[0.5, 0.55],
|
|
[1, 1],
|
|
],
|
|
},
|
|
hueCurves: {
|
|
hueVsSaturation: [
|
|
[180, 0],
|
|
[210, 0.15],
|
|
[240, 0],
|
|
],
|
|
},
|
|
secondaries: [
|
|
{
|
|
key: {
|
|
hue: { center: 215, range: 25, softness: 10 },
|
|
saturation: { min: 0.2, max: 1, softness: 0.08 },
|
|
luma: { min: 0.1, max: 0.9, softness: 0.08 },
|
|
},
|
|
correction: { saturation: 0.15, luma: 0.03 },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.value).toContain('"wheels"');
|
|
expect(result.value).toContain('"curves"');
|
|
expect(result.value).toContain('"hueCurves"');
|
|
expect(result.value).toContain('"secondaries"');
|
|
});
|
|
|
|
it("persists a disabled secondary without treating it as an active grade", () => {
|
|
const result = applyMediaTreatmentToHtml(VIDEO, {
|
|
selector: "#hero",
|
|
grading: {
|
|
secondaries: [
|
|
{
|
|
enabled: false,
|
|
key: { hue: { center: 215, range: 25 } },
|
|
correction: { saturation: 0.15 },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.value).toContain('"enabled":false');
|
|
expect(result.value).toContain('"secondaries"');
|
|
});
|
|
|
|
it("resolves nested composition media through the shared project-root contract", () => {
|
|
const project = mkdtempSync(join(tmpdir(), "hf-media-treatment-assets-"));
|
|
const escapedAsset = join(project, "..", `${basename(project)}-escape.mp4`);
|
|
mkdirSync(join(project, "capture"), { recursive: true });
|
|
mkdirSync(join(project, "assets"), { recursive: true });
|
|
writeFileSync(join(project, "capture", "talking-head.mp4"), "");
|
|
writeFileSync(join(project, "assets", "photo.webp"), "");
|
|
writeFileSync(join(project, "assets", "My Clip.mp4"), "");
|
|
writeFileSync(escapedAsset, "");
|
|
|
|
try {
|
|
expect(
|
|
resolveMediaTreatmentSource(
|
|
project,
|
|
"compositions/scene.html",
|
|
"../capture/talking-head.mp4?v=1#frame",
|
|
),
|
|
).toBe(join(project, "capture/talking-head.mp4"));
|
|
expect(
|
|
resolveMediaTreatmentSource(project, "compositions/scene.html", "assets/photo.webp"),
|
|
).toBe(join(project, "assets/photo.webp"));
|
|
expect(
|
|
resolveMediaTreatmentSource(
|
|
project,
|
|
"compositions/scene.html",
|
|
"/assets/My%20Clip.mp4?v=1",
|
|
),
|
|
).toBe(join(project, "assets/My Clip.mp4"));
|
|
expect(() =>
|
|
resolveMediaTreatmentSource(
|
|
project,
|
|
"compositions/scene.html",
|
|
"https://example.com/a.mp4",
|
|
),
|
|
).toThrow(/local project asset/);
|
|
expect(() => resolveMediaTreatmentSource(project, "compositions/scene.html", "#")).toThrow(
|
|
/local project asset/,
|
|
);
|
|
expect(() =>
|
|
resolveMediaTreatmentSource(project, "compositions/scene.html", "missing.mp4"),
|
|
).toThrow(/Media file not found/);
|
|
expect(() =>
|
|
resolveMediaTreatmentSource(
|
|
project,
|
|
"compositions/scene.html",
|
|
`../../${basename(escapedAsset)}`,
|
|
),
|
|
).toThrow(/Media file not found/);
|
|
} finally {
|
|
rmSync(project, { recursive: true, force: true });
|
|
rmSync(escapedAsset, { force: true });
|
|
}
|
|
});
|
|
|
|
it("merges a validated patch and reports the stored before and after payloads", () => {
|
|
const initial = applyMediaTreatmentToHtml(VIDEO, {
|
|
selector: "#hero",
|
|
grading: {
|
|
adjust: { exposure: 0.1 },
|
|
details: { grain: 0.2 },
|
|
},
|
|
});
|
|
const patched = applyMediaTreatmentToHtml(initial.html, {
|
|
selector: "#hero",
|
|
grading: { adjust: { shadows: 0.08 } },
|
|
});
|
|
|
|
expect(patched.before).toMatchObject({
|
|
adjust: { exposure: 0.1 },
|
|
details: { grain: 0.2 },
|
|
});
|
|
expect(patched.after).toMatchObject({
|
|
adjust: { exposure: 0.1, shadows: 0.08 },
|
|
details: { grain: 0.2 },
|
|
});
|
|
|
|
const repeated = applyMediaTreatmentToHtml(patched.html, {
|
|
selector: "#hero",
|
|
grading: { adjust: { shadows: 0.08 } },
|
|
});
|
|
expect(repeated.changed).toBe(false);
|
|
expect(repeated.html).toBe(patched.html);
|
|
expect(repeated.after).toEqual(repeated.before);
|
|
});
|
|
|
|
it("preserves unresolved variable references for runtime resolution", () => {
|
|
const wholeGrade = applyMediaTreatmentToHtml(VIDEO, {
|
|
selector: "#hero",
|
|
grading: "$interviewGrade",
|
|
});
|
|
expect(wholeGrade.value).toBe("$interviewGrade");
|
|
|
|
const nested = applyMediaTreatmentToHtml(VIDEO, {
|
|
selector: "#hero",
|
|
grading: { adjust: { exposure: "$interviewExposure" } },
|
|
});
|
|
expect(JSON.parse(nested.value ?? "{}")).toMatchObject({
|
|
adjust: { exposure: "$interviewExposure" },
|
|
});
|
|
|
|
const storedVariable = VIDEO.replace(" src=", ` data-color-grading="$interviewGrade" src=`);
|
|
expect(() =>
|
|
applyMediaTreatmentToHtml(storedVariable, {
|
|
selector: "#hero",
|
|
grading: { adjust: { exposure: 0.1 } },
|
|
}),
|
|
).toThrow(/Cannot merge.*unresolved whole-grade variable/);
|
|
});
|
|
|
|
it("requires an unambiguous media target", () => {
|
|
const source = `<img class="media" src="a.png"><img class="media" src="b.png">`;
|
|
expect(() =>
|
|
applyMediaTreatmentToHtml(source, { selector: ".media", grading: { preset: "neutral" } }),
|
|
).toThrow(/matched 2 elements/);
|
|
|
|
const result = applyMediaTreatmentToHtml(source, {
|
|
selector: ".media",
|
|
selectorIndex: 1,
|
|
grading: { preset: "warm-daylight" },
|
|
});
|
|
expect((result.html.match(/data-color-grading/g) ?? []).length).toBe(1);
|
|
});
|
|
|
|
it("persists grading inside composition templates", () => {
|
|
const source = `<template><video id="hero" src="hero.mp4"></video></template>`;
|
|
const result = applyMediaTreatmentToHtml(source, {
|
|
selector: "#hero",
|
|
grading: { preset: "warm-daylight" },
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.html).toContain("data-color-grading=");
|
|
});
|
|
|
|
it("rejects non-media elements", () => {
|
|
expect(() =>
|
|
applyMediaTreatmentToHtml(`<div id="hero"></div>`, {
|
|
selector: "#hero",
|
|
grading: { preset: "warm-daylight" },
|
|
}),
|
|
).toThrow(/requires an <img> or <video>/);
|
|
});
|
|
|
|
it("rejects unknown keys instead of silently dropping agent mistakes", () => {
|
|
expect(() =>
|
|
applyMediaTreatmentToHtml(VIDEO, {
|
|
selector: "#hero",
|
|
grading: { adjustments: { exposure: -0.45 }, effects: { dither: 1 } },
|
|
}),
|
|
).toThrow(/grading.*adjustments/i);
|
|
|
|
expect(() =>
|
|
applyMediaTreatmentToHtml(VIDEO, {
|
|
selector: "#hero",
|
|
grading: { effects: { dithering: 1 } },
|
|
}),
|
|
).toThrow(/effects.*dithering/i);
|
|
});
|
|
|
|
it("requires --apply for --grading while keeping --clear explicit", async () => {
|
|
const project = mkdtempSync(join(tmpdir(), "hf-media-treatment-"));
|
|
const file = join(project, "index.html");
|
|
const grading = '{"adjust":{"exposure":0.1}}';
|
|
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
writeFileSync(file, VIDEO);
|
|
|
|
try {
|
|
await expect(
|
|
runCommand(mediaTreatmentCommand, {
|
|
rawArgs: ["--project", project, "--selector", "#hero", "--grading", grading],
|
|
}),
|
|
).rejects.toThrow(CliRuntimeError);
|
|
expect(error).toHaveBeenLastCalledWith(expect.stringContaining("--grading requires --apply"));
|
|
expect(readFileSync(file, "utf8")).toBe(VIDEO);
|
|
|
|
await runCommand(mediaTreatmentCommand, {
|
|
rawArgs: ["--project", project, "--selector", "#hero", "--grading", grading, "--apply"],
|
|
});
|
|
expect(readFileSync(file, "utf8")).toContain("data-color-grading");
|
|
|
|
await runCommand(mediaTreatmentCommand, {
|
|
rawArgs: ["--project", project, "--selector", "#hero", "--clear"],
|
|
});
|
|
expect(readFileSync(file, "utf8")).not.toContain("data-color-grading");
|
|
} finally {
|
|
log.mockRestore();
|
|
error.mockRestore();
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("clears both explicit and normalized no-op grading", () => {
|
|
const graded = VIDEO.replace(" src=", ` data-color-grading='{"preset":"warm-daylight"}' src=`);
|
|
expect(
|
|
applyMediaTreatmentToHtml(graded, { selector: "#hero", clear: true }).html,
|
|
).not.toContain("data-color-grading");
|
|
expect(
|
|
applyMediaTreatmentToHtml(graded, { selector: "#hero", grading: { preset: "neutral" } }).html,
|
|
).not.toContain("data-color-grading");
|
|
});
|
|
|
|
it("does not report or serialize a no-op clear because unrelated HTML formatting differs", () => {
|
|
const source = `<!doctype html><html><head><meta charset="utf-8" /></head><body><video id="hero" src="hero.mp4"></video></body></html>`;
|
|
const result = applyMediaTreatmentToHtml(source, { selector: "#hero", clear: true });
|
|
|
|
expect(result.changed).toBe(false);
|
|
expect(result.html).toBe(source);
|
|
});
|
|
});
|