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
4 changes: 3 additions & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ npx hyperframes media-treatment --selector '#hero' --analyze --json
npx hyperframes media-treatment --selector '#hero' \
--grading '{"preset":"skin-soft","intensity":0.6}' --apply
npx hyperframes media-treatment --file compositions/scene.html --selector 'video' \
--grading '{"preset":"warm-daylight"}' --apply --dry-run --json
--grading '{"preset":"warm-daylight"}' --dry-run --json
npx hyperframes media-treatment --selector '#hero' --clear
```

Expand All @@ -455,6 +455,8 @@ npx hyperframes media-treatment --selector '#hero' --clear
| `--dry-run` | Validate and report without writing |
| `--json` | Agent-readable JSON |

`--dry-run` accepts `--grading` without `--apply` and writes nothing. JSON includes `attribute`, the exact normalized `value` that apply would write (or `null` to remove it), and `lint`, the color-grading lint verdict and findings for the proposed HTML.

The command writes `data-color-grading` — the same contract Studio, preview, and
render all read, so the two can never disagree. It does not recognise subjects
or isolate part of an image; the effect covers the whole media layer.
Expand Down
6 changes: 4 additions & 2 deletions docs/reference/color-grading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ can tween directly:
--hf-color-grading-lut-intensity
```

Start the value at identity in both the payload and inline style, then animate
it on the registered timeline:
Once set, a property overrides the payload's value for that control, whether it
comes from the element's inline style, a stylesheet or a parent. Set it only on
media you animate, starting at the value the tween begins from; leave it unset
for a static grade. Then animate it on the registered timeline:

