Skip to content

Commit 7fa85fb

Browse files
authored
Merge pull request #2796 from heygen-com/feat/professional-color-grading-core
feat(core): add professional color grading controls
2 parents 5c5179b + e446de6 commit 7fa85fb

10 files changed

Lines changed: 2201 additions & 79 deletions

packages/core/src/colorGrading.test.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from "vitest";
2+
import { COLOR_GRADING_ADVANCED_LIMITS } from "@hyperframes/parsers/color-grading-contract";
3+
import { unitFloatToByte } from "./colorLuts";
24
import {
5+
calculateHfColorGradingSecondaryMask,
36
HF_COLOR_GRADING_COLOR_SPACE,
47
HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
58
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
@@ -8,6 +11,7 @@ import {
811
HF_COLOR_GRADING_PALETTES,
912
HF_COLOR_GRADING_PRESETS,
1013
getHfColorGradingCapabilities,
14+
hasHfColorGradingAuthoredValues,
1115
isHfColorGradingActive,
1216
normalizeHfColorGrading,
1317
normalizeHfColorGradingWithVariables,
@@ -123,6 +127,7 @@ describe("color grading", () => {
123127
it("publishes a complete capability catalog for agent-built treatments", () => {
124128
const capabilities = getHfColorGradingCapabilities();
125129

130+
expect(capabilities.version).toBe(2);
126131
expect(capabilities.colorSpace).toBe("rec709");
127132
expect(capabilities.adjustments.map(({ key }) => key)).toEqual([
128133
"exposure",
@@ -158,6 +163,72 @@ describe("color grading", () => {
158163
expect(capabilities.effects.find(({ key }) => key === "kuwahara")?.renderLane).toBe(
159164
"multipass",
160165
);
166+
expect(capabilities.wheels.zones).toEqual(["shadows", "midtones", "highlights"]);
167+
expect(capabilities.curves).toMatchObject({
168+
channels: ["master", "red", "green", "blue"],
169+
minPoints: 2,
170+
maxPoints: 16,
171+
implicitEndpoints: true,
172+
});
173+
expect(capabilities.hueCurves.channels).toEqual(
174+
expect.arrayContaining([
175+
expect.objectContaining({
176+
key: "hueVsHue",
177+
input: expect.objectContaining({ unit: "degrees", wrap: true }),
178+
output: { min: -180, max: 180 },
179+
}),
180+
]),
181+
);
182+
expect(capabilities.secondaries.max).toBe(4);
183+
expect(capabilities.secondaries).toMatchObject({
184+
hue: {
185+
center: {
186+
min: COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.min,
187+
maxExclusive: COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.max,
188+
},
189+
range: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueRange,
190+
softness: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueSoftness,
191+
rangePlusSoftnessMax: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueCombinedMax,
192+
},
193+
saturation: {
194+
softness: COLOR_GRADING_ADVANCED_LIMITS.secondarySoftRangeSoftness,
195+
},
196+
correction: {
197+
hueShift: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueShift,
198+
saturation: COLOR_GRADING_ADVANCED_LIMITS.signedUnit,
199+
},
200+
});
201+
});
202+
203+
it("matches the shader's packed secondary-softness convention", () => {
204+
for (const axis of ["saturation", "luma"] as const) {
205+
const grading = normalizeHfColorGrading({
206+
secondaries: [
207+
{
208+
key: {
209+
hue: { center: 0, range: 180, softness: 0 },
210+
saturation: { min: axis === "saturation" ? 0.5 : 0, max: 1, softness: 0.4 },
211+
luma: { min: axis === "luma" ? 0.5 : 0, max: 1, softness: 0.4 },
212+
},
213+
correction: {},
214+
},
215+
],
216+
});
217+
const key = grading?.secondaries[0]?.key;
218+
if (!key) throw new Error("Expected a normalized secondary key");
219+
220+
const packedSoftness = unitFloatToByte(key[axis].softness / 0.5);
221+
const shaderDecodedSoftness = (packedSoftness / 255) * 0.5;
222+
const decodedKey = {
223+
...key,
224+
[axis]: { ...key[axis], softness: shaderDecodedSoftness },
225+
};
226+
const productMask = calculateHfColorGradingSecondaryMask(0, 0.2, 0.2, key);
227+
const shaderMask = calculateHfColorGradingSecondaryMask(0, 0.2, 0.2, decodedKey);
228+
229+
expect(productMask, axis).toBeGreaterThan(0);
230+
expect(productMask, axis).toBeCloseTo(shaderMask, 2);
231+
}
161232
});
162233

163234
it("merges manual adjustments over preset values", () => {
@@ -617,4 +688,181 @@ describe("color grading", () => {
617688
expect(grading?.adjust.contrast).toBe(0.2);
618689
expect(grading?.lut).toEqual({ src: "assets/luts/natural-boost.cube", intensity: 0.75 });
619690
});
691+
692+
it("resolves scalar variable references inside secondary arrays", () => {
693+
const grading = normalizeHfColorGradingWithVariables(
694+
{
695+
secondaries: [
696+
{
697+
key: {
698+
hue: { center: "$skinHue", range: 18 },
699+
saturation: { min: 0.1, max: 1 },
700+
},
701+
correction: { saturation: "${skinSaturation}" },
702+
},
703+
],
704+
},
705+
{ skinHue: 24, skinSaturation: -0.12 },
706+
);
707+
708+
expect(grading?.secondaries[0]).toMatchObject({
709+
key: { hue: { center: 24, range: 18 } },
710+
correction: { saturation: -0.12 },
711+
});
712+
});
713+
714+
it("normalizes advanced controls defensively", () => {
715+
const grading = normalizeHfColorGrading({
716+
colorSpace: "display-p3",
717+
wheels: {
718+
shadows: { hue: -30, amount: 2, level: -2 },
719+
highlights: { hue: 750, amount: 0.15, level: 0.05 },
720+
},
721+
curves: {
722+
master: [
723+
[0.75, 0.82],
724+
[0.25, 0.18],
725+
],
726+
},
727+
hueCurves: {
728+
hueVsHue: [
729+
[330, -20],
730+
[-10, 15],
731+
[120, 0],
732+
],
733+
},
734+
secondaries: [
735+
{
736+
enabled: false,
737+
key: {
738+
hue: { center: -10, range: 30, softness: 200 },
739+
saturation: { min: 0.8, max: 0.2, softness: 1 },
740+
},
741+
correction: { hueShift: 250, saturation: 2, luma: -2 },
742+
},
743+
],
744+
});
745+
746+
expect(grading?.colorSpace).toBe("display-p3");
747+
expect(grading?.wheels.shadows).toEqual({ hue: 330, amount: 1, level: -1 });
748+
expect(grading?.wheels.highlights).toEqual({ hue: 30, amount: 0.15, level: 0.05 });
749+
expect(grading?.curves.master).toEqual([
750+
[0, 0],
751+
[0.25, 0.18],
752+
[0.75, 0.82],
753+
[1, 1],
754+
]);
755+
expect(grading?.hueCurves.hueVsHue).toEqual([
756+
[120, 0],
757+
[330, -20],
758+
[350, 15],
759+
]);
760+
expect(grading?.secondaries[0]).toMatchObject({
761+
enabled: false,
762+
key: {
763+
hue: { center: 350, range: 30, softness: 150 },
764+
saturation: { min: 0.2, max: 0.8, softness: 0.5 },
765+
},
766+
correction: { hueShift: 180, saturation: 1, luma: -1 },
767+
});
768+
});
769+
770+
it("keeps disabled secondaries authored but pixel-inactive", () => {
771+
const grading = normalizeHfColorGrading({
772+
secondaries: [
773+
{
774+
enabled: false,
775+
key: { hue: { center: 24, range: 18 } },
776+
correction: { saturation: 0.5 },
777+
},
778+
],
779+
});
780+
781+
expect(grading?.secondaries[0]?.enabled).toBe(false);
782+
expect(isHfColorGradingActive(grading)).toBe(false);
783+
expect(hasHfColorGradingAuthoredValues(grading)).toBe(true);
784+
expect(serializeHfColorGrading(grading)).toContain('"enabled":false');
785+
});
786+
787+
it("omits identity advanced controls while retaining authored secondary selectors", () => {
788+
const grading = normalizeHfColorGrading({
789+
wheels: { shadows: { hue: 200 } },
790+
curves: {
791+
master: [
792+
[0, 0],
793+
[0.5, 0.5],
794+
[1, 1],
795+
],
796+
},
797+
hueCurves: {
798+
hueVsSaturation: [
799+
[0, 0],
800+
[120, 0],
801+
[240, 0],
802+
],
803+
},
804+
secondaries: [
805+
{
806+
key: { hue: { center: 20, range: 10, softness: 5 } },
807+
correction: {},
808+
},
809+
],
810+
});
811+
812+
expect(isHfColorGradingActive(grading)).toBe(false);
813+
expect(hasHfColorGradingAuthoredValues(grading)).toBe(true);
814+
const serialized = serializeHfColorGrading(grading);
815+
expect(serialized).not.toContain('"wheels"');
816+
expect(serialized).not.toContain('"curves"');
817+
expect(serialized).not.toContain('"hueCurves"');
818+
expect(serialized).toContain('"secondaries"');
819+
});
820+
821+
it("round-trips advanced grading byte-identically", () => {
822+
const grading = normalizeHfColorGrading({
823+
wheels: { shadows: { hue: 205, amount: 0.08 } },
824+
curves: {
825+
red: [
826+
[0, 0],
827+
[0.5, 0.55],
828+
[1, 1],
829+
],
830+
},
831+
hueCurves: {
832+
hueVsSaturation: [
833+
[180, 0],
834+
[215, 0.15],
835+
[250, 0],
836+
],
837+
},
838+
secondaries: [
839+
{
840+
key: { hue: { center: 215, range: 25, softness: 10 } },
841+
correction: { saturation: 0.15 },
842+
},
843+
],
844+
});
845+
const first = serializeHfColorGrading(grading);
846+
expect(serializeHfColorGrading(normalizeHfColorGrading(first))).toBe(first);
847+
});
848+
849+
it("gates advanced grading with intensity while keeping effects independent", () => {
850+
expect(
851+
isHfColorGradingActive(
852+
normalizeHfColorGrading({
853+
intensity: 0,
854+
wheels: { shadows: { hue: 205, amount: 0.2 } },
855+
}),
856+
),
857+
).toBe(false);
858+
expect(
859+
isHfColorGradingActive(
860+
normalizeHfColorGrading({
861+
intensity: 0,
862+
wheels: { shadows: { hue: 205, amount: 0.2 } },
863+
effects: { blur: 0.2 },
864+
}),
865+
),
866+
).toBe(true);
867+
});
620868
});

0 commit comments

Comments
 (0)