mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
feat(cli): add agent-first media treatment tools
This commit is contained in:
@@ -73,3 +73,56 @@ describe("media-use TTS documentation", () => {
|
||||
expect(captions).toContain("heygen-tts.mjs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("media treatment routing documentation", () => {
|
||||
it("routes vague composition-media feedback to the canonical workflow", () => {
|
||||
const router = read("skills", "hyperframes", "SKILL.md");
|
||||
const mediaUse = read("skills", "media-use", "SKILL.md");
|
||||
const treatments = read("skills", "media-use", "references", "media-treatments.md");
|
||||
|
||||
expect(router).toContain("dark/flat/boring footage");
|
||||
expect(router).toContain("`/media-use`");
|
||||
expect(mediaUse).toContain("references/media-treatments.md");
|
||||
expect(mediaUse).toContain("`hyperframes media-treatment`");
|
||||
expect(treatments).toContain("Persist pixel settings with `hyperframes media-treatment`");
|
||||
expect(treatments).toContain("apply to the entire selected real `<img>` or");
|
||||
expect(treatments).toContain("external segmentation/tracking tool");
|
||||
});
|
||||
|
||||
it("keeps discovery progressive and verification visual", () => {
|
||||
const treatments = read("skills", "media-use", "references", "media-treatments.md");
|
||||
const recipes = read("skills", "media-use", "references", "media-treatment-recipes.md");
|
||||
|
||||
expect(treatments).toContain("hyperframes media-treatment --capabilities --json");
|
||||
expect(treatments).toContain("--capability <id>");
|
||||
expect(treatments).toContain("Recipes are optional macros");
|
||||
expect(recipes).toContain("optional tested seeds");
|
||||
expect(treatments).toContain("hyperframes add <name> --dir <project>");
|
||||
expect(treatments).toContain("snapshots/treatment-before/contact-sheet.jpg");
|
||||
expect(treatments).toMatch(/Do not report visual\s+quality from command success alone/);
|
||||
});
|
||||
|
||||
it("indexes calibrated treatment recipes without making them mandatory", () => {
|
||||
const treatments = read("skills", "media-use", "references", "media-treatments.md");
|
||||
const recipes = read("skills", "media-use", "references", "media-treatment-recipes.md");
|
||||
|
||||
for (const heading of [
|
||||
"Monochrome Screen Print",
|
||||
"Engraved Illustration",
|
||||
"Crosshatched Sketch",
|
||||
"CRT Display",
|
||||
]) {
|
||||
expect(treatments).toContain(`\`${heading}\``);
|
||||
expect(recipes).toContain(`## ${heading}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("places the media-treatment discovery gate in new project instructions", () => {
|
||||
for (const file of ["AGENTS.md", "CLAUDE.md"]) {
|
||||
const template = read("packages", "cli", "src", "templates", "_shared", file);
|
||||
expect(template).toContain("Changing how real footage or images look or reveal?");
|
||||
expect(template).toContain("Load `/media-use`");
|
||||
expect(template).toContain("do not improvise equivalent CSS/SVG filters or overlays");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { 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,
|
||||
} 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 === "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" }),
|
||||
});
|
||||
});
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import {
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
getHfColorGradingCapabilities,
|
||||
isHfColorGradingActive,
|
||||
isPathInside,
|
||||
normalizeHfColorGrading,
|
||||
serializeHfColorGrading,
|
||||
} from "@hyperframes/core";
|
||||
import {
|
||||
isColorGradingVariableRef,
|
||||
validateColorGradingContract,
|
||||
} from "@hyperframes/parsers/color-grading-contract";
|
||||
import { patchElementInHtml } from "@hyperframes/studio-server/source-mutation";
|
||||
import { defineCommand } from "citty";
|
||||
import { parseHTML } from "linkedom";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { readOptionalString } from "../utils/pathArgs.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
|
||||
export function getMediaTreatmentCapabilityOverview() {
|
||||
const capabilities = getHfColorGradingCapabilities();
|
||||
const family = (id: string, label: string, description: string) => ({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
});
|
||||
|
||||
return {
|
||||
version: capabilities.version,
|
||||
targetTags: capabilities.targetTags,
|
||||
colorSpace: capabilities.colorSpace,
|
||||
families: [
|
||||
family("correction", "Adjust", "Fix exposure, tonal balance, color casts, and saturation."),
|
||||
family(
|
||||
"presets",
|
||||
"Presets",
|
||||
"Apply a tested starting point, then tune only when the source or intent requires it.",
|
||||
),
|
||||
family("finishing", "Finish", "Shape vignette and deterministic film grain."),
|
||||
...capabilities.effectFamilies,
|
||||
family(
|
||||
"palettes",
|
||||
"Palettes",
|
||||
"Reusable two-to-six-color palettes for compatible art effects.",
|
||||
),
|
||||
family(
|
||||
"animation",
|
||||
"Animation",
|
||||
"Seek-safe CSS properties that registered GSAP timelines may animate.",
|
||||
),
|
||||
family("lut", "Custom LUT", "Apply a user-owned 3D .cube LUT."),
|
||||
{
|
||||
id: "overlays",
|
||||
label: "Overlays",
|
||||
description: "Install authored HUD, light-leak, flash, or freeze-frame overlay blocks.",
|
||||
owner: "registry",
|
||||
},
|
||||
],
|
||||
discovery: {
|
||||
detail: "Use --capability <family-or-item> for exact controls and examples.",
|
||||
full: "Use --all only for tooling or exhaustive inspection.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMediaTreatmentCapabilityDetail(id: string): unknown {
|
||||
const capabilities = getHfColorGradingCapabilities();
|
||||
const animation = (path: string) => {
|
||||
const property = capabilities.animatable.find((candidate) => candidate.path === path);
|
||||
if (!property) return null;
|
||||
return {
|
||||
property,
|
||||
initial: `style="${property.name}: <start>"`,
|
||||
tween: `timeline.to("<selector>", { "${property.name}": <end>, duration: <seconds> })`,
|
||||
rules: [
|
||||
"Author the initial value inline on the media element.",
|
||||
"Use finite keyframes on a paused timeline registered in window.__timelines.",
|
||||
"Do not use a frame-zero set, timers, random values, or onUpdate callbacks.",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const effect = capabilities.effects.find(({ key }) => key === id);
|
||||
if (effect) {
|
||||
return {
|
||||
id,
|
||||
family: effect.family,
|
||||
label: effect.label,
|
||||
description: effect.description,
|
||||
renderLane: effect.renderLane,
|
||||
supportsPalette: effect.supportsPalette,
|
||||
apply: { effects: effect.apply },
|
||||
controls: effect.controls,
|
||||
animation: animation(`effects.${id}`),
|
||||
};
|
||||
}
|
||||
|
||||
const effectFamily = capabilities.effectFamilies.find((family) => family.id === id);
|
||||
if (effectFamily) {
|
||||
return {
|
||||
...effectFamily,
|
||||
effects: capabilities.effects
|
||||
.filter((candidate) => candidate.family === id)
|
||||
.map((candidate) => ({
|
||||
id: candidate.key,
|
||||
label: candidate.label,
|
||||
description: candidate.description,
|
||||
renderLane: candidate.renderLane,
|
||||
supportsPalette: candidate.supportsPalette,
|
||||
animatable: capabilities.animatable.some(
|
||||
({ path }) => path === `effects.${candidate.key}`,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const adjustment = capabilities.adjustments.find(({ key }) => key === id);
|
||||
if (adjustment) {
|
||||
return {
|
||||
id,
|
||||
family: "correction",
|
||||
description: `Adjust ${id} within the canonical correction range.`,
|
||||
control: adjustment,
|
||||
apply: { adjust: { [id]: adjustment.identity } },
|
||||
animation: animation(`adjust.${id}`),
|
||||
};
|
||||
}
|
||||
|
||||
const finishing = capabilities.finishing.find(({ key }) => key === id);
|
||||
if (finishing) {
|
||||
return {
|
||||
id,
|
||||
family: "finishing",
|
||||
description: `Adjust ${id} within the canonical finishing range.`,
|
||||
control: finishing,
|
||||
apply: { details: { [id]: finishing.identity } },
|
||||
};
|
||||
}
|
||||
|
||||
const preset = capabilities.presets.find((candidate) => candidate.id === id);
|
||||
if (preset) {
|
||||
return {
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
description: "Tested built-in media preset.",
|
||||
apply: { preset: preset.id, intensity: preset.intensity },
|
||||
};
|
||||
}
|
||||
|
||||
const palette = capabilities.palettes.find((candidate) => candidate.id === id);
|
||||
if (palette) return { ...palette, apply: { palette: palette.colors } };
|
||||
|
||||
const details = {
|
||||
correction: {
|
||||
id,
|
||||
description: "Fix exposure, tonal balance, color casts, and saturation.",
|
||||
controls: capabilities.adjustments,
|
||||
},
|
||||
presets: {
|
||||
id,
|
||||
description: "Tested starting points for color and stylized media effects.",
|
||||
presets: capabilities.presets,
|
||||
},
|
||||
finishing: {
|
||||
id,
|
||||
description: "Vignette and deterministic film-grain controls.",
|
||||
controls: capabilities.finishing,
|
||||
},
|
||||
palettes: {
|
||||
id,
|
||||
description: "Named palettes plus the custom palette contract.",
|
||||
contract: capabilities.palette,
|
||||
palettes: capabilities.palettes,
|
||||
},
|
||||
animation: {
|
||||
id,
|
||||
description: "Seek-safe CSS properties for registered GSAP timelines.",
|
||||
properties: capabilities.animatable,
|
||||
},
|
||||
lut: {
|
||||
id,
|
||||
description: "User-owned 3D .cube LUT support.",
|
||||
contract: capabilities.lut,
|
||||
},
|
||||
overlays: {
|
||||
id,
|
||||
description: "Authored overlay blocks owned by the HyperFrames Registry.",
|
||||
discover: "hyperframes catalog",
|
||||
apply: "hyperframes add <overlay> --dir <project> --no-clipboard --json",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const detail = Object.hasOwn(details, id) ? Reflect.get(details, id) : undefined;
|
||||
if (detail) return detail;
|
||||
throw new Error(`Unknown media-treatment capability: ${id}`);
|
||||
}
|
||||
|
||||
export const examples: Example[] = [
|
||||
[
|
||||
"Discover the complete treatment surface without loading every control",
|
||||
`hyperframes media-treatment --capabilities --json`,
|
||||
],
|
||||
[
|
||||
"Inspect one relevant effect in detail",
|
||||
`hyperframes media-treatment --capability kuwahara --json`,
|
||||
],
|
||||
[
|
||||
"Inspect the exhaustive machine-readable catalog",
|
||||
`hyperframes media-treatment --capabilities --all --json`,
|
||||
],
|
||||
[
|
||||
"Apply a resolved treatment to one media element",
|
||||
`hyperframes media-treatment --selector '#hero' --grading '{"preset":"skin-soft","intensity":0.6}' --apply`,
|
||||
],
|
||||
[
|
||||
"Preview the exact mutation without writing",
|
||||
`hyperframes media-treatment --file compositions/scene.html --selector 'video' --grading '{"preset":"warm-daylight"}' --apply --dry-run --json`,
|
||||
],
|
||||
["Remove a treatment", `hyperframes media-treatment --selector '#hero' --clear`],
|
||||
];
|
||||
|
||||
interface ApplyMediaTreatmentOptions {
|
||||
selector: string;
|
||||
selectorIndex?: number;
|
||||
grading?: unknown;
|
||||
clear?: boolean;
|
||||
}
|
||||
|
||||
interface ApplyMediaTreatmentResult {
|
||||
html: string;
|
||||
changed: boolean;
|
||||
tag: "img" | "video";
|
||||
value: string | null;
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
}
|
||||
|
||||
function parseSourceDocument(source: string): Document {
|
||||
if (/<!doctype|<html[\s>]/i.test(source)) return parseHTML(source).document;
|
||||
return parseHTML(`<!DOCTYPE html><html><body>${source}</body></html>`).document;
|
||||
}
|
||||
|
||||
function assertKnownGradingShape(value: unknown): void {
|
||||
const issue = validateColorGradingContract(value)[0];
|
||||
if (issue) {
|
||||
const hint = issue.hint ? ` ${issue.hint}` : "";
|
||||
throw new Error(`Invalid color-grading ${issue.path}: ${issue.message}.${hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
function containsColorGradingVariableRef(value: unknown): boolean {
|
||||
if (isColorGradingVariableRef(value)) return true;
|
||||
if (Array.isArray(value)) return value.some(containsColorGradingVariableRef);
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
return Object.values(value).some(containsColorGradingVariableRef);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseStoredGrading(raw: string | null): unknown {
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeGradingPatch(current: unknown, patch: unknown): unknown {
|
||||
if (!isRecord(current) || !isRecord(patch)) return patch;
|
||||
const merged = { ...current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
merged[key] =
|
||||
isRecord(value) && isRecord(merged[key]) ? mergeGradingPatch(merged[key], value) : value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function serializeGradingPatch(before: unknown, patch: unknown): string | null {
|
||||
assertKnownGradingShape(patch);
|
||||
if (isColorGradingVariableRef(before) && isRecord(patch)) {
|
||||
throw new Error("Cannot merge a grading patch into an unresolved whole-grade variable");
|
||||
}
|
||||
const current =
|
||||
typeof before === "string" && !isColorGradingVariableRef(before) ? { preset: before } : before;
|
||||
const grading = mergeGradingPatch(current, patch);
|
||||
assertKnownGradingShape(grading);
|
||||
if (containsColorGradingVariableRef(grading)) {
|
||||
return typeof grading === "string" ? grading.trim() : JSON.stringify(grading);
|
||||
}
|
||||
const normalized = normalizeHfColorGrading(grading);
|
||||
if (!normalized) throw new Error("--grading must be valid HyperFrames color-grading JSON");
|
||||
return isHfColorGradingActive(normalized) ? serializeHfColorGrading(normalized) : null;
|
||||
}
|
||||
|
||||
function queryIncludingTemplates(root: Document | Element, selector: string): Element[] {
|
||||
const matches = Array.from(root.querySelectorAll(selector));
|
||||
if (matches.length > 0) return matches;
|
||||
for (const template of root.querySelectorAll("template")) {
|
||||
const nested = queryIncludingTemplates(template, selector);
|
||||
if (nested.length > 0) return nested;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function selectMediaElement(
|
||||
source: string,
|
||||
selector: string,
|
||||
selectorIndex?: number,
|
||||
): { element: Element; selectorIndex: number; tag: "img" | "video" } {
|
||||
const document = parseSourceDocument(source);
|
||||
let matches: Element[];
|
||||
try {
|
||||
matches = queryIncludingTemplates(document, selector);
|
||||
} catch {
|
||||
throw new Error(`Invalid selector: ${selector}`);
|
||||
}
|
||||
if (matches.length === 0) throw new Error(`Selector did not match: ${selector}`);
|
||||
if (selectorIndex === undefined && matches.length > 1) {
|
||||
throw new Error(
|
||||
`Selector matched ${matches.length} elements; use a unique selector or --selector-index`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedIndex = selectorIndex ?? 0;
|
||||
const element = matches[resolvedIndex];
|
||||
if (!element) {
|
||||
throw new Error(`--selector-index ${resolvedIndex} is outside ${matches.length} matches`);
|
||||
}
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (tag !== "img" && tag !== "video") {
|
||||
throw new Error(`Color grading requires an <img> or <video>; selector matched <${tag}>`);
|
||||
}
|
||||
return { element, selectorIndex: resolvedIndex, tag };
|
||||
}
|
||||
|
||||
export function applyMediaTreatmentToHtml(
|
||||
source: string,
|
||||
options: ApplyMediaTreatmentOptions,
|
||||
): ApplyMediaTreatmentResult {
|
||||
const { element, selectorIndex, tag } = selectMediaElement(
|
||||
source,
|
||||
options.selector,
|
||||
options.selectorIndex,
|
||||
);
|
||||
const before = parseStoredGrading(element.getAttribute(HF_COLOR_GRADING_ATTR));
|
||||
|
||||
const value = options.clear ? null : serializeGradingPatch(before, options.grading);
|
||||
|
||||
const changed = element.getAttribute(HF_COLOR_GRADING_ATTR) !== value;
|
||||
const after = parseStoredGrading(value);
|
||||
if (!changed) return { html: source, changed: false, tag, value, before, after };
|
||||
|
||||
const patched = patchElementInHtml(source, { selector: options.selector, selectorIndex }, [
|
||||
{ type: "attribute", property: HF_COLOR_GRADING_ATTR, value },
|
||||
]);
|
||||
if (!patched.matched) throw new Error(`Could not persist selector: ${options.selector}`);
|
||||
return { html: patched.html, changed: true, tag, value, before, after };
|
||||
}
|
||||
|
||||
function parseSelectorIndex(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error("--selector-index must be a non-negative integer");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGrading(raw: string | undefined, apply: boolean, clear: boolean): unknown {
|
||||
if (clear) {
|
||||
if (raw !== undefined || apply) {
|
||||
throw new Error("Use either --apply with --grading or --clear, not both");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!apply) {
|
||||
if (raw !== undefined) throw new Error("--grading requires --apply");
|
||||
throw new Error("Use --apply with --grading <json> or --clear");
|
||||
}
|
||||
if (raw === undefined) throw new Error("--apply requires --grading <json>");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse --grading JSON: ${normalizeErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function mutationVerb(action: "apply" | "clear", changed: boolean, dryRun: boolean): string {
|
||||
if (dryRun) return `Would ${action}`;
|
||||
if (!changed) return action === "apply" ? "Already applied" : "Already clear";
|
||||
return action === "apply" ? "Applied" : "Cleared";
|
||||
}
|
||||
|
||||
export const mediaTreatmentCommand = defineCommand({
|
||||
meta: {
|
||||
name: "media-treatment",
|
||||
description: "Discover, apply, or clear deterministic media treatments",
|
||||
},
|
||||
args: {
|
||||
capabilities: {
|
||||
type: "boolean",
|
||||
description: "Print a concise agent-readable capability overview",
|
||||
default: false,
|
||||
},
|
||||
capability: {
|
||||
type: "string",
|
||||
description: "Inspect one family, control, effect, preset, or palette",
|
||||
},
|
||||
all: {
|
||||
type: "boolean",
|
||||
description: "Print the exhaustive capability catalog",
|
||||
default: false,
|
||||
},
|
||||
project: { type: "string", description: "Project directory (default: cwd)" },
|
||||
file: {
|
||||
type: "string",
|
||||
description: "Composition file relative to project (default: index.html)",
|
||||
},
|
||||
selector: {
|
||||
type: "string",
|
||||
description: "Unique CSS selector for one <img> or <video>",
|
||||
},
|
||||
"selector-index": {
|
||||
type: "string",
|
||||
description: "Zero-based match index when the selector is not unique",
|
||||
},
|
||||
grading: { type: "string", description: "Canonical color-grading JSON patch" },
|
||||
apply: {
|
||||
type: "boolean",
|
||||
description: "Apply the validated grading patch (explicit agent form)",
|
||||
default: false,
|
||||
},
|
||||
clear: { type: "boolean", description: "Remove color grading from the target", default: false },
|
||||
"dry-run": {
|
||||
type: "boolean",
|
||||
description: "Validate and report without writing",
|
||||
default: false,
|
||||
},
|
||||
json: { type: "boolean", description: "Output an agent-friendly JSON result", default: false },
|
||||
},
|
||||
run({ args }) {
|
||||
const runCapabilityQuery = () => {
|
||||
const capability = readOptionalString(args.capability);
|
||||
const hasMutationOption = [
|
||||
readOptionalString(args.selector),
|
||||
readOptionalString(args.grading),
|
||||
args.clear,
|
||||
args["dry-run"],
|
||||
args.apply,
|
||||
].some(Boolean);
|
||||
if (hasMutationOption) {
|
||||
throw new Error("--capabilities cannot be combined with mutation options");
|
||||
}
|
||||
if (args.all === true && capability) {
|
||||
throw new Error("Use either --all or --capability, not both");
|
||||
}
|
||||
let capabilities: unknown = getMediaTreatmentCapabilityOverview();
|
||||
if (args.all === true) capabilities = getHfColorGradingCapabilities();
|
||||
else if (capability) capabilities = getMediaTreatmentCapabilityDetail(capability);
|
||||
console.log(JSON.stringify(withMeta({ ok: true, capabilities }), null, 2));
|
||||
};
|
||||
|
||||
const resolveMutationFile = () => {
|
||||
const project = resolveProject(readOptionalString(args.project));
|
||||
const fileArg = readOptionalString(args.file) ?? "index.html";
|
||||
const filePath = resolve(project.dir, fileArg);
|
||||
if (!isPathInside(filePath, project.dir) || !filePath.toLowerCase().endsWith(".html")) {
|
||||
throw new Error("--file must be an HTML file inside the project");
|
||||
}
|
||||
if (!existsSync(filePath)) throw new Error(`Composition file not found: ${fileArg}`);
|
||||
return { project, filePath };
|
||||
};
|
||||
|
||||
const prepareMutation = () => {
|
||||
const { project, filePath } = resolveMutationFile();
|
||||
const selector = readOptionalString(args.selector);
|
||||
if (!selector) throw new Error("--selector is required");
|
||||
const clear = args.clear === true;
|
||||
const apply = args.apply === true;
|
||||
const selectorIndex = parseSelectorIndex(readOptionalString(args["selector-index"]));
|
||||
const result = applyMediaTreatmentToHtml(readFileSync(filePath, "utf8"), {
|
||||
selector,
|
||||
selectorIndex,
|
||||
grading: parseGrading(readOptionalString(args.grading), apply, clear),
|
||||
clear,
|
||||
});
|
||||
const dryRun = args["dry-run"] === true;
|
||||
if (result.changed && !dryRun) writeFileSync(filePath, result.html);
|
||||
|
||||
const action: "clear" | "apply" = result.value === null ? "clear" : "apply";
|
||||
return {
|
||||
action,
|
||||
result,
|
||||
selector,
|
||||
payload: {
|
||||
ok: true,
|
||||
action,
|
||||
file: relative(project.dir, filePath) || "index.html",
|
||||
selector,
|
||||
selectorIndex: selectorIndex ?? 0,
|
||||
tag: result.tag,
|
||||
changed: result.changed,
|
||||
dryRun,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
if (args.capabilities === true || readOptionalString(args.capability) || args.all === true) {
|
||||
return runCapabilityQuery();
|
||||
}
|
||||
const { action, result, selector, payload } = prepareMutation();
|
||||
if (args.json === true) {
|
||||
console.log(JSON.stringify(withMeta(payload), null, 2));
|
||||
} else {
|
||||
const verb = mutationVerb(action, result.changed, payload.dryRun);
|
||||
console.log(`${c.success("◇")} ${verb} media treatment on ${c.accent(selector)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = normalizeErrorMessage(error);
|
||||
if (args.json === true) console.log(JSON.stringify(withMeta({ ok: false, error: message })));
|
||||
else console.error(`${c.error("✗")} ${message}`);
|
||||
failCommand();
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user