Skip to content

Commit 5bfda4e

Browse files
authored
Merge pull request #2797 from heygen-com/feat/studio-professional-color-grading
feat(studio): add professional grading controls
2 parents e2e61b0 + 794930a commit 5bfda4e

24 files changed

Lines changed: 3745 additions & 354 deletions

packages/studio/src/components/editor/PropertyPanelFlat.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ export function PropertyPanelFlat({
427427
mediaMetadata={colorGradingController.mediaMetadata}
428428
presetPreviews={colorGradingController.presetPreviews}
429429
onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
430+
captureGradedFrame={colorGradingController.captureGradedFrame}
430431
/>
431432
),
432433
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
normalizeHfColorGrading,
4+
type HfColorGradingSecondary,
5+
} from "@hyperframes/core/color-grading";
6+
import {
7+
analyzeColorGradingFrame,
8+
buildColorGradingSecondaryMatte,
9+
colorGradingSecondaryMask,
10+
sampleColorGradingSecondary,
11+
} from "./colorGradingFrameAnalysis";
12+
13+
function pixels(...rgba: number[]): Uint8ClampedArray {
14+
return new Uint8ClampedArray(rgba);
15+
}
16+
17+
function secondaryKey(key: HfColorGradingSecondary["key"]) {
18+
const secondary = normalizeHfColorGrading({
19+
secondaries: [{ key, correction: {} }],
20+
})?.secondaries?.[0];
21+
if (!secondary) throw new Error("Expected a normalized secondary");
22+
return secondary.key;
23+
}
24+
25+
describe("colorGradingFrameAnalysis", () => {
26+
it("places black and white at the Rec.709 scope endpoints", () => {
27+
const result = analyzeColorGradingFrame(pixels(0, 0, 0, 255, 255, 255, 255, 255), 2, 1);
28+
29+
expect(result.histogram[0]).toBe(255);
30+
expect(result.histogram[255]).toBe(255);
31+
expect(result.waveform[255]).toBe(255);
32+
expect(result.waveform[256]).toBe(255);
33+
expect(result.parade.reduce((sum, value) => sum + value, 0)).toBe(6 * 255);
34+
});
35+
36+
it("alpha-weights all scopes and excludes transparent pixels", () => {
37+
const result = analyzeColorGradingFrame(pixels(0, 0, 0, 0, 255, 255, 255, 64), 2, 1);
38+
39+
expect(result.histogram.reduce((sum, value) => sum + value, 0)).toBe(64);
40+
expect(result.waveform.reduce((sum, value) => sum + value, 0)).toBe(64);
41+
expect(result.parade.reduce((sum, value) => sum + value, 0)).toBe(3 * 64);
42+
expect(result.vectorscope.reduce((sum, value) => sum + value, 0)).toBe(64);
43+
});
44+
45+
it("averages hue circularly when an eyedropper sample crosses red", () => {
46+
const sampled = sampleColorGradingSecondary(
47+
pixels(255, 0, 10, 255, 255, 10, 0, 255),
48+
2,
49+
1,
50+
0.5,
51+
0,
52+
1,
53+
);
54+
55+
expect(sampled).not.toBeNull();
56+
if (!sampled) throw new Error("Expected a sample");
57+
expect(Math.min(sampled.hue.center, 360 - sampled.hue.center)).toBeLessThan(3);
58+
expect(sampled.hue.range).toBe(18);
59+
});
60+
61+
it("does not invent a hue qualifier for a neutral sample", () => {
62+
const sampled = sampleColorGradingSecondary(pixels(128, 128, 128, 255), 1, 1, 0, 0);
63+
64+
expect(sampled?.hue).toEqual({ center: 0, range: 180, softness: 0 });
65+
expect(sampled?.saturation.max).toBeCloseTo(0.2);
66+
});
67+
68+
it("matches the runtime softness on saturation and luma feather edges", () => {
69+
const saturationKey = secondaryKey({
70+
hue: { center: 0, range: 180, softness: 0 },
71+
saturation: { min: 0.5, max: 1, softness: 0.4 },
72+
luma: { min: 0, max: 1, softness: 0 },
73+
});
74+
const lumaKey = secondaryKey({
75+
hue: { center: 0, range: 180, softness: 0 },
76+
saturation: { min: 0, max: 1, softness: 0 },
77+
luma: { min: 0.5, max: 1, softness: 0.4 },
78+
});
79+
80+
expect(colorGradingSecondaryMask(255, 204, 204, saturationKey)).toBeCloseTo(0.15625, 5);
81+
expect(colorGradingSecondaryMask(51, 51, 51, lumaKey)).toBeCloseTo(0.15625, 5);
82+
});
83+
84+
it("builds an alpha-preserving black-and-white selection matte", () => {
85+
const key = secondaryKey({
86+
hue: { center: 0, range: 15, softness: 0 },
87+
saturation: { min: 0.5, max: 1, softness: 0 },
88+
luma: { min: 0, max: 1, softness: 0 },
89+
});
90+
const matte = buildColorGradingSecondaryMatte(
91+
pixels(255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 0, 0),
92+
3,
93+
1,
94+
key,
95+
);
96+
97+
expect([...matte.slice(0, 4)]).toEqual([255, 255, 255, 255]);
98+
expect([...matte.slice(4, 8)]).toEqual([0, 0, 0, 128]);
99+
expect([...matte.slice(8, 12)]).toEqual([0, 0, 0, 0]);
100+
});
101+
});
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import {
2+
calculateHfColorGradingSecondaryMask,
3+
type NormalizedHfColorGradingSecondary,
4+
} from "@hyperframes/core/color-grading";
5+
import { clampNumber } from "../../utils/studioHelpers";
6+
7+
const BYTE_MAX = 255;
8+
const SCOPE_SIZE = 256;
9+
10+
export type ColorGradingScopeMode = "histogram" | "waveform" | "parade" | "vectorscope";
11+
12+
export interface ColorGradingScopeAnalysis {
13+
width: number;
14+
histogram: Uint32Array;
15+
waveform: Uint32Array;
16+
parade: Uint32Array;
17+
vectorscope: Uint32Array;
18+
}
19+
20+
export async function readColorGradingFramePixels(frame: {
21+
dataUrl: string;
22+
width: number;
23+
height: number;
24+
}): Promise<Uint8ClampedArray | null> {
25+
const image = new Image();
26+
image.src = frame.dataUrl;
27+
await image.decode();
28+
const canvas = document.createElement("canvas");
29+
canvas.width = frame.width;
30+
canvas.height = frame.height;
31+
const context = canvas.getContext("2d", { willReadFrequently: true });
32+
if (!context) return null;
33+
context.drawImage(image, 0, 0, frame.width, frame.height);
34+
return context.getImageData(0, 0, frame.width, frame.height).data;
35+
}
36+
37+
function rec709Luma(red: number, green: number, blue: number): number {
38+
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
39+
}
40+
41+
function rgbToHsv(red: number, green: number, blue: number) {
42+
const max = Math.max(red, green, blue);
43+
const min = Math.min(red, green, blue);
44+
const chroma = max - min;
45+
let hue = 0;
46+
if (chroma > 0) {
47+
if (max === red) hue = ((green - blue) / chroma) % 6;
48+
else if (max === green) hue = (blue - red) / chroma + 2;
49+
else hue = (red - green) / chroma + 4;
50+
hue = (hue * 60 + 360) % 360;
51+
}
52+
return {
53+
hue,
54+
saturation: max === 0 ? 0 : chroma / max,
55+
value: max,
56+
};
57+
}
58+
59+
export function colorGradingSecondaryMask(
60+
red: number,
61+
green: number,
62+
blue: number,
63+
key: NormalizedHfColorGradingSecondary["key"],
64+
): number {
65+
const hsv = rgbToHsv(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX);
66+
return calculateHfColorGradingSecondaryMask(
67+
hsv.hue,
68+
hsv.saturation,
69+
rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX),
70+
key,
71+
);
72+
}
73+
74+
export function buildColorGradingSecondaryMatte(
75+
pixels: Uint8ClampedArray,
76+
width: number,
77+
height: number,
78+
key: NormalizedHfColorGradingSecondary["key"],
79+
): Uint8ClampedArray {
80+
const matte = new Uint8ClampedArray(width * height * 4);
81+
for (let offset = 0; offset < pixels.length; offset += 4) {
82+
const alpha = pixels[offset + 3] ?? 0;
83+
const mask =
84+
alpha === 0
85+
? 0
86+
: colorGradingSecondaryMask(
87+
pixels[offset] ?? 0,
88+
pixels[offset + 1] ?? 0,
89+
pixels[offset + 2] ?? 0,
90+
key,
91+
);
92+
const value = Math.round(mask * BYTE_MAX);
93+
matte[offset] = value;
94+
matte[offset + 1] = value;
95+
matte[offset + 2] = value;
96+
matte[offset + 3] = alpha;
97+
}
98+
return matte;
99+
}
100+
101+
function pixelOffset(width: number, x: number, y: number): number {
102+
return (y * width + x) * 4;
103+
}
104+
105+
function byteIndex(value: number): number {
106+
return Math.round(clampNumber(value, 0, 1) * BYTE_MAX);
107+
}
108+
109+
export function analyzeColorGradingFrame(
110+
pixels: Uint8ClampedArray,
111+
width: number,
112+
height: number,
113+
): ColorGradingScopeAnalysis {
114+
const histogram = new Uint32Array(SCOPE_SIZE);
115+
const waveform = new Uint32Array(width * SCOPE_SIZE);
116+
const parade = new Uint32Array(width * SCOPE_SIZE * 3);
117+
const vectorscope = new Uint32Array(SCOPE_SIZE * SCOPE_SIZE);
118+
119+
for (let y = 0; y < height; y += 1) {
120+
for (let x = 0; x < width; x += 1) {
121+
const offset = pixelOffset(width, x, y);
122+
const alpha = pixels[offset + 3] ?? 0;
123+
if (alpha === 0) continue;
124+
const red = (pixels[offset] ?? 0) / BYTE_MAX;
125+
const green = (pixels[offset + 1] ?? 0) / BYTE_MAX;
126+
const blue = (pixels[offset + 2] ?? 0) / BYTE_MAX;
127+
const normalizedLuma = rec709Luma(red, green, blue);
128+
const luma = byteIndex(normalizedLuma);
129+
histogram[luma] += alpha;
130+
waveform[x * SCOPE_SIZE + (BYTE_MAX - luma)] += alpha;
131+
parade[x * SCOPE_SIZE + (BYTE_MAX - byteIndex(red))] += alpha;
132+
parade[width * SCOPE_SIZE + x * SCOPE_SIZE + (BYTE_MAX - byteIndex(green))] += alpha;
133+
parade[width * SCOPE_SIZE * 2 + x * SCOPE_SIZE + (BYTE_MAX - byteIndex(blue))] += alpha;
134+
135+
const chromaBlue = (blue - normalizedLuma) / 1.8556;
136+
const chromaRed = (red - normalizedLuma) / 1.5748;
137+
const vectorX = byteIndex(chromaBlue + 0.5);
138+
const vectorY = BYTE_MAX - byteIndex(chromaRed + 0.5);
139+
vectorscope[vectorY * SCOPE_SIZE + vectorX] += alpha;
140+
}
141+
}
142+
143+
return { width, histogram, waveform, parade, vectorscope };
144+
}
145+
146+
export function sampleColorGradingSecondary(
147+
pixels: Uint8ClampedArray,
148+
width: number,
149+
height: number,
150+
x: number,
151+
y: number,
152+
radius = 2,
153+
): NormalizedHfColorGradingSecondary["key"] | null {
154+
const minX = clampNumber(Math.floor(x) - radius, 0, width - 1);
155+
const maxX = clampNumber(Math.floor(x) + radius, 0, width - 1);
156+
const minY = clampNumber(Math.floor(y) - radius, 0, height - 1);
157+
const maxY = clampNumber(Math.floor(y) + radius, 0, height - 1);
158+
let hueX = 0;
159+
let hueY = 0;
160+
let saturation = 0;
161+
let luma = 0;
162+
let weight = 0;
163+
164+
for (let sampleY = minY; sampleY <= maxY; sampleY += 1) {
165+
for (let sampleX = minX; sampleX <= maxX; sampleX += 1) {
166+
const offset = pixelOffset(width, sampleX, sampleY);
167+
const alpha = (pixels[offset + 3] ?? 0) / BYTE_MAX;
168+
if (alpha === 0) continue;
169+
const red = (pixels[offset] ?? 0) / BYTE_MAX;
170+
const green = (pixels[offset + 1] ?? 0) / BYTE_MAX;
171+
const blue = (pixels[offset + 2] ?? 0) / BYTE_MAX;
172+
const hsv = rgbToHsv(red, green, blue);
173+
const hueRadians = (hsv.hue * Math.PI) / 180;
174+
const hueWeight = hsv.saturation * alpha;
175+
hueX += Math.cos(hueRadians) * hueWeight;
176+
hueY += Math.sin(hueRadians) * hueWeight;
177+
saturation += hsv.saturation * alpha;
178+
luma += rec709Luma(red, green, blue) * alpha;
179+
weight += alpha;
180+
}
181+
}
182+
183+
if (weight === 0) return null;
184+
const averageHue = ((Math.atan2(hueY, hueX) * 180) / Math.PI + 360) % 360;
185+
const averageSaturation = saturation / weight;
186+
const averageLuma = luma / weight;
187+
const neutralSample = averageSaturation < 0.02;
188+
return {
189+
hue: neutralSample
190+
? { center: 0, range: 180, softness: 0 }
191+
: { center: averageHue, range: 18, softness: 12 },
192+
saturation: {
193+
min: clampNumber(averageSaturation - 0.2, 0, 1),
194+
max: clampNumber(averageSaturation + 0.2, 0, 1),
195+
softness: 0.1,
196+
},
197+
luma: {
198+
min: clampNumber(averageLuma - 0.2, 0, 1),
199+
max: clampNumber(averageLuma + 0.2, 0, 1),
200+
softness: 0.1,
201+
},
202+
};
203+
}

0 commit comments

Comments
 (0)