fix(core): make grading writes previewable and honor computed properties (#5157)

* fix(core): write only authored hue curves, so a single hue curve passes lint

* fix(cli): apply over a grading saved with empty hue curves instead of refusing it

* fix(core): read grading properties from computed style, and stop advising an inline start

* fix(cli): preserve validation during legacy hue repair

* fix(cli): preview grading writes without apply
This commit is contained in:
Miguel Ángel
2026-10-08 15:21:47 +00:00
committed by GitHub
parent 202d5b5300
commit 8dae5ef89b
10 changed files with 203 additions and 22 deletions
+3 -1
View File
@@ -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
```
@@ -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.
+4 -2
View File
@@ -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(
@@ -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,
@@ -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/,
@@ -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",
@@ -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");
+43 -14
View File
@@ -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,
@@ -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";
@@ -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.",
],
@@ -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",
@@ -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)) {
@@ -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>");
@@ -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";
@@ -645,6 +670,9 @@ function prepareMutation(args: MediaTreatmentCommandArgs) {
dryRun,
before: result.before,
after: result.after,
attribute: HF_COLOR_GRADING_ATTR,
value: result.value,
lint,
},
};
}
@@ -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;
@@ -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);
}
+30
View File
@@ -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 } },
+12 -1
View File
@@ -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 {
@@ -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 } : {}),
@@ -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(
+1 -1
View File
@@ -2966,7 +2966,7 @@ const ANIMATED_GRADING_PROPERTIES = [
];
function readAnimatedValue(element: HTMLElement, property: AnimatedProperty): number | null {
const raw = element.style.getPropertyValue(property.name);
const raw = getComputedStyle(element).getPropertyValue(property.name).trim();
if (!raw) return null;
const value = Number(raw);
return Number.isFinite(value) ? Math.min(property.max, Math.max(property.min, value)) : null;
+1 -1
View File
@@ -54,7 +54,7 @@
"files": 1
},
"media-use": {
"hash": "d0875c7f8bc28d51",
"hash": "2bfc140b0b645f66",
"files": 109
},
"motion-graphics": {
+4 -2
View File
@@ -83,8 +83,10 @@ on that same real media element with its registered paused GSAP timeline:
| `--hf-color-grading-ascii` | 0 to 1 |
| `--hf-color-grading-dither` | 0 to 1 |
Author the initial value directly in the media element's inline `style`, then
use finite `tl.to()` keyframes. Do not use a frame-zero `tl.set()`, CSS
Once set (inline, in a stylesheet or on a parent), a property overrides the
payload's value for that control, so set it only on media you animate: put the
tween's first value in the element's inline `style`, then use finite `tl.to()`
keyframes. Leave it unset for a static grade. Do not use a frame-zero `tl.set()`, CSS
animation clocks, timers, random values, or `onUpdate` callbacks. The static
`data-color-grading` payload remains the fallback and source of the other
controls.