diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index b3e218930bb..6a5779a8cb4 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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. diff --git a/docs/reference/color-grading.mdx b/docs/reference/color-grading.mdx index c433bcf1cbe..6f864b53171 100644 --- a/docs/reference/color-grading.mdx +++ b/docs/reference/color-grading.mdx @@ -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( diff --git a/packages/cli/src/commands/media-treatment.test.ts b/packages/cli/src/commands/media-treatment.test.ts index d525a6c64fd..d050a67679f 100644 --- a/packages/cli/src/commands/media-treatment.test.ts +++ b/packages/cli/src/commands/media-treatment.test.ts @@ -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"); diff --git a/packages/cli/src/commands/media-treatment.ts b/packages/cli/src/commands/media-treatment.ts index 38d78871a08..ea286ea6c17 100644 --- a/packages/cli/src/commands/media-treatment.ts +++ b/packages/cli/src/commands/media-treatment.ts @@ -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}: "`, tween: `timeline.to("", { "${property.name}": , duration: })`, 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 or --clear"); } if (raw === undefined) throw new Error("--apply requires --grading "); @@ -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 { + 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); } diff --git a/packages/core/src/colorGrading.test.ts b/packages/core/src/colorGrading.test.ts index 6e5178b8704..c336992f217 100644 --- a/packages/core/src/colorGrading.test.ts +++ b/packages/core/src/colorGrading.test.ts @@ -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(` + +
+ +
+ + + `); + 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 } }, diff --git a/packages/core/src/colorGrading.ts b/packages/core/src/colorGrading.ts index 0d24a00377b..23285dda147 100644 --- a/packages/core/src/colorGrading.ts +++ b/packages/core/src/colorGrading.ts @@ -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 } : {}), diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index c0a42155721..afa313f1a50 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -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( diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 1467eab4613..1ced515bb69 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -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; diff --git a/skills-manifest.json b/skills-manifest.json index 4655e215d69..f4127f03b32 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,7 +54,7 @@ "files": 1 }, "media-use": { - "hash": "d0875c7f8bc28d51", + "hash": "2bfc140b0b645f66", "files": 109 }, "motion-graphics": { diff --git a/skills/media-use/references/grading.md b/skills/media-use/references/grading.md index a86503d3c6e..7f7e79770b1 100644 --- a/skills/media-use/references/grading.md +++ b/skills/media-use/references/grading.md @@ -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.