feat(core): define media treatment capabilities

This commit is contained in:
ukimsanov
2026-07-24 08:56:24 -07:00
parent 9d4c3493d0
commit 70213c5a85
18 changed files with 1485 additions and 221 deletions
+435 -23
View File
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
import {
HF_COLOR_GRADING_COLOR_SPACE,
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
HF_COLOR_GRADING_EFFECT_PRESETS,
HF_COLOR_GRADING_GRADE_PRESETS,
HF_COLOR_GRADING_PALETTES,
HF_COLOR_GRADING_PRESETS,
getHfColorGradingCapabilities,
isHfColorGradingActive,
normalizeHfColorGrading,
normalizeHfColorGradingWithVariables,
@@ -9,25 +15,127 @@ import {
} from "./colorGrading";
describe("color grading", () => {
it("derives grade and effect preset views from their actual payloads", () => {
expect(HF_COLOR_GRADING_GRADE_PRESETS.map(({ id }) => id)).toContain("bright-pop");
expect(HF_COLOR_GRADING_GRADE_PRESETS.map(({ id }) => id)).not.toContain("vhs-playback");
expect(HF_COLOR_GRADING_EFFECT_PRESETS.map(({ id }) => id)).toEqual([
"creator-camcorder",
"vhs-playback",
"home-movie-8mm",
"editorial-halftone",
"two-ink-print",
]);
expect(HF_COLOR_GRADING_PRESETS).toHaveLength(18);
});
it("parses preset shorthand", () => {
const grading = normalizeHfColorGrading("warm-clean");
expect(grading?.preset).toBe("warm-clean");
const grading = normalizeHfColorGrading("warm-daylight");
expect(grading?.preset).toBe("warm-daylight");
expect(grading?.colorSpace).toBe(HF_COLOR_GRADING_COLOR_SPACE);
expect(grading?.adjust.temperature).toBeGreaterThan(0);
expect(isHfColorGradingActive(grading)).toBe(true);
});
it("includes consumer-friendly filter presets", () => {
expect(HF_COLOR_GRADING_PRESETS.some((preset) => preset.id === "fresh-pop")).toBe(true);
expect(HF_COLOR_GRADING_PRESETS.some((preset) => preset.id === "bright-pop")).toBe(true);
expect(normalizeHfColorGrading("mono-clean")?.adjust.saturation).toBe(-1);
expect(normalizeHfColorGrading("vintage-wash")?.details.vignette).toBeGreaterThan(0);
expect(normalizeHfColorGrading("food-pop")?.adjust.saturation).toBeGreaterThan(0);
expect(normalizeHfColorGrading("food-pop")?.adjust.vibrance).toBeGreaterThan(0);
});
it("publishes valid named palettes through the existing palette contract", () => {
expect(HF_COLOR_GRADING_PALETTES.map(({ id }) => id)).toContain("handheld-green");
for (const palette of HF_COLOR_GRADING_PALETTES) {
expect(normalizeHfColorGrading({ palette: palette.colors })?.palette).toEqual(palette.colors);
}
});
it("resolves calibrated complete-filter presets", () => {
expect(normalizeHfColorGrading("creator-camcorder")).toMatchObject({
intensity: 0.72,
effects: { chromaBleed: 0.55 },
});
expect(normalizeHfColorGrading("vhs-playback")).toMatchObject({
intensity: 1,
effects: {
tapeDamage: 0.82,
tapeTracking: 0.85,
scanlineCount: 0.17,
digitalGlitchLineTear: 0.08,
},
});
expect(normalizeHfColorGrading("home-movie-8mm")).toMatchObject({
intensity: 0.72,
details: { grain: 0.34, vignette: 0.28 },
effects: { filmArtifacts: 0.62 },
});
expect(normalizeHfColorGrading("editorial-halftone")?.effects).toMatchObject({
halftone: 0.94,
halftoneSize: 0.36,
});
expect(normalizeHfColorGrading("two-ink-print")?.effects).toMatchObject({
twoInkPrint: 1,
twoInkPrintSize: 0.42,
});
});
it("defines a useful normalized apply payload for every active effect", () => {
expect(Object.keys(HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS).sort()).toEqual(
[...HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS].sort(),
);
for (const key of HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS) {
const grading = normalizeHfColorGrading({
effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[key],
});
expect(grading?.effects[key], key).toBeGreaterThan(0);
expect(isHfColorGradingActive(grading), key).toBe(true);
}
});
it("publishes a complete capability catalog for agent-built treatments", () => {
const capabilities = getHfColorGradingCapabilities();
expect(capabilities.colorSpace).toBe("rec709");
expect(capabilities.adjustments.map(({ key }) => key)).toEqual([
"exposure",
"contrast",
"highlights",
"shadows",
"whites",
"blacks",
"temperature",
"tint",
"vibrance",
"saturation",
]);
expect(capabilities.effects.map(({ key }) => key)).toEqual(HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS);
expect(capabilities.effects.find(({ key }) => key === "digitalGlitch")?.controls).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "digitalGlitch", recommended: 0.55 }),
expect.objectContaining({ key: "digitalGlitchLineTear", min: 0, max: 1 }),
]),
);
expect(capabilities.presets.map(({ id }) => id)).toEqual([
...HF_COLOR_GRADING_GRADE_PRESETS.map(({ id }) => id),
...HF_COLOR_GRADING_EFFECT_PRESETS.map(({ id }) => id),
]);
expect(capabilities.palettes.map(({ id }) => id)).toContain("handheld-green");
expect(capabilities.animatable.map(({ path }) => path)).toContain("effects.kuwahara");
expect(capabilities.palette).toEqual({ minColors: 2, maxColors: 6, colorFormat: "#rrggbb" });
expect(capabilities.lut).toMatchObject({ format: "3d-cube", maxCubeSize: 64 });
expect(capabilities.effects.find(({ key }) => key === "ascii")).toMatchObject({
supportsPalette: true,
renderLane: "single-pass",
});
expect(capabilities.effects.find(({ key }) => key === "kuwahara")?.renderLane).toBe(
"multipass",
);
});
it("merges manual adjustments over preset values", () => {
const grading = normalizeHfColorGrading({
preset: "warm-clean",
preset: "warm-daylight",
intensity: 0.5,
adjust: { temperature: -0.25, contrast: 0.2 },
});
@@ -50,7 +158,23 @@ describe("color grading", () => {
grainSize: 2,
grainRoughness: -1,
},
effects: { blur: 2, pixelate: 3 },
effects: {
blur: 2,
pixelate: 3,
chromaBleed: 4,
tapeDamage: 5,
filmArtifacts: 6,
halftone: 7,
halftoneSize: 8,
twoInkPrint: 9,
twoInkPrintSize: 10,
ascii: 11,
asciiSize: 12,
asciiInvert: 13,
dither: 14,
ditherSize: 15,
},
palette: ["#FF6B66", "#080717", "#D9339F", "#3C185F"],
lut: { src: "looks/test.cube", intensity: 3 },
});
expect(grading?.intensity).toBe(1);
@@ -65,35 +189,292 @@ describe("color grading", () => {
expect(grading?.details.grain).toBe(0);
expect(grading?.details.grainSize).toBe(1);
expect(grading?.details.grainRoughness).toBe(0);
expect(grading?.effects.blur).toBe(1);
expect(grading?.effects.pixelate).toBe(1);
expect(grading?.effects).toMatchObject({
blur: 1,
pixelate: 1,
chromaBleed: 1,
tapeDamage: 1,
filmArtifacts: 1,
halftone: 1,
halftoneSize: 1,
twoInkPrint: 1,
twoInkPrintSize: 1,
ascii: 1,
asciiSize: 1,
asciiInvert: 1,
dither: 1,
ditherSize: 1,
});
expect(grading?.palette).toEqual(["#ff6b66", "#080717", "#d9339f", "#3c185f"]);
expect(grading?.lut?.intensity).toBe(1);
});
it("returns null for disabled or invalid grading", () => {
expect(normalizeHfColorGrading({ enabled: false, preset: "warm-clean" })).toBeNull();
expect(normalizeHfColorGrading({ enabled: false, preset: "warm-daylight" })).toBeNull();
expect(normalizeHfColorGrading("{nope")).toBeNull();
expect(normalizeHfColorGrading("")).toBeNull();
});
it("normalizes shared creative effect controls without activating subordinate options", () => {
const grading = normalizeHfColorGrading({
effects: {
asciiStyle: 9,
asciiColor: 2,
asciiRotation: 2,
monoScreen: 0.25,
monoScreenSize: 0.35,
monoScreenAngle: 0.45,
monoScreenSpread: 0.55,
monoScreenShape: 8,
monoScreenInvert: 2,
scanlines: 0.3,
scanlineCount: 0.4,
scanlineSoftness: 0.5,
chromaticAberration: 0.6,
chromaticAngle: 0.7,
crtCurvature: 0.8,
digitalGlitch: 0.7,
digitalGlitchColorSplit: 0.75,
digitalGlitchLineTear: 0.8,
digitalGlitchPixelate: 0.85,
digitalGlitchBlockAmount: 0.9,
digitalGlitchBlockDisplacement: 1.2,
digitalGlitchBlockOpacity: 1.3,
digitalGlitchSpeed: 0.9,
},
});
expect(grading?.effects).toMatchObject({
asciiStyle: 7,
asciiColor: 1,
asciiRotation: 1,
monoScreen: 0.25,
monoScreenSize: 0.35,
monoScreenAngle: 0.45,
monoScreenSpread: 0.55,
monoScreenShape: 4,
monoScreenInvert: 1,
scanlines: 0.3,
scanlineCount: 0.4,
scanlineSoftness: 0.5,
chromaticAberration: 0.6,
chromaticAngle: 0.7,
crtCurvature: 0.8,
digitalGlitch: 0.7,
digitalGlitchColorSplit: 0.75,
digitalGlitchLineTear: 0.8,
digitalGlitchPixelate: 0.85,
digitalGlitchBlockAmount: 0.9,
digitalGlitchBlockDisplacement: 1,
digitalGlitchBlockOpacity: 1,
digitalGlitchSpeed: 0.9,
});
const optionsOnly = normalizeHfColorGrading({
effects: {
asciiStyle: 4,
asciiColor: 1,
asciiRotation: 1,
monoScreenSize: 0.5,
monoScreenAngle: 0.5,
monoScreenSpread: 0.5,
monoScreenShape: 3,
monoScreenInvert: 1,
scanlineCount: 0.5,
scanlineSoftness: 0.5,
chromaticAngle: 0.5,
digitalGlitchColorSplit: 0.5,
digitalGlitchLineTear: 0.5,
digitalGlitchPixelate: 0.5,
digitalGlitchBlockAmount: 0.5,
digitalGlitchBlockDisplacement: 0.5,
digitalGlitchBlockOpacity: 0.5,
digitalGlitchSpeed: 0.5,
},
});
expect(isHfColorGradingActive(optionsOnly)).toBe(false);
});
it("uses the public ASCII defaults when only the family is enabled", () => {
const grading = normalizeHfColorGrading({ effects: { ascii: 1 } });
expect(grading?.effects).toMatchObject({
ascii: 1,
asciiSize: 5 / 76,
asciiInvert: 0,
asciiStyle: 0,
asciiColor: 1,
asciiRotation: 0,
});
});
it("normalizes tape controls without activating them independently", () => {
const defaults = normalizeHfColorGrading({ effects: { tapeDamage: 1 } });
expect(defaults?.effects).toMatchObject({
tapeDamage: 1,
tapeTracking: 0,
tapeNoise: 1,
tapeSpeed: 0.5,
});
expect(isHfColorGradingActive(defaults)).toBe(true);
const controlsOnly = normalizeHfColorGrading({
effects: { tapeTracking: 2, tapeNoise: -1, tapeSpeed: 2 },
});
expect(controlsOnly?.effects).toMatchObject({
tapeTracking: 1,
tapeNoise: 0,
tapeSpeed: 1,
});
expect(isHfColorGradingActive(controlsOnly)).toBe(false);
});
it("normalizes engraving controls and activates only from its master amount", () => {
const defaults = normalizeHfColorGrading({ effects: { engraving: 1 } });
expect(defaults?.effects).toMatchObject({
engraving: 1,
engravingSpacing: 7 / 17,
engravingMinThickness: 0.2,
engravingMaxThickness: 3.2 / 7,
engravingAngle: 0.25,
engravingContrast: 7 / 15,
engravingSharpness: 0.59,
engravingWave: 0.2,
engravingWaveFrequency: 2 / 9,
});
expect(isHfColorGradingActive(defaults)).toBe(true);
const controlsOnly = normalizeHfColorGrading({
effects: {
engravingSpacing: 0.8,
engravingMinThickness: 0,
engravingMaxThickness: 0,
engravingAngle: 0,
engravingContrast: 0,
engravingSharpness: 0,
engravingWave: 0,
engravingWaveFrequency: 0,
},
});
expect(isHfColorGradingActive(controlsOnly)).toBe(false);
expect(controlsOnly?.effects.engravingSpacing).toBe(0.8);
expect(controlsOnly?.effects.engravingMinThickness).toBe(0);
});
it("normalizes crosshatch controls and activates only from its master amount", () => {
const defaults = normalizeHfColorGrading({ effects: { crosshatch: 1 } });
expect(defaults?.effects).toMatchObject({
crosshatch: 1,
crosshatchSpacing: 7 / 25,
crosshatchThickness: 0.25,
crosshatchAngle: 0.25,
crosshatchContrast: 1 / 3,
crosshatchEdges: 0.5,
crosshatchLineWeight: 0,
crosshatchWave: 0.33,
crosshatchWaveFrequency: 2 / 9,
});
expect(isHfColorGradingActive(defaults)).toBe(true);
const controlsOnly = normalizeHfColorGrading({
effects: {
crosshatchSpacing: 0.8,
crosshatchThickness: 0,
crosshatchAngle: 0,
crosshatchContrast: 0,
crosshatchEdges: 0,
crosshatchLineWeight: 0,
crosshatchWave: 0,
crosshatchWaveFrequency: 0,
},
});
expect(isHfColorGradingActive(controlsOnly)).toBe(false);
expect(controlsOnly?.effects.crosshatchSpacing).toBe(0.8);
expect(controlsOnly?.effects.crosshatchThickness).toBe(0);
});
it("normalizes Kuwahara controls and activates only from its master amount", () => {
const defaults = normalizeHfColorGrading({ effects: { kuwahara: 1 } });
expect(defaults?.effects).toMatchObject({
kuwahara: 1,
kuwaharaRadius: 1 / 7,
kuwaharaSharpness: 5 / 16,
kuwaharaSaturation: 0.5,
});
expect(isHfColorGradingActive(defaults)).toBe(true);
const controlsOnly = normalizeHfColorGrading({
effects: {
kuwaharaRadius: 2,
kuwaharaSharpness: -1,
kuwaharaSaturation: 0.75,
},
});
expect(controlsOnly?.effects).toMatchObject({
kuwaharaRadius: 1,
kuwaharaSharpness: 0,
kuwaharaSaturation: 0.75,
});
expect(isHfColorGradingActive(controlsOnly)).toBe(false);
});
it("normalizes article bloom controls and activates only from intensity", () => {
const grading = normalizeHfColorGrading({ effects: { bloom: 0.5 } });
expect(grading?.effects).toMatchObject({ bloom: 0.5, bloomRadius: 8 });
expect(isHfColorGradingActive(grading)).toBe(true);
const controlsOnly = normalizeHfColorGrading({ effects: { bloomRadius: 101 } });
expect(controlsOnly?.effects.bloomRadius).toBe(100);
expect(isHfColorGradingActive(controlsOnly)).toBe(false);
expect(normalizeHfColorGrading({ effects: { bloom: 4 } })?.effects.bloom).toBe(3);
});
it("serializes normalized grading for data-color-grading", () => {
const grading = normalizeHfColorGrading({
adjust: { exposure: 0.25 },
details: { vignette: 0.3, grain: 0.1 },
effects: { blur: 0.2, pixelate: 0.4 },
effects: {
blur: 0.2,
pixelate: 0.4,
chromaBleed: 0.3,
tapeDamage: 0.5,
filmArtifacts: 0.6,
halftone: 0.7,
halftoneSize: 0.8,
twoInkPrint: 0.9,
twoInkPrintSize: 0.4,
ascii: 0.65,
asciiSize: 0.35,
asciiInvert: 1,
dither: 0.75,
ditherSize: 0.25,
},
palette: ["#080717", "#3c185f", "#d9339f", "#ff6b66", "#f6d365", "#aafae0"],
lut: { src: "assets/luts/test.cube", intensity: 0.6 },
});
const serialized = serializeHfColorGrading(grading);
expect(serialized).toContain('"exposure":0.25');
expect(serialized).toContain('"vignette":0.3');
expect(serialized).toContain('"grain":0.1');
expect(serialized).toContain('"blur":0.2');
expect(serialized).toContain('"pixelate":0.4');
expect(serialized).toContain('"src":"assets/luts/test.cube"');
expect(normalizeHfColorGrading(serialized)?.adjust.exposure).toBe(0.25);
expect(normalizeHfColorGrading(serialized)?.details.vignette).toBe(0.3);
expect(normalizeHfColorGrading(serialized)?.effects.blur).toBe(0.2);
expect(normalizeHfColorGrading(serialized)?.lut?.intensity).toBe(0.6);
expect(normalizeHfColorGrading(serialized)).toMatchObject({
adjust: { exposure: 0.25 },
details: { vignette: 0.3, grain: 0.1 },
effects: {
blur: 0.2,
pixelate: 0.4,
chromaBleed: 0.3,
tapeDamage: 0.5,
filmArtifacts: 0.6,
halftone: 0.7,
halftoneSize: 0.8,
twoInkPrint: 0.9,
twoInkPrintSize: 0.4,
ascii: 0.65,
asciiSize: 0.35,
asciiInvert: 1,
dither: 0.75,
ditherSize: 0.25,
},
palette: ["#080717", "#3c185f", "#d9339f", "#ff6b66", "#f6d365", "#aafae0"],
lut: { src: "assets/luts/test.cube", intensity: 0.6 },
});
});
it("treats zero global intensity as inactive even with LUT data", () => {
@@ -106,7 +487,7 @@ describe("color grading", () => {
});
it("treats finishing details as active grading", () => {
const grading = normalizeHfColorGrading({ details: { vignette: 0.2 } });
const grading = normalizeHfColorGrading({ intensity: 0, details: { vignette: 0.2 } });
expect(isHfColorGradingActive(grading)).toBe(true);
});
@@ -117,8 +498,36 @@ describe("color grading", () => {
expect(isHfColorGradingActive(grading)).toBe(false);
});
it("does not activate grading for halftone size alone", () => {
const grading = normalizeHfColorGrading({ effects: { halftoneSize: 0.8 } });
expect(isHfColorGradingActive(grading)).toBe(false);
});
it("does not activate grading for two-ink screen size alone", () => {
const grading = normalizeHfColorGrading({ effects: { twoInkPrintSize: 0.8 } });
expect(isHfColorGradingActive(grading)).toBe(false);
});
it("does not activate grading for ASCII/dither options without an effect amount", () => {
const grading = normalizeHfColorGrading({
effects: { asciiSize: 0.8, asciiInvert: 1, ditherSize: 0.7 },
palette: ["#111111", "#eeeeee"],
});
expect(isHfColorGradingActive(grading)).toBe(false);
});
it("rejects malformed or out-of-range effect palettes", () => {
expect(normalizeHfColorGrading({ palette: ["#000000"] })?.palette).toBeNull();
expect(normalizeHfColorGrading({ palette: ["#000000", "red"] })?.palette).toBeNull();
expect(
normalizeHfColorGrading({
palette: ["#000000", "#111111", "#222222", "#333333", "#444444", "#555555", "#666666"],
})?.palette,
).toBeNull();
});
it("treats media effects as active grading", () => {
const grading = normalizeHfColorGrading({ effects: { blur: 0.2 } });
const grading = normalizeHfColorGrading({ intensity: 0, effects: { blur: 0.2 } });
expect(isHfColorGradingActive(grading)).toBe(true);
});
@@ -137,13 +546,14 @@ describe("color grading", () => {
grainSize: "$grainSize",
},
effects: { pixelate: "$pixelate" },
palette: "$palette",
lut: {
src: "$lutSrc",
intensity: "$lutIntensity",
},
}),
{
preset: "warm-clean",
preset: "warm-daylight",
gradingIntensity: 0.6,
exposure: 0.25,
vibrance: 0.3,
@@ -151,12 +561,13 @@ describe("color grading", () => {
vignette: 0.15,
grainSize: 0.4,
pixelate: 0.1,
palette: ["#080717", "#aafae0"],
lutSrc: "assets/luts/warm.cube",
lutIntensity: 0.4,
},
);
expect(grading?.preset).toBe("warm-clean");
expect(grading?.preset).toBe("warm-daylight");
expect(grading?.intensity).toBe(0.6);
expect(grading?.adjust.exposure).toBe(0.25);
expect(grading?.adjust.vibrance).toBe(0.3);
@@ -164,6 +575,7 @@ describe("color grading", () => {
expect(grading?.details.vignette).toBe(0.15);
expect(grading?.details.grainSize).toBe(0.4);
expect(grading?.effects.pixelate).toBe(0.1);
expect(grading?.palette).toEqual(["#080717", "#aafae0"]);
expect(grading?.lut).toEqual({ src: "assets/luts/warm.cube", intensity: 0.4 });
});
+535 -136
View File
@@ -1,3 +1,13 @@
import { DEFAULT_MAX_CUBE_LUT_SIZE } from "./colorLuts";
import {
COLOR_GRADING_ADJUST_KEYS,
COLOR_GRADING_COLOR_SPACE,
COLOR_GRADING_DETAIL_KEYS,
COLOR_GRADING_EFFECT_KEYS,
COLOR_GRADING_LUT_KEYS,
COLOR_GRADING_TOP_LEVEL_KEYS,
} from "@hyperframes/parsers/color-grading-contract";
export const HF_COLOR_GRADING_ATTR = "data-color-grading";
// Runtime <-> studio contract attributes. The runtime grading engine writes
@@ -14,12 +24,10 @@ export const COLOR_GRADING_AUTHORED_OPACITY_ATTR = "data-hf-authored-opacity";
export const HF_COLOR_GRADING_CANVAS_ID_PREFIX = "__hf_color_grading_";
export const HF_COLOR_GRADING_COLOR_SPACE = "rec709";
export const HF_COLOR_GRADING_COLOR_SPACE = COLOR_GRADING_COLOR_SPACE;
export type HfColorGradingPresetId =
| "neutral"
| "natural-lift"
| "fresh-pop"
| "warm-daylight"
| "clean-studio"
| "skin-soft"
@@ -29,38 +37,115 @@ export type HfColorGradingPresetId =
| "vintage-wash"
| "mono-clean"
| "mono-fade"
| "warm-clean"
| "cool-clean"
| "soft-boost"
| "bright-pop"
| "deep-contrast";
| "deep-contrast"
| "creator-camcorder"
| "vhs-playback"
| "home-movie-8mm"
| "editorial-halftone"
| "two-ink-print";
export type HfColorGradingAdjustKey =
| "exposure"
| "contrast"
| "highlights"
| "shadows"
| "whites"
| "blacks"
| "temperature"
| "tint"
| "vibrance"
| "saturation";
export type HfColorGradingAdjustKey = (typeof COLOR_GRADING_ADJUST_KEYS)[number];
const ADJUST_ZERO = {
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
temperature: 0,
tint: 0,
vibrance: 0,
saturation: 0,
} satisfies Record<HfColorGradingAdjustKey, number>;
export type HfColorGradingAdjust = Partial<Record<HfColorGradingAdjustKey, number>>;
export type HfColorGradingDetailKey =
| "vignette"
| "vignetteMidpoint"
| "vignetteRoundness"
| "vignetteFeather"
| "grain"
| "grainSize"
| "grainRoughness";
// Sub-controls use useful identity defaults rather than raw mathematical zeroes.
export type HfColorGradingDetailKey = (typeof COLOR_GRADING_DETAIL_KEYS)[number];
const DETAIL_DEFAULTS = {
vignette: 0,
vignetteMidpoint: 0.5,
vignetteRoundness: 0,
vignetteFeather: 0.65,
grain: 0,
grainSize: 0.25,
grainRoughness: 0.5,
} satisfies Record<HfColorGradingDetailKey, number>;
export type HfColorGradingDetails = Partial<Record<HfColorGradingDetailKey, number>>;
export type HfColorGradingEffectKey = "blur" | "pixelate";
export type HfColorGradingEffectKey = (typeof COLOR_GRADING_EFFECT_KEYS)[number];
const EFFECT_DEFAULTS = {
blur: 0,
pixelate: 0,
chromaBleed: 0,
tapeDamage: 0,
tapeTracking: 0,
tapeNoise: 1,
tapeSpeed: 0.5,
filmArtifacts: 0,
halftone: 0,
halftoneSize: 0,
twoInkPrint: 0,
twoInkPrintSize: 0,
ascii: 0,
asciiSize: 5 / 76,
asciiInvert: 0,
asciiStyle: 0,
asciiColor: 1,
asciiRotation: 0,
dither: 0,
ditherSize: 0,
bloom: 0,
bloomRadius: 8,
monoScreen: 0,
monoScreenSize: 0,
monoScreenAngle: 0,
monoScreenSpread: 0,
monoScreenShape: 0,
monoScreenInvert: 0,
scanlines: 0,
scanlineCount: 0,
scanlineSoftness: 0,
chromaticAberration: 0,
chromaticAngle: 0,
crtCurvature: 0,
digitalGlitch: 0,
digitalGlitchColorSplit: 0,
digitalGlitchLineTear: 0,
digitalGlitchPixelate: 0,
digitalGlitchBlockAmount: 0,
digitalGlitchBlockDisplacement: 0,
digitalGlitchBlockOpacity: 0,
digitalGlitchSpeed: 0,
engraving: 0,
engravingSpacing: 7 / 17,
engravingMinThickness: 0.2,
engravingMaxThickness: 3.2 / 7,
engravingAngle: 0.25,
engravingContrast: 7 / 15,
engravingSharpness: 0.59,
engravingWave: 0.2,
engravingWaveFrequency: 2 / 9,
crosshatch: 0,
crosshatchSpacing: 7 / 25,
crosshatchThickness: 0.25,
crosshatchAngle: 0.25,
crosshatchContrast: 1 / 3,
crosshatchEdges: 0.5,
crosshatchLineWeight: 0,
crosshatchWave: 0.33,
crosshatchWaveFrequency: 2 / 9,
kuwahara: 0,
kuwaharaRadius: 1 / 7,
kuwaharaSharpness: 5 / 16,
kuwaharaSaturation: 0.5,
} satisfies Record<HfColorGradingEffectKey, number>;
export type HfColorGradingEffects = Partial<Record<HfColorGradingEffectKey, number>>;
@@ -76,10 +161,17 @@ export interface HfColorGrading {
adjust?: HfColorGradingAdjust;
details?: HfColorGradingDetails;
effects?: HfColorGradingEffects;
palette?: readonly string[] | null;
lut?: HfColorGradingLutRef | string | null;
colorSpace?: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
}
export const HF_COLOR_GRADING_TOP_LEVEL_KEYS =
COLOR_GRADING_TOP_LEVEL_KEYS satisfies readonly (keyof HfColorGrading)[];
export const HF_COLOR_GRADING_LUT_KEYS =
COLOR_GRADING_LUT_KEYS satisfies readonly (keyof HfColorGradingLutRef)[];
export interface NormalizedHfColorGrading {
enabled: boolean;
preset: HfColorGradingPresetId | string | null;
@@ -87,6 +179,7 @@ export interface NormalizedHfColorGrading {
adjust: Record<HfColorGradingAdjustKey, number>;
details: Record<HfColorGradingDetailKey, number>;
effects: Record<HfColorGradingEffectKey, number>;
palette: readonly string[] | null;
lut: HfColorGradingLutRef | null;
colorSpace: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
}
@@ -101,88 +194,145 @@ export interface HfColorGradingTarget {
export interface HfColorGradingPreset {
id: HfColorGradingPresetId;
label: string;
intensity: number;
adjust: Record<HfColorGradingAdjustKey, number>;
details: Record<HfColorGradingDetailKey, number>;
effects: Record<HfColorGradingEffectKey, number>;
}
export const HF_COLOR_GRADING_PALETTES = [
{ id: "noir", label: "Noir", group: "Classic", colors: ["#000000", "#ffffff"] },
{
id: "ink-paper",
label: "Ink & Paper",
group: "Classic",
colors: ["#1a1a2e", "#f5f5dc"],
},
{
id: "terminal",
label: "Terminal",
group: "Classic",
colors: ["#001100", "#00ff00"],
},
{
id: "amber-glow",
label: "Amber Glow",
group: "Classic",
colors: ["#1a0f00", "#ffcc00"],
},
{
id: "handheld-green",
label: "Handheld Green",
group: "Classic",
colors: ["#0f380f", "#306230", "#8bac0f", "#9bbc0f"],
},
{
id: "golden-hour",
label: "Golden Hour",
group: "Mood",
colors: ["#1a1205", "#4a3510", "#8b6914", "#d4a017", "#fff8dc"],
},
{
id: "deep-sea",
label: "Deep Sea",
group: "Mood",
colors: ["#0a1628", "#1a3a5c", "#2d6187", "#5ba4c9", "#a8dce8"],
},
{
id: "arctic-night",
label: "Arctic Night",
group: "Mood",
colors: ["#0a0a14", "#1a2a4a", "#3a5a8a", "#6a9aca", "#cae8ff"],
},
{
id: "synthwave",
label: "Synthwave",
group: "Mood",
colors: ["#120458", "#7b2cbf", "#e040fb", "#ff6ec7", "#fff59d"],
},
{
id: "vaporwave",
label: "Vaporwave",
group: "Mood",
colors: ["#1a0a2e", "#3d1a5c", "#ff71ce", "#01cdfe", "#fffb96"],
},
{
id: "forest",
label: "Forest",
group: "Mood",
colors: ["#1a2e1a", "#2d4a2d", "#4a7c4a", "#7ab37a", "#c8e6c8"],
},
{
id: "sepia",
label: "Sepia",
group: "Mono",
colors: ["#1a1610", "#3d3020", "#6b5a40", "#a89070", "#e8dcc8"],
},
{
id: "blueprint",
label: "Blueprint",
group: "Mono",
colors: ["#001830", "#003060", "#0050a0", "#0080e0", "#e0f0ff"],
},
{
id: "warm-print",
label: "Warm Print",
group: "HyperFrames",
colors: ["#17121a", "#824c50", "#e09873", "#f7ddb1"],
},
{
id: "electric-ink",
label: "Electric Ink",
group: "HyperFrames",
colors: ["#080717", "#3c185f", "#7e2278", "#d9339f", "#ff6b66", "#aafae0"],
},
] as const;
export type HfColorGradingVariableMap = Record<string, unknown>;
const ADJUST_ZERO: Record<HfColorGradingAdjustKey, number> = {
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
temperature: 0,
tint: 0,
vibrance: 0,
saturation: 0,
export const HF_COLOR_GRADING_ADJUST_KEYS =
COLOR_GRADING_ADJUST_KEYS satisfies readonly HfColorGradingAdjustKey[];
export const HF_COLOR_GRADING_DETAIL_KEYS =
COLOR_GRADING_DETAIL_KEYS satisfies readonly HfColorGradingDetailKey[];
export const HF_COLOR_GRADING_EFFECT_KEYS =
COLOR_GRADING_EFFECT_KEYS satisfies readonly HfColorGradingEffectKey[];
const VINTAGE_WASH_ADJUST: HfColorGradingAdjust = {
exposure: 0.03,
contrast: -0.12,
highlights: -0.1,
shadows: 0.16,
whites: -0.04,
blacks: 0.08,
temperature: 0.13,
vibrance: -0.08,
saturation: -0.08,
};
// Detail sub-controls keep identity-state defaults so enabling vignette/grain starts from useful
// perceptual settings instead of raw mathematical zeroes.
const DETAIL_ZERO: Record<HfColorGradingDetailKey, number> = {
vignette: 0,
vignetteMidpoint: 0.5,
vignetteRoundness: 0,
vignetteFeather: 0.65,
grain: 0,
grainSize: 0.25,
grainRoughness: 0.5,
};
const EFFECT_ZERO: Record<HfColorGradingEffectKey, number> = {
blur: 0,
pixelate: 0,
};
export const HF_COLOR_GRADING_ADJUST_KEYS = Object.keys(
ADJUST_ZERO,
) as readonly HfColorGradingAdjustKey[];
export const HF_COLOR_GRADING_DETAIL_KEYS = Object.keys(
DETAIL_ZERO,
) as readonly HfColorGradingDetailKey[];
export const HF_COLOR_GRADING_EFFECT_KEYS = Object.keys(
EFFECT_ZERO,
) as readonly HfColorGradingEffectKey[];
const VINTAGE_WASH_DETAILS: HfColorGradingDetails = { vignette: 0.18 };
function preset(
id: HfColorGradingPresetId,
label: string,
adjust: HfColorGradingAdjust = {},
details: HfColorGradingDetails = {},
effects: HfColorGradingEffects = {},
intensity = 1,
): HfColorGradingPreset {
return {
id,
label,
intensity,
adjust: { ...ADJUST_ZERO, ...adjust },
details: { ...DETAIL_ZERO, ...details },
effects: { ...EFFECT_ZERO },
details: { ...DETAIL_DEFAULTS, ...details },
effects: { ...EFFECT_DEFAULTS, ...effects },
};
}
export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [
preset("neutral", "Neutral"),
preset("natural-lift", "Natural Lift", {
exposure: 0.04,
contrast: 0.06,
highlights: -0.06,
shadows: 0.08,
saturation: 0.05,
}),
preset("fresh-pop", "Fresh Pop", {
exposure: 0.08,
contrast: 0.12,
whites: 0.06,
shadows: 0.04,
temperature: -0.02,
vibrance: 0.08,
saturation: 0.16,
}),
preset("warm-daylight", "Warm Daylight", {
exposure: 0.06,
contrast: 0.07,
@@ -247,24 +397,7 @@ export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [
vignette: 0.1,
},
),
preset(
"vintage-wash",
"Vintage Wash",
{
exposure: 0.03,
contrast: -0.12,
highlights: -0.1,
shadows: 0.16,
whites: -0.04,
blacks: 0.08,
temperature: 0.13,
vibrance: -0.08,
saturation: -0.08,
},
{
vignette: 0.18,
},
),
preset("vintage-wash", "Vintage Wash", VINTAGE_WASH_ADJUST, VINTAGE_WASH_DETAILS),
preset("mono-clean", "Mono Clean", {
contrast: 0.12,
highlights: -0.04,
@@ -286,23 +419,6 @@ export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [
vignette: 0.08,
},
),
preset("warm-clean", "Warm Clean", {
exposure: 0.05,
contrast: 0.08,
highlights: -0.08,
shadows: 0.08,
temperature: 0.16,
vibrance: 0.04,
saturation: 0.06,
}),
preset("cool-clean", "Cool Clean", {
contrast: 0.06,
highlights: -0.06,
shadows: 0.06,
temperature: -0.12,
tint: 0.04,
saturation: 0.04,
}),
preset("soft-boost", "Soft Boost", {
exposure: 0.06,
contrast: -0.04,
@@ -327,6 +443,74 @@ export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [
blacks: -0.12,
saturation: 0.06,
}),
preset(
"creator-camcorder",
"Creator Camcorder",
{
contrast: 0.08,
highlights: -0.05,
shadows: 0.02,
whites: 0.03,
blacks: -0.04,
temperature: -0.03,
tint: -0.015,
vibrance: -0.03,
saturation: -0.06,
},
{ vignette: 0.06, grain: 0.08, grainSize: 0.18, grainRoughness: 0.58 },
{ chromaBleed: 0.55 },
0.72,
),
preset(
"vhs-playback",
"VHS Playback",
{ contrast: -0.04, saturation: -0.08 },
{ grain: 0.16, grainSize: 0.12, grainRoughness: 0.72 },
{
tapeDamage: 0.82,
tapeTracking: 0.85,
tapeNoise: 0.3,
tapeSpeed: 0.5,
chromaBleed: 0.5,
chromaticAberration: 0.18,
scanlines: 0.35,
scanlineCount: 0.17,
scanlineSoftness: 1,
digitalGlitch: 0.32,
digitalGlitchLineTear: 0.08,
digitalGlitchSpeed: 0.5,
},
),
preset(
"home-movie-8mm",
"8mm Home Movie",
VINTAGE_WASH_ADJUST,
{
...VINTAGE_WASH_DETAILS,
vignette: 0.28,
vignetteMidpoint: 0.54,
vignetteFeather: 0.72,
grain: 0.34,
grainSize: 0.18,
grainRoughness: 0.72,
},
{ filmArtifacts: 0.62 },
0.72,
),
preset(
"editorial-halftone",
"Editorial Halftone",
{ contrast: 0.04, saturation: 0.04 },
{},
{ halftone: 0.94, halftoneSize: 0.36 },
),
preset(
"two-ink-print",
"Two-Ink Print",
{ contrast: 0.08, highlights: -0.06, shadows: 0.04 },
{},
{ twoInkPrint: 1, twoInkPrintSize: 0.42 },
),
];
const PRESETS_BY_ID = new Map<string, HfColorGradingPreset>(
@@ -358,11 +542,222 @@ const DETAIL_LIMITS: Record<HfColorGradingDetailKey, { min: number; max: number
grainRoughness: { min: 0, max: 1 },
};
const EFFECT_LIMITS: Record<HfColorGradingEffectKey, { min: number; max: number }> = {
blur: { min: 0, max: 1 },
pixelate: { min: 0, max: 1 },
const UNIT_LIMIT = { min: 0, max: 1 };
const EFFECT_LIMIT_OVERRIDES: Partial<
Record<HfColorGradingEffectKey, { min: number; max: number }>
> = {
asciiStyle: { min: 0, max: 7 },
bloom: { min: 0, max: 3 },
bloomRadius: { min: 1, max: 100 },
monoScreenShape: { min: 0, max: 4 },
};
export const HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS = [
"blur",
"pixelate",
"chromaBleed",
"tapeDamage",
"filmArtifacts",
"halftone",
"twoInkPrint",
"ascii",
"dither",
"bloom",
"monoScreen",
"scanlines",
"chromaticAberration",
"crtCurvature",
"digitalGlitch",
"engraving",
"crosshatch",
"kuwahara",
] as const satisfies readonly HfColorGradingEffectKey[];
export type HfColorGradingActiveEffectKey = (typeof HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS)[number];
export const HF_COLOR_GRADING_EFFECT_PRESETS = HF_COLOR_GRADING_PRESETS.filter((preset) =>
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS.some((key) => preset.effects[key] > 0.0001),
);
export const HF_COLOR_GRADING_GRADE_PRESETS = HF_COLOR_GRADING_PRESETS.filter(
(preset) => !HF_COLOR_GRADING_EFFECT_PRESETS.includes(preset),
);
/** Useful one-click values. Sub-controls not listed here keep their normalized defaults. */
export const HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS: Readonly<
Record<HfColorGradingActiveEffectKey, HfColorGradingEffects>
> = {
blur: { blur: 0.45 },
pixelate: { pixelate: 0.55 },
bloom: { bloom: 0.55, bloomRadius: 8 },
chromaBleed: { chromaBleed: 0.55 },
tapeDamage: {
tapeDamage: 0.65,
tapeTracking: 0.55,
tapeNoise: 0.25,
tapeSpeed: 0.5,
},
filmArtifacts: { filmArtifacts: 0.55 },
scanlines: { scanlines: 0.35, scanlineCount: 0.17, scanlineSoftness: 1 },
chromaticAberration: { chromaticAberration: 0.15, chromaticAngle: 0 },
crtCurvature: { crtCurvature: 0.2 },
digitalGlitch: {
digitalGlitch: 0.55,
digitalGlitchColorSplit: 0.25,
digitalGlitchLineTear: 0.25,
digitalGlitchPixelate: 0.15,
digitalGlitchBlockAmount: 0.5,
digitalGlitchBlockDisplacement: 0.25,
digitalGlitchBlockOpacity: 0,
digitalGlitchSpeed: 0.5,
},
halftone: { halftone: 0.94, halftoneSize: 0.36 },
twoInkPrint: { twoInkPrint: 1, twoInkPrintSize: 0.42 },
ascii: {
ascii: 1,
asciiSize: 5 / 76,
asciiInvert: 0,
asciiStyle: 0,
asciiColor: 1,
asciiRotation: 0,
},
dither: { dither: 1, ditherSize: 0.5 },
monoScreen: {
monoScreen: 1,
monoScreenSize: 0.35,
monoScreenAngle: 0.25,
monoScreenSpread: 0.3,
monoScreenShape: 0,
monoScreenInvert: 0,
},
engraving: {
engraving: 1,
engravingSpacing: 7 / 17,
engravingMinThickness: 0.2,
engravingMaxThickness: 3.2 / 7,
engravingAngle: 0.25,
engravingContrast: 7 / 15,
engravingSharpness: 0.59,
engravingWave: 0.2,
engravingWaveFrequency: 2 / 9,
},
crosshatch: {
crosshatch: 1,
crosshatchSpacing: 7 / 25,
crosshatchThickness: 0.25,
crosshatchAngle: 0.25,
crosshatchContrast: 1 / 3,
crosshatchEdges: 0.5,
crosshatchLineWeight: 0,
crosshatchWave: 0.33,
crosshatchWaveFrequency: 2 / 9,
},
kuwahara: {
kuwahara: 1,
kuwaharaRadius: 1 / 7,
kuwaharaSharpness: 5 / 16,
kuwaharaSaturation: 0.5,
},
};
export const HF_COLOR_GRADING_ANIMATABLE_PROPERTIES = [
{ path: "intensity", name: "--hf-color-grading-intensity", min: 0, max: 1 },
{ path: "lut.intensity", name: "--hf-color-grading-lut-intensity", min: 0, max: 1 },
{ path: "adjust.exposure", name: "--hf-color-grading-exposure", min: -2, max: 2 },
{ path: "effects.blur", name: "--hf-color-grading-blur", min: 0, max: 1 },
{ path: "effects.bloom", name: "--hf-color-grading-bloom", min: 0, max: 3 },
{ path: "effects.kuwahara", name: "--hf-color-grading-kuwahara", min: 0, max: 1 },
{ path: "effects.pixelate", name: "--hf-color-grading-pixelate", min: 0, max: 1 },
{ path: "effects.ascii", name: "--hf-color-grading-ascii", min: 0, max: 1 },
{ path: "effects.dither", name: "--hf-color-grading-dither", min: 0, max: 1 },
] as const;
export type HfColorGradingAnimatablePath =
(typeof HF_COLOR_GRADING_ANIMATABLE_PROPERTIES)[number]["path"];
function effectLimit(key: HfColorGradingEffectKey): { min: number; max: number } {
return EFFECT_LIMIT_OVERRIDES[key] ?? UNIT_LIMIT;
}
const PALETTE_EFFECT_KEYS = new Set<HfColorGradingActiveEffectKey>([
"ascii",
"dither",
"monoScreen",
"engraving",
"crosshatch",
]);
const MULTIPASS_EFFECT_KEYS = new Set<HfColorGradingActiveEffectKey>(["blur", "bloom", "kuwahara"]);
/** Agent-readable view of the canonical grading and effect contracts. */
export function getHfColorGradingCapabilities() {
return {
version: 1,
targetTags: ["img", "video"],
colorSpace: HF_COLOR_GRADING_COLOR_SPACE,
intensity: { identity: 1, min: 0, max: 1 },
palette: { minColors: 2, maxColors: 6, colorFormat: "#rrggbb" },
lut: {
format: "3d-cube",
maxCubeSize: DEFAULT_MAX_CUBE_LUT_SIZE,
intensity: { ...UNIT_LIMIT },
},
presets: [...HF_COLOR_GRADING_GRADE_PRESETS, ...HF_COLOR_GRADING_EFFECT_PRESETS].map(
(preset) => ({
id: preset.id,
label: preset.label,
intensity: preset.intensity,
}),
),
adjustments: HF_COLOR_GRADING_ADJUST_KEYS.map((key) => ({
key,
identity: ADJUST_ZERO[key],
...ADJUST_LIMITS[key],
})),
finishing: HF_COLOR_GRADING_DETAIL_KEYS.map((key) => ({
key,
identity: DETAIL_DEFAULTS[key],
...DETAIL_LIMITS[key],
})),
effects: HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS.map((key) => {
const apply = HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[key];
return {
key,
apply: { ...apply },
supportsPalette: PALETTE_EFFECT_KEYS.has(key),
renderLane: MULTIPASS_EFFECT_KEYS.has(key) ? "multipass" : "single-pass",
controls: HF_COLOR_GRADING_EFFECT_KEYS.filter((control) =>
Object.hasOwn(apply, control),
).map((control) => ({
key: control,
identity: EFFECT_DEFAULTS[control],
recommended: apply[control] ?? EFFECT_DEFAULTS[control],
...effectLimit(control),
})),
};
}),
palettes: HF_COLOR_GRADING_PALETTES.map(({ id, label, group, colors }) => ({
id,
label,
group,
colors,
})),
animatable: HF_COLOR_GRADING_ANIMATABLE_PROPERTIES,
};
}
export type HfColorGradingCapabilities = ReturnType<typeof getHfColorGradingCapabilities>;
const EFFECT_PALETTE_COLOR = /^#[0-9a-f]{6}$/i;
function normalizePalette(value: unknown): readonly string[] | null {
if (!Array.isArray(value) || value.length < 2 || value.length > 6) return null;
if (!value.every((color) => typeof color === "string" && EFFECT_PALETTE_COLOR.test(color))) {
return null;
}
return value.map((color) => color.toLowerCase());
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -417,7 +812,7 @@ function readColorGradingObject(raw: unknown): Record<string, unknown> | null {
return null;
}
}
return { preset: trimmed, intensity: 1 };
return { preset: trimmed };
}
return isRecord(raw) ? raw : null;
}
@@ -466,8 +861,8 @@ export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading
const presetId = normalizePresetId(grading.preset);
const preset = getHfColorGradingPreset(presetId);
const presetAdjust = preset?.adjust ?? ADJUST_ZERO;
const presetDetails = preset?.details ?? DETAIL_ZERO;
const presetEffects = preset?.effects ?? EFFECT_ZERO;
const presetDetails = preset?.details ?? DETAIL_DEFAULTS;
const presetEffects = preset?.effects ?? EFFECT_DEFAULTS;
const rawAdjust = isRecord(grading.adjust) ? grading.adjust : {};
const rawDetails = isRecord(grading.details) ? grading.details : {};
const rawEffects = isRecord(grading.effects) ? grading.effects : {};
@@ -483,23 +878,27 @@ export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading
result[key] = readLimitedValue(rawDetails[key] ?? presetDetails[key], DETAIL_LIMITS[key]);
return result;
},
{ ...DETAIL_ZERO },
{ ...DETAIL_DEFAULTS },
);
const effects = HF_COLOR_GRADING_EFFECT_KEYS.reduce<Record<HfColorGradingEffectKey, number>>(
(result, key) => {
result[key] = readLimitedValue(rawEffects[key] ?? presetEffects[key], EFFECT_LIMITS[key]);
result[key] = readLimitedValue(
rawEffects[key] ?? presetEffects[key],
EFFECT_LIMIT_OVERRIDES[key] ?? UNIT_LIMIT,
);
return result;
},
{ ...EFFECT_ZERO },
{ ...EFFECT_DEFAULTS },
);
return {
enabled: true,
preset: presetId,
intensity: clampUnit(grading.intensity, 1),
intensity: clampUnit(grading.intensity, preset?.intensity ?? 1),
adjust,
details,
effects,
palette: normalizePalette(grading.palette),
lut: normalizeLut(grading.lut),
colorSpace:
typeof grading.colorSpace === "string" && grading.colorSpace.trim()
@@ -520,20 +919,20 @@ export function serializeHfColorGrading(
): string {
const normalized = normalizeHfColorGrading(grading);
if (!normalized) return "";
const { enabled: _enabled, ...serializable } = normalized;
return JSON.stringify(serializable);
const { enabled: _enabled, palette, ...serializable } = normalized;
return JSON.stringify(palette ? { ...serializable, palette } : serializable);
}
export function isHfColorGradingActive(
grading: NormalizedHfColorGrading | null,
): grading is NormalizedHfColorGrading {
if (!grading?.enabled) return false;
if (grading.intensity === 0) return false;
if (grading.lut && grading.lut.intensity !== 0) return true;
return (
HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001) ||
const hasIndependentTreatment =
Math.abs(grading.details.vignette) > 0.0001 ||
Math.abs(grading.details.grain) > 0.0001 ||
HF_COLOR_GRADING_EFFECT_KEYS.some((key) => Math.abs(grading.effects[key]) > 0.0001)
);
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS.some((key) => Math.abs(grading.effects[key]) > 0.0001);
if (hasIndependentTreatment) return true;
if (grading.intensity === 0) return false;
if (grading.lut && grading.lut.intensity !== 0) return true;
return HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001);
}
+12
View File
@@ -169,11 +169,20 @@ export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif"
export {
HF_COLOR_GRADING_ATTR,
HF_COLOR_GRADING_ADJUST_KEYS,
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
HF_COLOR_GRADING_ANIMATABLE_PROPERTIES,
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
HF_COLOR_GRADING_COLOR_SPACE,
HF_COLOR_GRADING_DETAIL_KEYS,
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
HF_COLOR_GRADING_EFFECT_KEYS,
HF_COLOR_GRADING_EFFECT_PRESETS,
HF_COLOR_GRADING_GRADE_PRESETS,
HF_COLOR_GRADING_LUT_KEYS,
HF_COLOR_GRADING_PALETTES,
HF_COLOR_GRADING_PRESETS,
HF_COLOR_GRADING_TOP_LEVEL_KEYS,
getHfColorGradingCapabilities,
isHfColorGradingActive,
normalizeHfColorGrading,
normalizeHfColorGradingWithVariables,
@@ -182,6 +191,8 @@ export {
type HfColorGrading,
type HfColorGradingAdjust,
type HfColorGradingAdjustKey,
type HfColorGradingAnimatablePath,
type HfColorGradingActiveEffectKey,
type HfColorGradingDetailKey,
type HfColorGradingDetails,
type HfColorGradingEffectKey,
@@ -191,6 +202,7 @@ export {
type HfColorGradingPresetId,
type HfColorGradingTarget,
type HfColorGradingVariableMap,
type HfColorGradingCapabilities,
type NormalizedHfColorGrading,
} from "./colorGrading";
export { parseCubeLut, CubeLutParseError, type ParseCubeLutOptions } from "./colorLuts";
+1 -1
View File
@@ -182,7 +182,7 @@ describe("installRuntimeControlBridge", () => {
it("dispatches set-color-grading command with target and grading payload", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
const grading = { preset: "warm-clean", intensity: 0.7 };
const grading = { preset: "warm-daylight", intensity: 0.7 };
const target = { id: "hero-video", selectorIndex: 0 };
handler(makeControlMessage("set-color-grading", { target, grading }));
expect(deps.onSetColorGrading).toHaveBeenCalledWith(target, grading);
+6
View File
@@ -80,6 +80,12 @@
"types": "./dist/compositionContract.d.ts",
"environments": ["browser", "bun", "node"]
},
"./color-grading-contract": {
"source": "./src/colorGradingContract.ts",
"runtime": "./dist/colorGradingContract.js",
"types": "./dist/colorGradingContract.d.ts",
"environments": ["browser", "bun", "node"]
},
"./sub-composition-validity": {
"source": "./src/subCompositionValidity.ts",
"runtime": "./dist/subCompositionValidity.js",
+10
View File
@@ -88,6 +88,12 @@
"import": "./src/compositionContract.ts",
"types": "./src/compositionContract.ts"
},
"./color-grading-contract": {
"bun": "./src/colorGradingContract.ts",
"node": "./dist/colorGradingContract.js",
"import": "./src/colorGradingContract.ts",
"types": "./src/colorGradingContract.ts"
},
"./sub-composition-validity": {
"bun": "./src/subCompositionValidity.ts",
"node": "./dist/subCompositionValidity.js",
@@ -159,6 +165,10 @@
"import": "./dist/compositionContract.js",
"types": "./dist/compositionContract.d.ts"
},
"./color-grading-contract": {
"import": "./dist/colorGradingContract.js",
"types": "./dist/colorGradingContract.d.ts"
},
"./sub-composition-validity": {
"import": "./dist/subCompositionValidity.js",
"types": "./dist/subCompositionValidity.d.ts"
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
COLOR_GRADING_ADJUST_KEYS,
COLOR_GRADING_DETAIL_KEYS,
COLOR_GRADING_EFFECT_KEYS,
COLOR_GRADING_LUT_KEYS,
COLOR_GRADING_TOP_LEVEL_KEYS,
isColorGradingVariableRef,
validateColorGradingContract,
} from "./colorGradingContract";
describe("color grading contract", () => {
it("publishes one complete key registry", () => {
expect(COLOR_GRADING_TOP_LEVEL_KEYS).toContain("effects");
expect(COLOR_GRADING_ADJUST_KEYS).toContain("exposure");
expect(COLOR_GRADING_DETAIL_KEYS).toContain("grain");
expect(COLOR_GRADING_EFFECT_KEYS).toContain("kuwahara");
expect(COLOR_GRADING_LUT_KEYS).toEqual(["src", "intensity"]);
});
it("accepts the complete current contract and variable references", () => {
expect(
validateColorGradingContract({
enabled: "$enabled",
preset: "clean-studio",
intensity: 0.8,
adjust: { exposure: 0.1, contrast: "$contrast" },
details: { grain: 0.1 },
effects: { bloom: 1.2, bloomRadius: 24, asciiStyle: 4 },
palette: ["#112233", "#abcdef"],
lut: { src: "$lutPath", intensity: 0.5 },
colorSpace: "rec709",
}),
).toEqual([]);
expect(isColorGradingVariableRef("${grade.amount}")).toBe(true);
});
it("rejects unknown fields, invalid ranges, and malformed palettes", () => {
expect(
validateColorGradingContract({
mystery: true,
adjust: { exposure: 3, mystery: 1 },
details: { grain: -0.1 },
effects: { bloom: 4, bloomRadius: 0 },
palette: ["red"],
lut: {},
colorSpace: "display-p3",
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "grading" }),
expect.objectContaining({ path: "adjust" }),
expect.objectContaining({ path: "adjust.exposure" }),
expect.objectContaining({ path: "details.grain" }),
expect.objectContaining({ path: "effects.bloom" }),
expect.objectContaining({ path: "effects.bloomRadius" }),
expect.objectContaining({ path: "palette" }),
expect.objectContaining({ path: "lut.src" }),
expect.objectContaining({ path: "colorSpace" }),
]),
);
});
});
@@ -0,0 +1,331 @@
export const COLOR_GRADING_CONTRACT_VERSION = 1;
export const COLOR_GRADING_COLOR_SPACE = "rec709";
export const COLOR_GRADING_TOP_LEVEL_KEYS = [
"enabled",
"preset",
"intensity",
"adjust",
"details",
"effects",
"palette",
"lut",
"colorSpace",
] as const;
export const COLOR_GRADING_ADJUST_KEYS = [
"exposure",
"contrast",
"highlights",
"shadows",
"whites",
"blacks",
"temperature",
"tint",
"vibrance",
"saturation",
] as const;
export const COLOR_GRADING_DETAIL_KEYS = [
"vignette",
"vignetteMidpoint",
"vignetteRoundness",
"vignetteFeather",
"grain",
"grainSize",
"grainRoughness",
] as const;
export const COLOR_GRADING_EFFECT_KEYS = [
"blur",
"pixelate",
"chromaBleed",
"tapeDamage",
"tapeTracking",
"tapeNoise",
"tapeSpeed",
"filmArtifacts",
"halftone",
"halftoneSize",
"twoInkPrint",
"twoInkPrintSize",
"ascii",
"asciiSize",
"asciiInvert",
"asciiStyle",
"asciiColor",
"asciiRotation",
"dither",
"ditherSize",
"bloom",
"bloomRadius",
"monoScreen",
"monoScreenSize",
"monoScreenAngle",
"monoScreenSpread",
"monoScreenShape",
"monoScreenInvert",
"scanlines",
"scanlineCount",
"scanlineSoftness",
"chromaticAberration",
"chromaticAngle",
"crtCurvature",
"digitalGlitch",
"digitalGlitchColorSplit",
"digitalGlitchLineTear",
"digitalGlitchPixelate",
"digitalGlitchBlockAmount",
"digitalGlitchBlockDisplacement",
"digitalGlitchBlockOpacity",
"digitalGlitchSpeed",
"engraving",
"engravingSpacing",
"engravingMinThickness",
"engravingMaxThickness",
"engravingAngle",
"engravingContrast",
"engravingSharpness",
"engravingWave",
"engravingWaveFrequency",
"crosshatch",
"crosshatchSpacing",
"crosshatchThickness",
"crosshatchAngle",
"crosshatchContrast",
"crosshatchEdges",
"crosshatchLineWeight",
"crosshatchWave",
"crosshatchWaveFrequency",
"kuwahara",
"kuwaharaRadius",
"kuwaharaSharpness",
"kuwaharaSaturation",
] as const;
export const COLOR_GRADING_LUT_KEYS = ["src", "intensity"] as const;
type NumericLimit = Readonly<{ min: number; max: number }>;
const UNIT_LIMIT: NumericLimit = { min: 0, max: 1 };
const SIGNED_UNIT_LIMIT: NumericLimit = { min: -1, max: 1 };
const EFFECT_LIMIT_OVERRIDES: Readonly<Record<string, NumericLimit>> = {
asciiStyle: { min: 0, max: 7 },
bloom: { min: 0, max: 3 },
bloomRadius: { min: 1, max: 100 },
monoScreenShape: { min: 0, max: 4 },
};
const VARIABLE_REF = /^\$(?:\{[A-Za-z0-9_.:-]+\}|[A-Za-z0-9_.:-]+)$/;
const PALETTE_COLOR = /^#[0-9a-f]{6}$/i;
const OBJECT_SECTIONS = [
["adjust", COLOR_GRADING_ADJUST_KEYS],
["details", COLOR_GRADING_DETAIL_KEYS],
["effects", COLOR_GRADING_EFFECT_KEYS],
["lut", COLOR_GRADING_LUT_KEYS],
] as const;
export interface ColorGradingContractIssue {
path: string;
message: string;
hint?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isColorGradingVariableRef(value: unknown): value is string {
return typeof value === "string" && VARIABLE_REF.test(value.trim());
}
function validateObject(
value: unknown,
path: string,
keys: readonly string[],
issues: ColorGradingContractIssue[],
): Record<string, unknown> | null {
if (isColorGradingVariableRef(value)) return null;
if (!isRecord(value)) {
issues.push({ path, message: "must be an object or variable reference" });
return null;
}
const allowed = new Set(keys);
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
issues.push({ path, message: `has unsupported key(s): ${unknown.join(", ")}` });
}
return value;
}
function validateNumericField(
value: Record<string, unknown>,
key: string,
path: string,
limit: NumericLimit,
issues: ColorGradingContractIssue[],
): void {
const candidate = value[key];
if (candidate === undefined || isColorGradingVariableRef(candidate)) return;
if (
typeof candidate !== "number" ||
!Number.isFinite(candidate) ||
candidate < limit.min ||
candidate > limit.max
) {
issues.push({
path: path ? `${path}.${key}` : key,
message: `must be a finite number from ${limit.min} through ${limit.max}`,
});
}
}
function validateNumericSection(
value: Record<string, unknown>,
path: string,
keys: readonly string[],
limitFor: (key: string) => NumericLimit,
issues: ColorGradingContractIssue[],
): void {
for (const key of keys) validateNumericField(value, key, path, limitFor(key), issues);
}
function validatePalette(value: unknown, issues: ColorGradingContractIssue[]): void {
if (value === undefined || value === null || isColorGradingVariableRef(value)) return;
if (!Array.isArray(value) || value.length < 2 || value.length > 6) {
issues.push({ path: "palette", message: "must contain 2 to 6 hex colors" });
return;
}
value.forEach((color, index) => {
if (typeof color !== "string" || !PALETTE_COLOR.test(color)) {
issues.push({ path: `palette[${index}]`, message: "must be a six-digit hex color" });
}
});
}
function validateLut(
value: unknown,
object: Record<string, unknown> | null,
issues: ColorGradingContractIssue[],
): void {
if (typeof value === "string") {
if (!value.trim()) issues.push({ path: "lut", message: "must not be empty" });
return;
}
if (!object) return;
if (
!isColorGradingVariableRef(object.src) &&
(typeof object.src !== "string" || !object.src.trim())
) {
issues.push({
path: "lut.src",
message: "must be a non-empty string or variable reference",
});
}
validateNumericField(object, "intensity", "lut", UNIT_LIMIT, issues);
}
function validateEnabled(
grading: Record<string, unknown>,
issues: ColorGradingContractIssue[],
): void {
if (
grading.enabled !== undefined &&
!isColorGradingVariableRef(grading.enabled) &&
typeof grading.enabled !== "boolean"
) {
issues.push({ path: "enabled", message: "must be a boolean or variable reference" });
}
}
function validatePreset(
grading: Record<string, unknown>,
issues: ColorGradingContractIssue[],
): void {
if (
grading.preset !== undefined &&
grading.preset !== null &&
!isColorGradingVariableRef(grading.preset) &&
(typeof grading.preset !== "string" || !grading.preset.trim())
) {
issues.push({
path: "preset",
message: "must be a non-empty string, null, or variable reference",
});
}
}
function validateColorSpace(
grading: Record<string, unknown>,
issues: ColorGradingContractIssue[],
): void {
if (
grading.colorSpace !== undefined &&
!isColorGradingVariableRef(grading.colorSpace) &&
grading.colorSpace !== COLOR_GRADING_COLOR_SPACE
) {
issues.push({
path: "colorSpace",
message: `must be "${COLOR_GRADING_COLOR_SPACE}" or a variable reference`,
});
}
}
function validateTopLevel(
grading: Record<string, unknown>,
issues: ColorGradingContractIssue[],
): void {
validateEnabled(grading, issues);
validateNumericField(grading, "intensity", "", UNIT_LIMIT, issues);
validatePreset(grading, issues);
validateColorSpace(grading, issues);
}
function validateSection(
grading: Record<string, unknown>,
key: (typeof OBJECT_SECTIONS)[number][0],
keys: readonly string[],
issues: ColorGradingContractIssue[],
): void {
const section = grading[key];
if (section === undefined || (key === "lut" && section === null)) return;
if (key === "lut" && typeof section === "string") {
validateLut(section, null, issues);
return;
}
const object = validateObject(section, key, keys, issues);
if (!object) return;
if (key === "lut") return validateLut(section, object, issues);
const limitFor = (control: string): NumericLimit => {
if (key === "adjust" && control === "exposure") return { min: -2, max: 2 };
if (key === "adjust" || (key === "details" && control === "vignetteRoundness")) {
return SIGNED_UNIT_LIMIT;
}
return key === "effects" ? (EFFECT_LIMIT_OVERRIDES[control] ?? UNIT_LIMIT) : UNIT_LIMIT;
};
validateNumericSection(object, key, keys, limitFor, issues);
}
function validateSections(
grading: Record<string, unknown>,
issues: ColorGradingContractIssue[],
): void {
for (const [key, keys] of OBJECT_SECTIONS) validateSection(grading, key, keys, issues);
}
/** Browser-safe structural validation shared by Lint, CLI, and Core consumers. */
export function validateColorGradingContract(value: unknown): ColorGradingContractIssue[] {
if (typeof value === "string") {
return value.trim() ? [] : [{ path: "grading", message: "is empty" }];
}
const issues: ColorGradingContractIssue[] = [];
const grading = validateObject(value, "grading", COLOR_GRADING_TOP_LEVEL_KEYS, issues);
if (!grading) return issues;
validateTopLevel(grading, issues);
validateSections(grading, issues);
validatePalette(grading.palette, issues);
return issues;
}
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
assets: "src/assets.ts",
composition: "src/composition.ts",
compositionContract: "src/compositionContract.ts",
colorGradingContract: "src/colorGradingContract.ts",
subCompositionValidity: "src/subCompositionValidity.ts",
ffBinaries: "src/ffBinaries.ts",
assetResolution: "src/assetResolution.ts",
@@ -5,15 +5,15 @@ describe("patchMediaColorGradingInHtml", () => {
it("adds color grading to video and image tags only", () => {
const { html, count } = patchMediaColorGradingInHtml(
`<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
`{"preset":"natural-lift"}`,
`{"preset":"warm-daylight"}`,
);
expect(count).toBe(2);
expect(html).toContain(
`video id="v" data-color-grading="{&quot;preset&quot;:&quot;natural-lift&quot;}"`,
`video id="v" data-color-grading="{&quot;preset&quot;:&quot;warm-daylight&quot;}"`,
);
expect(html).toContain(
`img id="i" data-color-grading="{&quot;preset&quot;:&quot;natural-lift&quot;}"`,
`img id="i" data-color-grading="{&quot;preset&quot;:&quot;warm-daylight&quot;}"`,
);
expect(html).toContain(`<audio id="a"></audio>`);
});
@@ -243,11 +243,11 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
// needs its own accessible name via the dedicated ariaLabel prop.
expect(presetSelect.getAttribute("aria-label")).toBe("Preset");
act(() => {
presetSelect.value = "fresh-pop";
presetSelect.value = "bright-pop";
presetSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("fresh-pop");
expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("bright-pop");
act(() => root.unmount());
});
@@ -1159,20 +1159,20 @@ describe("FlatSelectRow — label/value options", () => {
const { host, root } = renderInto(
<FlatSelectRow
label="Preset"
value="natural-lift"
value="clean-studio"
options={[
{ value: "neutral", label: "Neutral" },
{ value: "natural-lift", label: "Natural Lift" },
{ value: "fresh-pop", label: "Fresh Pop" },
{ value: "clean-studio", label: "Clean Studio" },
{ value: "bright-pop", label: "Bright Pop" },
]}
tier="explicitCustom"
onChange={vi.fn()}
/>,
);
const select = host.querySelector("select");
expect(select?.value).toBe("natural-lift");
expect(select?.value).toBe("clean-studio");
const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent);
expect(options).toEqual(["Neutral", "Natural Lift", "Fresh Pop"]);
expect(options).toEqual(["Neutral", "Clean Studio", "Bright Pop"]);
act(() => root.unmount());
});
@@ -7,15 +7,15 @@ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
import { useColorGradingController } from "./useColorGradingController";
import type { DomEditSelection } from "./domEditing";
function freshPopGrading() {
const next = normalizeHfColorGrading({ preset: "fresh-pop", intensity: 1 });
if (!next) throw new Error("expected fresh-pop preset to normalize");
function brightPopGrading() {
const next = normalizeHfColorGrading({ preset: "bright-pop", intensity: 1 });
if (!next) throw new Error("expected bright-pop preset to normalize");
return next;
}
function naturalLiftGrading() {
const next = normalizeHfColorGrading({ preset: "natural-lift", intensity: 1 });
if (!next) throw new Error("expected natural-lift preset to normalize");
function warmDaylightGrading() {
const next = normalizeHfColorGrading({ preset: "warm-daylight", intensity: 1 });
if (!next) throw new Error("expected warm-daylight preset to normalize");
return next;
}
@@ -120,9 +120,9 @@ describe("useColorGradingController", () => {
const onSetAttributeLive = vi.fn();
const { root, getState } = renderHook(onSetAttributeLive);
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
expect(getState().grading.preset).toBe("fresh-pop");
expect(getState().grading.preset).toBe("bright-pop");
expect(onSetAttributeLive).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(400);
@@ -130,7 +130,7 @@ describe("useColorGradingController", () => {
expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string];
expect(attr).toBe("color-grading");
expect(value).toContain("fresh-pop");
expect(value).toContain("bright-pop");
act(() => root.unmount());
vi.useRealTimers();
});
@@ -149,9 +149,9 @@ describe("useColorGradingController", () => {
);
const { root, getState } = renderHook(onSetAttributeLive);
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
expect(getState().grading.preset).toBe("fresh-pop");
expect(getState().grading.preset).toBe("bright-pop");
act(() => {
vi.advanceTimersByTime(400);
});
@@ -170,9 +170,9 @@ describe("useColorGradingController", () => {
const onSetAttributeLive = vi.fn().mockRejectedValue(new Error("disk full"));
const { root, getState } = renderHook(onSetAttributeLive);
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
expect(getState().grading.preset).toBe("fresh-pop");
expect(getState().grading.preset).toBe("bright-pop");
act(() => {
vi.advanceTimersByTime(400);
});
@@ -182,7 +182,7 @@ describe("useColorGradingController", () => {
await Promise.resolve();
});
// Reverted to "neutral" (the last confirmed-good value, from before this
// commit) instead of permanently showing "fresh-pop" as if it had saved.
// commit) instead of permanently showing "bright-pop" as if it had saved.
expect(getState().grading.preset).toBe("neutral");
expect(getState().runtimeStatus.state).toBe("unavailable");
act(() => root.unmount());
@@ -206,7 +206,7 @@ describe("useColorGradingController", () => {
makeElement({ id: "s1-bg" }),
);
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
// Let the debounce fire while still on s1-bg — the persist call is now
// genuinely in flight (its promise won't settle until resolveA() below).
@@ -243,7 +243,7 @@ describe("useColorGradingController", () => {
let capturedOnSettledA: ((ok: boolean) => void) | undefined;
const onSetAttributeLive = vi
.fn()
// Edit A (fresh-pop): captures its onSettled and never resolves until
// Edit A (bright-pop): captures its onSettled and never resolves until
// resolveA() is called below — simulates a slow persist.
.mockImplementationOnce(
(_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
@@ -253,7 +253,7 @@ describe("useColorGradingController", () => {
});
},
)
// Edit B (natural-lift): settles immediately and successfully.
// Edit B (warm-daylight): settles immediately and successfully.
.mockImplementationOnce(
(_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
onSettled?.(true);
@@ -263,7 +263,7 @@ describe("useColorGradingController", () => {
const { root, getState } = renderHook(onSetAttributeLive);
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
act(() => {
vi.advanceTimersByTime(400);
@@ -272,13 +272,13 @@ describe("useColorGradingController", () => {
// B commits on the SAME element before A's persist has settled.
act(() => {
getState().commitColorGrading(naturalLiftGrading());
getState().commitColorGrading(warmDaylightGrading());
});
act(() => {
vi.advanceTimersByTime(400);
});
expect(onSetAttributeLive).toHaveBeenCalledTimes(2); // B's persist has already settled (mock resolves sync)
expect(getState().grading.preset).toBe("natural-lift");
expect(getState().grading.preset).toBe("warm-daylight");
// NOW A's stale persist finally settles as a FAILURE — must not revert
// `grading` (which now correctly shows B's newer edit) back to the
@@ -292,7 +292,7 @@ describe("useColorGradingController", () => {
await Promise.resolve();
await Promise.resolve();
});
expect(getState().grading.preset).toBe("natural-lift");
expect(getState().grading.preset).toBe("warm-daylight");
act(() => root.unmount());
vi.useRealTimers();
});
@@ -300,7 +300,7 @@ describe("useColorGradingController", () => {
it("resetGrading returns to the neutral preset", () => {
const { root, getState } = renderHook(vi.fn());
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
act(() => {
getState().resetGrading();
@@ -315,9 +315,9 @@ describe("useColorGradingController", () => {
makeElement({ id: "s1-bg" }),
);
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
expect(getState().grading.preset).toBe("fresh-pop");
expect(getState().grading.preset).toBe("bright-pop");
// A different element, with no persisted grading of its own — without a
// reset, this hook (unlike the legacy component it was extracted from,
// which remounts via a `key={selectionIdentityKey}`) would keep showing
@@ -337,9 +337,9 @@ describe("useColorGradingController", () => {
makeElement({ id: "bg", sourceFile: "index.html" }),
);
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
expect(getState().grading.preset).toBe("fresh-pop");
expect(getState().grading.preset).toBe("bright-pop");
rerenderWithElement(makeElement({ id: "bg", sourceFile: "sub-comp.html" }));
expect(getState().grading.preset).toBe("neutral");
act(() => root.unmount());
@@ -353,7 +353,7 @@ describe("useColorGradingController", () => {
makeElement({ id: "s1-bg" }),
);
act(() => {
getState().commitColorGrading(freshPopGrading());
getState().commitColorGrading(brightPopGrading());
});
// Switch selection before the 350ms debounce fires — the in-flight edit
// must be written immediately (targeting the OUTGOING element's own
@@ -366,7 +366,7 @@ describe("useColorGradingController", () => {
expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string];
expect(attr).toBe("color-grading");
expect(value).toContain("fresh-pop");
expect(value).toContain("bright-pop");
// And it must not ALSO fire again once the (now-cleared) original timer
// window would have elapsed.
act(() => {