mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"files": 1
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "d0875c7f8bc28d51",
|
||||
"hash": "2bfc140b0b645f66",
|
||||
"files": 109
|
||||
},
|
||||
"motion-graphics": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user