Skip to content

Commit f99fc4e

Browse files
committed
feat(core): add professional color grading controls
1 parent 07f9a3d commit f99fc4e

9 files changed

Lines changed: 1968 additions & 52 deletions

packages/core/src/colorGrading.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
HF_COLOR_GRADING_PALETTES,
99
HF_COLOR_GRADING_PRESETS,
1010
getHfColorGradingCapabilities,
11+
hasHfColorGradingAuthoredValues,
1112
isHfColorGradingActive,
1213
normalizeHfColorGrading,
1314
normalizeHfColorGradingWithVariables,
@@ -123,6 +124,7 @@ describe("color grading", () => {
123124
it("publishes a complete capability catalog for agent-built treatments", () => {
124125
const capabilities = getHfColorGradingCapabilities();
125126

127+
expect(capabilities.version).toBe(2);
126128
expect(capabilities.colorSpace).toBe("rec709");
127129
expect(capabilities.adjustments.map(({ key }) => key)).toEqual([
128130
"exposure",
@@ -158,6 +160,23 @@ describe("color grading", () => {
158160
expect(capabilities.effects.find(({ key }) => key === "kuwahara")?.renderLane).toBe(
159161
"multipass",
160162
);
163+
expect(capabilities.wheels.zones).toEqual(["shadows", "midtones", "highlights"]);
164+
expect(capabilities.curves).toMatchObject({
165+
channels: ["master", "red", "green", "blue"],
166+
minPoints: 2,
167+
maxPoints: 16,
168+
implicitEndpoints: true,
169+
});
170+
expect(capabilities.hueCurves.channels).toEqual(
171+
expect.arrayContaining([
172+
expect.objectContaining({
173+
key: "hueVsHue",
174+
input: expect.objectContaining({ unit: "degrees", wrap: true }),
175+
output: { min: -180, max: 180 },
176+
}),
177+
]),
178+
);
179+
expect(capabilities.secondaries.max).toBe(4);
161180
});
162181

163182
it("merges manual adjustments over preset values", () => {
@@ -617,4 +636,181 @@ describe("color grading", () => {
617636
expect(grading?.adjust.contrast).toBe(0.2);
618637
expect(grading?.lut).toEqual({ src: "assets/luts/natural-boost.cube", intensity: 0.75 });
619638
});
639+
640+
it("resolves scalar variable references inside secondary arrays", () => {
641+
const grading = normalizeHfColorGradingWithVariables(
642+
{
643+
secondaries: [
644+
{
645+
key: {
646+
hue: { center: "$skinHue", range: 18 },
647+
saturation: { min: 0.1, max: 1 },
648+
},
649+
correction: { saturation: "${skinSaturation}" },
650+
},
651+
],
652+
},
653+
{ skinHue: 24, skinSaturation: -0.12 },
654+
);
655+
656+
expect(grading?.secondaries[0]).toMatchObject({
657+
key: { hue: { center: 24, range: 18 } },
658+
correction: { saturation: -0.12 },
659+
});
660+
});
661+
662+
it("normalizes advanced controls defensively", () => {
663+
const grading = normalizeHfColorGrading({
664+
colorSpace: "display-p3",
665+
wheels: {
666+
shadows: { hue: -30, amount: 2, level: -2 },
667+
highlights: { hue: 750, amount: 0.15, level: 0.05 },
668+
},
669+
curves: {
670+
master: [
671+
[0.75, 0.82],
672+
[0.25, 0.18],
673+
],
674+
},
675+
hueCurves: {
676+
hueVsHue: [
677+
[330, -20],
678+
[-10, 15],
679+
[120, 0],
680+
],
681+
},
682+
secondaries: [
683+
{
684+
enabled: false,
685+
key: {
686+
hue: { center: -10, range: 30, softness: 200 },
687+
saturation: { min: 0.8, max: 0.2, softness: 1 },
688+
},
689+
correction: { hueShift: 250, saturation: 2, luma: -2 },
690+
},
691+
],
692+
});
693+
694+
expect(grading?.colorSpace).toBe("display-p3");
695+
expect(grading?.wheels.shadows).toEqual({ hue: 330, amount: 1, level: -1 });
696+
expect(grading?.wheels.highlights).toEqual({ hue: 30, amount: 0.15, level: 0.05 });
697+
expect(grading?.curves.master).toEqual([
698+
[0, 0],
699+
[0.25, 0.18],
700+
[0.75, 0.82],
701+
[1, 1],
702+
]);
703+
expect(grading?.hueCurves.hueVsHue).toEqual([
704+
[120, 0],
705+
[330, -20],
706+
[350, 15],
707+
]);
708+
expect(grading?.secondaries[0]).toMatchObject({
709+
enabled: false,
710+
key: {
711+
hue: { center: 350, range: 30, softness: 150 },
712+
saturation: { min: 0.2, max: 0.8, softness: 0.5 },
713+
},
714+
correction: { hueShift: 180, saturation: 1, luma: -1 },
715+
});
716+
});
717+
718+
it("keeps disabled secondaries authored but pixel-inactive", () => {
719+
const grading = normalizeHfColorGrading({
720+
secondaries: [
721+
{
722+
enabled: false,
723+
key: { hue: { center: 24, range: 18 } },
724+
correction: { saturation: 0.5 },
725+
},
726+
],
727+
});
728+
729+
expect(grading?.secondaries[0]?.enabled).toBe(false);
730+
expect(isHfColorGradingActive(grading)).toBe(false);
731+
expect(hasHfColorGradingAuthoredValues(grading)).toBe(true);
732+
expect(serializeHfColorGrading(grading)).toContain('"enabled":false');
733+
});
734+
735+
it("omits identity advanced controls while retaining authored secondary selectors", () => {
736+
const grading = normalizeHfColorGrading({
737+
wheels: { shadows: { hue: 200 } },
738+
curves: {
739+
master: [
740+
[0, 0],
741+
[0.5, 0.5],
742+
[1, 1],
743+
],
744+
},
745+
hueCurves: {
746+
hueVsSaturation: [
747+
[0, 0],
748+
[120, 0],
749+
[240, 0],
750+
],
751+
},
752+
secondaries: [
753+
{
754+
key: { hue: { center: 20, range: 10, softness: 5 } },
755+
correction: {},
756+
},
757+
],
758+
});
759+
760+
expect(isHfColorGradingActive(grading)).toBe(false);
761+
expect(hasHfColorGradingAuthoredValues(grading)).toBe(true);
762+
const serialized = serializeHfColorGrading(grading);
763+
expect(serialized).not.toContain('"wheels"');
764+
expect(serialized).not.toContain('"curves"');
765+
expect(serialized).not.toContain('"hueCurves"');
766+
expect(serialized).toContain('"secondaries"');
767+
});
768+
769+
it("round-trips advanced grading byte-identically", () => {
770+
const grading = normalizeHfColorGrading({
771+
wheels: { shadows: { hue: 205, amount: 0.08 } },
772+
curves: {
773+
red: [
774+
[0, 0],
775+
[0.5, 0.55],
776+
[1, 1],
777+
],
778+
},
779+
hueCurves: {
780+
hueVsSaturation: [
781+
[180, 0],
782+
[215, 0.15],
783+
[250, 0],
784+
],
785+
},
786+
secondaries: [
787+
{
788+
key: { hue: { center: 215, range: 25, softness: 10 } },
789+
correction: { saturation: 0.15 },
790+
},
791+
],
792+
});
793+
const first = serializeHfColorGrading(grading);
794+
expect(serializeHfColorGrading(normalizeHfColorGrading(first))).toBe(first);
795+
});
796+
797+
it("gates advanced grading with intensity while keeping effects independent", () => {
798+
expect(
799+
isHfColorGradingActive(
800+
normalizeHfColorGrading({
801+
intensity: 0,
802+
wheels: { shadows: { hue: 205, amount: 0.2 } },
803+
}),
804+
),
805+
).toBe(false);
806+
expect(
807+
isHfColorGradingActive(
808+
normalizeHfColorGrading({
809+
intensity: 0,
810+
wheels: { shadows: { hue: 205, amount: 0.2 } },
811+
effects: { blur: 0.2 },
812+
}),
813+
),
814+
).toBe(true);
815+
});
620816
});

0 commit comments

Comments
 (0)