```js
tl.to(
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/commands/media-treatment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { runCommand } from "citty";
import { parseHTML } from "linkedom";
import { describe, expect, it, vi } from "vitest";
import {
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
Expand Down Expand Up @@ -116,6 +117,16 @@ describe("applyMediaTreatmentToHtml", () => {
});
});

it("warns that an animated property overrides the payload, instead of asking for an inline start", () => {
const detail = getMediaTreatmentCapabilityDetail("blur");
expect(detail).toMatchObject({
animation: {
rules: expect.arrayContaining([expect.stringContaining("overrides the payload's value")]),
},
});
expect(JSON.stringify(detail)).not.toContain("Author the initial value inline");
});

it("rejects unknown capability lookups", () => {
expect(() => getMediaTreatmentCapabilityDetail("make-it-cinematic")).toThrow(
/Unknown media-treatment capability/,
Expand Down Expand Up @@ -294,6 +305,51 @@ describe("applyMediaTreatmentToHtml", () => {
expect(repeated.after).toEqual(repeated.before);
});

it("applies over a grading an earlier version wrote with empty hue curves, and writes it valid", () => {
const broken = JSON.stringify({
hueCurves: {
hueVsHue: [
[0, 0],
[120, 20],
[240, 0],
],
hueVsSaturation: [],
hueVsLuma: [],
},
});
const html = VIDEO.replace('id="hero"', `id="hero" data-color-grading='${broken}'`);

const patched = applyMediaTreatmentToHtml(html, {
selector: "#hero",
grading: { adjust: { exposure: 0.1 } },
});

expect(patched.after).toMatchObject({
adjust: { exposure: 0.1 },
hueCurves: {
hueVsHue: [
[0, 0],
[120, 20],
[240, 0],
],
},
});
expect(patched.after).not.toHaveProperty("hueCurves.hueVsSaturation");
});

it("rejects unknown empty hue curves instead of healing them", () => {
const html = VIDEO.replace(
'id="hero"',
`id="hero" data-color-grading='{"hueCurves":{"bogus":[]}}'`,
);
expect(() =>
applyMediaTreatmentToHtml(html, {
selector: "#hero",
grading: { adjust: { exposure: 0.1 } },
}),
).toThrow(/hueCurves.*unsupported key.*bogus/);
});

it("preserves unresolved variable references for runtime resolution", () => {
const wholeGrade = applyMediaTreatmentToHtml(VIDEO, {
selector: "#hero",
Expand Down Expand Up @@ -368,6 +424,38 @@ describe("applyMediaTreatmentToHtml", () => {
).toThrow(/effects.*dithering/i);
});

it("previews the exact grading attribute and lint verdict without --apply or writes", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-media-dry-run-"));
const file = join(project, "index.html");
const grading = '{"preset":"warm-daylight","intensity":0.8}';
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
writeFileSync(file, VIDEO);
const args = ["--project", project, "--selector", "#hero", "--grading", grading, "--json"];
try {
await runCommand(mediaTreatmentCommand, { rawArgs: [...args, "--dry-run"] });
const preview = JSON.parse(String(log.mock.calls.at(-1)?.[0]));
expect(preview).toMatchObject({
ok: true,
dryRun: true,
changed: true,
attribute: "data-color-grading",
lint: { ok: true, findings: [] },
});
expect(readFileSync(file, "utf8")).toBe(VIDEO);
await runCommand(mediaTreatmentCommand, { rawArgs: [...args, "--apply"] });
const applied = JSON.parse(String(log.mock.calls.at(-1)?.[0]));
const written = parseHTML(readFileSync(file, "utf8"))
.document.querySelector("#hero")
?.getAttribute("data-color-grading");
expect(preview.value).toBe(written);
expect(preview.value).toBe(applied.value);
expect(preview.lint).toEqual(applied.lint);
} finally {
log.mockRestore();
rmSync(project, { recursive: true, force: true });
}
});

it("requires --apply for --grading while keeping --clear explicit", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-media-treatment-"));
const file = join(project, "index.html");
Expand Down
57 changes: 43 additions & 14 deletions packages/cli/src/commands/media-treatment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import {
HF_COLOR_GRADING_ATTR,
HF_COLOR_GRADING_HUE_CURVE_KEYS,
getHfColorGradingCapabilities,
hasHfColorGradingAuthoredValues,
isPathInside,
Expand All @@ -20,6 +21,7 @@ import {
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import { patchElementInHtml } from "@hyperframes/studio-server/source-mutation";
import { defineCommand } from "citty";
import { lintHyperframeHtml } from "@hyperframes/lint";
import { parseHTML } from "linkedom";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
Expand Down Expand Up @@ -91,7 +93,7 @@ export function getMediaTreatmentCapabilityDetail(id: string): unknown {
initial: `style="${property.name}: <start>"`,
tween: `timeline.to("<selector>", { "${property.name}": <end>, duration: <seconds> })`,
rules: [
"Author the initial value inline on the media element.",
"Once set (inline, in a stylesheet or on a parent), the property overrides the payload's value for this control: set it only on media you animate, starting at the tween's first value.",
"Use finite keyframes on a paused timeline registered in window.__timelines.",
"Do not use a frame-zero set, timers, random values, or onUpdate callbacks.",
],
Expand Down Expand Up @@ -317,7 +319,7 @@ export const examples: Example[] = [
],
[
"Preview the exact mutation without writing",
`hyperframes media-treatment --file compositions/scene.html --selector 'video' --grading '{"preset":"warm-daylight"}' --apply --dry-run --json`,
`hyperframes media-treatment --file compositions/scene.html --selector 'video' --grading '{"preset":"warm-daylight"}' --dry-run --json`,
],
[
"Measure one local media source before choosing a correction",
Expand Down Expand Up @@ -385,13 +387,28 @@ function mergeGradingPatch(current: unknown, patch: unknown): unknown {
return merged;
}

function withoutEmptyHueCurves(grading: unknown): unknown {
if (!isRecord(grading) || !isRecord(grading.hueCurves)) return grading;
const hueCurves = Object.fromEntries(
Object.entries(grading.hueCurves).filter(
([key, curve]) =>
!HF_COLOR_GRADING_HUE_CURVE_KEYS.some((knownKey) => knownKey === key) ||
!Array.isArray(curve) ||
curve.length > 0,
),
);
return { ...grading, hueCurves };
}

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;
typeof before === "string" && !isColorGradingVariableRef(before)
? { preset: before }
: withoutEmptyHueCurves(before);
const grading = mergeGradingPatch(current, patch);
assertKnownGradingShape(grading);
if (containsColorGradingVariableRef(grading)) {
Expand Down Expand Up @@ -508,15 +525,20 @@ export function resolveMediaTreatmentSource(
return asset.resolved;
}

function parseGrading(raw: string | undefined, apply: boolean, clear: boolean): unknown {
function parseGrading(
raw: string | undefined,
apply: boolean,
clear: boolean,
dryRun: 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");
if (!apply && !dryRun) {
if (raw !== undefined) throw new Error("--grading requires --apply or --dry-run");
throw new Error("Use --apply with --grading <json> or --clear");
}
if (raw === undefined) throw new Error("--apply requires --grading <json>");
Expand Down Expand Up @@ -613,20 +635,23 @@ function analyzeTarget(args: MediaTreatmentCommandArgs) {
};
}

function prepareMutation(args: MediaTreatmentCommandArgs) {
async function prepareMutation(args: MediaTreatmentCommandArgs) {
const { project, filePath } = resolveMutationFile(args);
const selector = readOptionalString(args.selector);
if (!selector) throw new Error("--selector is required");
const clear = args.clear === true;
const apply = args.apply === true;
const dryRun = args["dry-run"] === true;
const selectorIndex = parseSelectorIndex(readOptionalString(args["selector-index"]));
const result = applyMediaTreatmentToHtml(readFileSync(filePath, "utf8"), {
selector,
selectorIndex,
grading: parseGrading(readOptionalString(args.grading), apply, clear),
grading: parseGrading(readOptionalString(args.grading), apply, clear, dryRun),
clear,
});
const dryRun = args["dry-run"] === true;
const lintResult = await lintHyperframeHtml(result.html);
const findings = lintResult.findings.filter(({ code }) => code.startsWith("color_grading_"));
const lint = { ok: findings.every(({ severity }) => severity !== "error"), findings };
if (result.changed && !dryRun) writeFileSync(filePath, result.html);

const action: "clear" | "apply" = result.value === null ? "clear" : "apply";
Expand All @@ -645,6 +670,9 @@ function prepareMutation(args: MediaTreatmentCommandArgs) {
dryRun,
before: result.before,
after: result.after,
attribute: HF_COLOR_GRADING_ATTR,
value: result.value,
lint,
},
};
}
Expand All @@ -667,8 +695,8 @@ function printAnalysis(args: MediaTreatmentCommandArgs): void {
console.log(` suggested patch: ${JSON.stringify(result.suggestedPatch)}`);
}

function printMutation(args: MediaTreatmentCommandArgs): void {
const { action, result, selector, payload } = prepareMutation(args);
async function printMutation(args: MediaTreatmentCommandArgs): Promise<void> {
const { action, result, selector, payload } = await prepareMutation(args);
if (args.json === true) {
console.log(JSON.stringify(withMeta(payload), null, 2));
return;
Expand Down Expand Up @@ -731,19 +759,20 @@ export const mediaTreatmentCommand = defineCommand({
clear: { type: "boolean", description: "Remove color grading from the target", default: false },
"dry-run": {
type: "boolean",
description: "Validate and report without writing",
description:
"Preview the normalized grading attribute and color-grading lint verdict without writing",
default: false,
},
json: { type: "boolean", description: "Output an agent-friendly JSON result", default: false },
},
run({ args }) {
async run({ args }) {
try {
if (isCapabilityQuery(args)) return runCapabilityQuery(args);
if (args.analyze === true) {
printAnalysis(args);
return;
}
printMutation(args);
await printMutation(args);
} catch (error) {
printFailure(error, args.json === true);
}
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/colorGrading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,36 @@ describe("color grading", () => {
expect(serialized).toContain('"secondaries"');
});

it("writes only the hue curves that were authored, so lint accepts a single hue curve", async () => {
const grading = normalizeHfColorGrading({
hueCurves: {
hueVsHue: [
[0, 0],
[120, 20],
[240, 0],
],
},
});
const serialized = serializeHfColorGrading(grading);
expect(JSON.parse(serialized).hueCurves).toEqual({
hueVsHue: [
[0, 0],
[120, 20],
[240, 0],
],
});

const lint = await lintHyperframeHtml(`
<html><body>
<div id="root" data-composition-id="c1" data-start="0" data-width="1920" data-height="1080" data-duration="1">
<img class="clip" data-start="0" data-duration="1" src="media.jpg" data-color-grading='${serialized}'>
</div>
<script>window.__timelines = {};</script>
</body></html>
`);
expect(lint.findings.filter((finding) => finding.severity === "error")).toEqual([]);
});

it("round-trips advanced grading byte-identically", () => {
const grading = normalizeHfColorGrading({
wheels: { shadows: { hue: 205, amount: 0.08 } },
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/colorGrading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1504,6 +1504,15 @@ export function hasHfColorGradingSecondaryValues(
);
}

function authoredHueCurves(curves: NormalizedHfColorGradingHueCurves): HfColorGradingHueCurves {
return Object.fromEntries(
HF_COLOR_GRADING_HUE_CURVE_KEYS.filter((key) => curves[key].length > 0).map((key) => [
key,
curves[key],
]),
);
}

export function serializeHfColorGrading(
grading: NormalizedHfColorGrading | HfColorGrading | null,
): string {
Expand All @@ -1522,7 +1531,9 @@ export function serializeHfColorGrading(
...serializable,
...(hasWheelGrade(wheels) ? { wheels } : {}),
...(hasHfColorGradingRgbCurveValues(curves) ? { curves } : {}),
...(hasHfColorGradingHueCurveValues(hueCurves) ? { hueCurves } : {}),
...(hasHfColorGradingHueCurveValues(hueCurves)
? { hueCurves: authoredHueCurves(hueCurves) }
: {}),
// Preserve a keyed secondary even before it changes pixels so Studio and
// CLI can author its qualifier and correction in separate transactions.
...(secondaries.length > 0 ? { secondaries } : {}),
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/colorGrading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,23 @@ describe("createColorGradingRuntime", () => {
expect(lastUniform1f).toHaveBeenCalledWith("u_exposure", 0.35);
});

it("reads a grading property set in a stylesheet, not only inline", () => {
const style = document.createElement("style");
style.textContent = "#plate { --hf-color-grading-blur: 0.6; }";
document.head.appendChild(style);
const video = makeDrawableVideo();
video.id = "plate";
video.setAttribute(
HF_COLOR_GRADING_ATTR,
serializeHfColorGrading({ adjust: { saturation: -0.5 } }),
);
stubCubeLutFetch();
startRuntimeWithVideo(video);

if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
expect(lastUniform1f).toHaveBeenCalledWith("u_blur", 0.6);
});

it("samples seek-derived grading values from inline CSS properties on every redraw", () => {
const video = makeDrawableVideo();
video.setAttribute(
Expand Down
Loading
Loading