mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(core): define media treatment capabilities
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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="{"preset":"natural-lift"}"`,
|
||||
`video id="v" data-color-grading="{"preset":"warm-daylight"}"`,
|
||||
);
|
||||
expect(html).toContain(
|
||||
`img id="i" data-color-grading="{"preset":"natural-lift"}"`,
|
||||
`img id="i" data-color-grading="{"preset":"warm-daylight"}"`,
|
||||
);
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user