feat(core): add professional color grading controls

This commit is contained in:
ukimsanov
2026-07-25 22:15:30 -07:00
parent 07f9a3de95
commit f99fc4e568
9 changed files with 1968 additions and 52 deletions
+196
View File
@@ -8,6 +8,7 @@ import {
HF_COLOR_GRADING_PALETTES, HF_COLOR_GRADING_PALETTES,
HF_COLOR_GRADING_PRESETS, HF_COLOR_GRADING_PRESETS,
getHfColorGradingCapabilities, getHfColorGradingCapabilities,
hasHfColorGradingAuthoredValues,
isHfColorGradingActive, isHfColorGradingActive,
normalizeHfColorGrading, normalizeHfColorGrading,
normalizeHfColorGradingWithVariables, normalizeHfColorGradingWithVariables,
@@ -123,6 +124,7 @@ describe("color grading", () => {
it("publishes a complete capability catalog for agent-built treatments", () => { it("publishes a complete capability catalog for agent-built treatments", () => {
const capabilities = getHfColorGradingCapabilities(); const capabilities = getHfColorGradingCapabilities();
expect(capabilities.version).toBe(2);
expect(capabilities.colorSpace).toBe("rec709"); expect(capabilities.colorSpace).toBe("rec709");
expect(capabilities.adjustments.map(({ key }) => key)).toEqual([ expect(capabilities.adjustments.map(({ key }) => key)).toEqual([
"exposure", "exposure",
@@ -158,6 +160,23 @@ describe("color grading", () => {
expect(capabilities.effects.find(({ key }) => key === "kuwahara")?.renderLane).toBe( expect(capabilities.effects.find(({ key }) => key === "kuwahara")?.renderLane).toBe(
"multipass", "multipass",
); );
expect(capabilities.wheels.zones).toEqual(["shadows", "midtones", "highlights"]);
expect(capabilities.curves).toMatchObject({
channels: ["master", "red", "green", "blue"],
minPoints: 2,
maxPoints: 16,
implicitEndpoints: true,
});
expect(capabilities.hueCurves.channels).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "hueVsHue",
input: expect.objectContaining({ unit: "degrees", wrap: true }),
output: { min: -180, max: 180 },
}),
]),
);
expect(capabilities.secondaries.max).toBe(4);
}); });
it("merges manual adjustments over preset values", () => { it("merges manual adjustments over preset values", () => {
@@ -617,4 +636,181 @@ describe("color grading", () => {
expect(grading?.adjust.contrast).toBe(0.2); expect(grading?.adjust.contrast).toBe(0.2);
expect(grading?.lut).toEqual({ src: "assets/luts/natural-boost.cube", intensity: 0.75 }); expect(grading?.lut).toEqual({ src: "assets/luts/natural-boost.cube", intensity: 0.75 });
}); });
it("resolves scalar variable references inside secondary arrays", () => {
const grading = normalizeHfColorGradingWithVariables(
{
secondaries: [
{
key: {
hue: { center: "$skinHue", range: 18 },
saturation: { min: 0.1, max: 1 },
},
correction: { saturation: "${skinSaturation}" },
},
],
},
{ skinHue: 24, skinSaturation: -0.12 },
);
expect(grading?.secondaries[0]).toMatchObject({
key: { hue: { center: 24, range: 18 } },
correction: { saturation: -0.12 },
});
});
it("normalizes advanced controls defensively", () => {
const grading = normalizeHfColorGrading({
colorSpace: "display-p3",
wheels: {
shadows: { hue: -30, amount: 2, level: -2 },
highlights: { hue: 750, amount: 0.15, level: 0.05 },
},
curves: {
master: [
[0.75, 0.82],
[0.25, 0.18],
],
},
hueCurves: {
hueVsHue: [
[330, -20],
[-10, 15],
[120, 0],
],
},
secondaries: [
{
enabled: false,
key: {
hue: { center: -10, range: 30, softness: 200 },
saturation: { min: 0.8, max: 0.2, softness: 1 },
},
correction: { hueShift: 250, saturation: 2, luma: -2 },
},
],
});
expect(grading?.colorSpace).toBe("display-p3");
expect(grading?.wheels.shadows).toEqual({ hue: 330, amount: 1, level: -1 });
expect(grading?.wheels.highlights).toEqual({ hue: 30, amount: 0.15, level: 0.05 });
expect(grading?.curves.master).toEqual([
[0, 0],
[0.25, 0.18],
[0.75, 0.82],
[1, 1],
]);
expect(grading?.hueCurves.hueVsHue).toEqual([
[120, 0],
[330, -20],
[350, 15],
]);
expect(grading?.secondaries[0]).toMatchObject({
enabled: false,
key: {
hue: { center: 350, range: 30, softness: 150 },
saturation: { min: 0.2, max: 0.8, softness: 0.5 },
},
correction: { hueShift: 180, saturation: 1, luma: -1 },
});
});
it("keeps disabled secondaries authored but pixel-inactive", () => {
const grading = normalizeHfColorGrading({
secondaries: [
{
enabled: false,
key: { hue: { center: 24, range: 18 } },
correction: { saturation: 0.5 },
},
],
});
expect(grading?.secondaries[0]?.enabled).toBe(false);
expect(isHfColorGradingActive(grading)).toBe(false);
expect(hasHfColorGradingAuthoredValues(grading)).toBe(true);
expect(serializeHfColorGrading(grading)).toContain('"enabled":false');
});
it("omits identity advanced controls while retaining authored secondary selectors", () => {
const grading = normalizeHfColorGrading({
wheels: { shadows: { hue: 200 } },
curves: {
master: [
[0, 0],
[0.5, 0.5],
[1, 1],
],
},
hueCurves: {
hueVsSaturation: [
[0, 0],
[120, 0],
[240, 0],
],
},
secondaries: [
{
key: { hue: { center: 20, range: 10, softness: 5 } },
correction: {},
},
],
});
expect(isHfColorGradingActive(grading)).toBe(false);
expect(hasHfColorGradingAuthoredValues(grading)).toBe(true);
const serialized = serializeHfColorGrading(grading);
expect(serialized).not.toContain('"wheels"');
expect(serialized).not.toContain('"curves"');
expect(serialized).not.toContain('"hueCurves"');
expect(serialized).toContain('"secondaries"');
});
it("round-trips advanced grading byte-identically", () => {
const grading = normalizeHfColorGrading({
wheels: { shadows: { hue: 205, amount: 0.08 } },
curves: {
red: [
[0, 0],
[0.5, 0.55],
[1, 1],
],
},
hueCurves: {
hueVsSaturation: [
[180, 0],
[215, 0.15],
[250, 0],
],
},
secondaries: [
{
key: { hue: { center: 215, range: 25, softness: 10 } },
correction: { saturation: 0.15 },
},
],
});
const first = serializeHfColorGrading(grading);
expect(serializeHfColorGrading(normalizeHfColorGrading(first))).toBe(first);
});
it("gates advanced grading with intensity while keeping effects independent", () => {
expect(
isHfColorGradingActive(
normalizeHfColorGrading({
intensity: 0,
wheels: { shadows: { hue: 205, amount: 0.2 } },
}),
),
).toBe(false);
expect(
isHfColorGradingActive(
normalizeHfColorGrading({
intensity: 0,
wheels: { shadows: { hue: 205, amount: 0.2 } },
effects: { blur: 0.2 },
}),
),
).toBe(true);
});
}); });
+449 -6
View File
@@ -1,13 +1,27 @@
import { DEFAULT_MAX_CUBE_LUT_SIZE } from "./colorLuts"; import { DEFAULT_MAX_CUBE_LUT_SIZE } from "./colorLuts";
import type { HfColorCurvePoint, HfHueCurvePoint } from "./colorGradingCurves";
import { import {
COLOR_GRADING_ADJUST_KEYS, COLOR_GRADING_ADJUST_KEYS,
COLOR_GRADING_COLOR_SPACE, COLOR_GRADING_COLOR_SPACE,
COLOR_GRADING_CONTRACT_VERSION,
COLOR_GRADING_CURVE_KEYS,
COLOR_GRADING_DETAIL_KEYS, COLOR_GRADING_DETAIL_KEYS,
COLOR_GRADING_EFFECT_KEYS, COLOR_GRADING_EFFECT_KEYS,
COLOR_GRADING_HUE_CURVE_KEYS,
COLOR_GRADING_LUT_KEYS, COLOR_GRADING_LUT_KEYS,
COLOR_GRADING_MAX_CURVE_POINTS,
COLOR_GRADING_MAX_SECONDARIES,
COLOR_GRADING_TOP_LEVEL_KEYS, COLOR_GRADING_TOP_LEVEL_KEYS,
COLOR_GRADING_WHEEL_KEYS,
} from "@hyperframes/parsers/color-grading-contract"; } from "@hyperframes/parsers/color-grading-contract";
export type { HfColorCurvePoint, HfHueCurvePoint } from "./colorGradingCurves";
export {
compileHfColorCurve,
compileHfHueCurve,
HF_COLOR_CURVE_MAX_POINTS,
} from "./colorGradingCurves";
export const HF_COLOR_GRADING_ATTR = "data-color-grading"; export const HF_COLOR_GRADING_ATTR = "data-color-grading";
// Runtime <-> studio contract attributes. The runtime grading engine writes // Runtime <-> studio contract attributes. The runtime grading engine writes
@@ -63,6 +77,80 @@ const ADJUST_ZERO = {
export type HfColorGradingAdjust = Partial<Record<HfColorGradingAdjustKey, number>>; export type HfColorGradingAdjust = Partial<Record<HfColorGradingAdjustKey, number>>;
export type HfColorGradingWheelKey = (typeof COLOR_GRADING_WHEEL_KEYS)[number];
export interface HfColorGradingTonalWheel {
hue?: number;
amount?: number;
level?: number;
}
export type HfColorGradingWheels = Partial<
Record<HfColorGradingWheelKey, HfColorGradingTonalWheel>
>;
export type NormalizedHfColorGradingWheels = Record<
HfColorGradingWheelKey,
Required<HfColorGradingTonalWheel>
>;
export type HfColorGradingCurveKey = (typeof COLOR_GRADING_CURVE_KEYS)[number];
export type HfColorGradingHueCurveKey = (typeof COLOR_GRADING_HUE_CURVE_KEYS)[number];
export type HfColorGradingCurves = Partial<
Record<HfColorGradingCurveKey, readonly HfColorCurvePoint[]>
>;
export type HfColorGradingHueCurves = Partial<
Record<HfColorGradingHueCurveKey, readonly HfHueCurvePoint[]>
>;
export type NormalizedHfColorGradingCurves = Record<
HfColorGradingCurveKey,
readonly HfColorCurvePoint[]
>;
export type NormalizedHfColorGradingHueCurves = Record<
HfColorGradingHueCurveKey,
readonly HfHueCurvePoint[]
>;
export interface HfColorGradingHueRange {
center?: number;
range?: number;
softness?: number;
}
export interface HfColorGradingSoftRange {
min?: number;
max?: number;
softness?: number;
}
export interface HfColorGradingSecondaryCorrection {
hueShift?: number;
saturation?: number;
luma?: number;
temperature?: number;
tint?: number;
}
export interface HfColorGradingSecondary {
enabled?: boolean;
key: {
hue?: HfColorGradingHueRange;
saturation?: HfColorGradingSoftRange;
luma?: HfColorGradingSoftRange;
};
correction: HfColorGradingSecondaryCorrection;
}
export interface NormalizedHfColorGradingSecondary {
enabled: boolean;
key: {
hue: Required<HfColorGradingHueRange>;
saturation: Required<HfColorGradingSoftRange>;
luma: Required<HfColorGradingSoftRange>;
};
correction: Required<HfColorGradingSecondaryCorrection>;
}
// Sub-controls use useful identity defaults rather than raw mathematical zeroes. // Sub-controls use useful identity defaults rather than raw mathematical zeroes.
export type HfColorGradingDetailKey = (typeof COLOR_GRADING_DETAIL_KEYS)[number]; export type HfColorGradingDetailKey = (typeof COLOR_GRADING_DETAIL_KEYS)[number];
@@ -159,6 +247,10 @@ export interface HfColorGrading {
preset?: HfColorGradingPresetId | string | null; preset?: HfColorGradingPresetId | string | null;
intensity?: number; intensity?: number;
adjust?: HfColorGradingAdjust; adjust?: HfColorGradingAdjust;
wheels?: HfColorGradingWheels;
curves?: HfColorGradingCurves;
hueCurves?: HfColorGradingHueCurves;
secondaries?: readonly HfColorGradingSecondary[];
details?: HfColorGradingDetails; details?: HfColorGradingDetails;
effects?: HfColorGradingEffects; effects?: HfColorGradingEffects;
palette?: readonly string[] | null; palette?: readonly string[] | null;
@@ -177,6 +269,10 @@ export interface NormalizedHfColorGrading {
preset: HfColorGradingPresetId | string | null; preset: HfColorGradingPresetId | string | null;
intensity: number; intensity: number;
adjust: Record<HfColorGradingAdjustKey, number>; adjust: Record<HfColorGradingAdjustKey, number>;
wheels?: NormalizedHfColorGradingWheels;
curves?: NormalizedHfColorGradingCurves;
hueCurves?: NormalizedHfColorGradingHueCurves;
secondaries?: readonly NormalizedHfColorGradingSecondary[];
details: Record<HfColorGradingDetailKey, number>; details: Record<HfColorGradingDetailKey, number>;
effects: Record<HfColorGradingEffectKey, number>; effects: Record<HfColorGradingEffectKey, number>;
palette: readonly string[] | null; palette: readonly string[] | null;
@@ -184,6 +280,13 @@ export interface NormalizedHfColorGrading {
colorSpace: typeof HF_COLOR_GRADING_COLOR_SPACE | string; colorSpace: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
} }
export interface ResolvedHfColorGrading extends NormalizedHfColorGrading {
wheels: NormalizedHfColorGradingWheels;
curves: NormalizedHfColorGradingCurves;
hueCurves: NormalizedHfColorGradingHueCurves;
secondaries: readonly NormalizedHfColorGradingSecondary[];
}
export interface HfColorGradingTarget { export interface HfColorGradingTarget {
id?: string | null; id?: string | null;
hfId?: string | null; hfId?: string | null;
@@ -293,6 +396,15 @@ export type HfColorGradingVariableMap = Record<string, unknown>;
export const HF_COLOR_GRADING_ADJUST_KEYS = export const HF_COLOR_GRADING_ADJUST_KEYS =
COLOR_GRADING_ADJUST_KEYS satisfies readonly HfColorGradingAdjustKey[]; COLOR_GRADING_ADJUST_KEYS satisfies readonly HfColorGradingAdjustKey[];
export const HF_COLOR_GRADING_WHEEL_KEYS =
COLOR_GRADING_WHEEL_KEYS satisfies readonly HfColorGradingWheelKey[];
export const HF_COLOR_GRADING_CURVE_KEYS =
COLOR_GRADING_CURVE_KEYS satisfies readonly HfColorGradingCurveKey[];
export const HF_COLOR_GRADING_HUE_CURVE_KEYS =
COLOR_GRADING_HUE_CURVE_KEYS satisfies readonly HfColorGradingHueCurveKey[];
export const HF_COLOR_GRADING_DETAIL_KEYS = export const HF_COLOR_GRADING_DETAIL_KEYS =
COLOR_GRADING_DETAIL_KEYS satisfies readonly HfColorGradingDetailKey[]; COLOR_GRADING_DETAIL_KEYS satisfies readonly HfColorGradingDetailKey[];
@@ -532,6 +644,35 @@ const ADJUST_LIMITS: Record<HfColorGradingAdjustKey, { min: number; max: number
saturation: { min: -1, max: 1 }, saturation: { min: -1, max: 1 },
}; };
const TONAL_WHEEL_DEFAULT: Required<HfColorGradingTonalWheel> = {
hue: 0,
amount: 0,
level: 0,
};
const WHEEL_LIMITS = {
amount: { min: 0, max: 1 },
level: { min: -1, max: 1 },
};
const RGB_CURVE_IDENTITY: readonly HfColorCurvePoint[] = [
[0, 0],
[1, 1],
];
const HUE_CURVE_IDENTITY: readonly HfHueCurvePoint[] = [];
const HUE_CURVE_LIMITS: Record<HfColorGradingHueCurveKey, { min: number; max: number }> = {
hueVsHue: { min: -180, max: 180 },
hueVsSaturation: { min: -1, max: 1 },
hueVsLuma: { min: -1, max: 1 },
};
const SECONDARY_HUE_DEFAULT: Required<HfColorGradingHueRange> = {
center: 0,
range: 180,
softness: 0,
};
const SECONDARY_SOFT_RANGE_DEFAULT: Required<HfColorGradingSoftRange> = {
min: 0,
max: 1,
softness: 0.05,
};
const DETAIL_LIMITS: Record<HfColorGradingDetailKey, { min: number; max: number }> = { const DETAIL_LIMITS: Record<HfColorGradingDetailKey, { min: number; max: number }> = {
vignette: { min: 0, max: 1 }, vignette: { min: 0, max: 1 },
vignetteMidpoint: { min: 0, max: 1 }, vignetteMidpoint: { min: 0, max: 1 },
@@ -813,7 +954,7 @@ const MULTIPASS_EFFECT_KEYS = new Set<HfColorGradingActiveEffectKey>(["blur", "b
/** Agent-readable view of the canonical grading and effect contracts. */ /** Agent-readable view of the canonical grading and effect contracts. */
export function getHfColorGradingCapabilities() { export function getHfColorGradingCapabilities() {
return { return {
version: 1, version: COLOR_GRADING_CONTRACT_VERSION,
targetTags: ["img", "video"], targetTags: ["img", "video"],
colorSpace: HF_COLOR_GRADING_COLOR_SPACE, colorSpace: HF_COLOR_GRADING_COLOR_SPACE,
intensity: { identity: 1, min: 0, max: 1 }, intensity: { identity: 1, min: 0, max: 1 },
@@ -835,6 +976,60 @@ export function getHfColorGradingCapabilities() {
identity: ADJUST_ZERO[key], identity: ADJUST_ZERO[key],
...ADJUST_LIMITS[key], ...ADJUST_LIMITS[key],
})), })),
wheels: {
zones: HF_COLOR_GRADING_WHEEL_KEYS,
controls: {
hue: { identity: 0, unit: "degrees", wrap: true, min: 0, maxExclusive: 360 },
amount: { identity: 0, ...WHEEL_LIMITS.amount },
level: { identity: 0, ...WHEEL_LIMITS.level },
},
},
curves: {
channels: HF_COLOR_GRADING_CURVE_KEYS,
input: { min: 0, max: 1 },
output: { min: 0, max: 1 },
minPoints: 2,
maxPoints: COLOR_GRADING_MAX_CURVE_POINTS,
implicitEndpoints: true,
endpointsCountTowardMax: true,
},
hueCurves: {
channels: HF_COLOR_GRADING_HUE_CURVE_KEYS.map((key) => ({
key,
input: { min: 0, maxExclusive: 360, unit: "degrees", wrap: true },
output: HUE_CURVE_LIMITS[key],
})),
minPoints: 3,
maxPoints: COLOR_GRADING_MAX_CURVE_POINTS,
},
secondaries: {
max: COLOR_GRADING_MAX_SECONDARIES,
hue: {
center: { min: 0, maxExclusive: 360, unit: "degrees", wrap: true },
range: { min: 0, max: 180, unit: "degrees" },
softness: { min: 0, max: 180, unit: "degrees" },
rangePlusSoftnessMax: 180,
},
saturation: {
min: { ...UNIT_LIMIT },
max: { ...UNIT_LIMIT },
relation: "min < max",
softness: { min: 0, max: 0.5 },
},
luma: {
min: { ...UNIT_LIMIT },
max: { ...UNIT_LIMIT },
relation: "min < max",
softness: { min: 0, max: 0.5 },
},
correction: {
hueShift: { min: -180, max: 180, unit: "degrees" },
saturation: { min: -1, max: 1 },
luma: { min: -1, max: 1 },
temperature: { min: -1, max: 1 },
tint: { min: -1, max: 1 },
},
},
effectFamilies: EFFECT_FAMILIES, effectFamilies: EFFECT_FAMILIES,
finishing: HF_COLOR_GRADING_DETAIL_KEYS.map((key) => ({ finishing: HF_COLOR_GRADING_DETAIL_KEYS.map((key) => ({
key, key,
@@ -902,6 +1097,186 @@ function readLimitedValue(value: unknown, limit: { min: number; max: number }):
return clamp(parsed, limit.min, limit.max); return clamp(parsed, limit.min, limit.max);
} }
function wrapDegrees(value: unknown, fallback = 0): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) return fallback;
return ((parsed % 360) + 360) % 360;
}
function normalizeWheels(value: unknown): NormalizedHfColorGradingWheels {
const wheels = isRecord(value) ? value : {};
return HF_COLOR_GRADING_WHEEL_KEYS.reduce<NormalizedHfColorGradingWheels>(
(result, key) => {
const wheel = isRecord(wheels[key]) ? wheels[key] : {};
result[key] = {
hue: wrapDegrees(wheel.hue, TONAL_WHEEL_DEFAULT.hue),
amount: readLimitedValue(wheel.amount ?? TONAL_WHEEL_DEFAULT.amount, WHEEL_LIMITS.amount),
level: readLimitedValue(wheel.level ?? TONAL_WHEEL_DEFAULT.level, WHEEL_LIMITS.level),
};
return result;
},
{
shadows: { ...TONAL_WHEEL_DEFAULT },
midtones: { ...TONAL_WHEEL_DEFAULT },
highlights: { ...TONAL_WHEEL_DEFAULT },
},
);
}
function normalizeCurvePoint(value: unknown): HfColorCurvePoint | null {
if (!Array.isArray(value) || value.length !== 2) return null;
const input = Number(value[0]);
const output = Number(value[1]);
return Number.isFinite(input) && Number.isFinite(output)
? [clamp(input, 0, 1), clamp(output, 0, 1)]
: null;
}
function hasDuplicateCurveInputs(points: readonly HfColorCurvePoint[]): boolean {
return points.some((point, index) => index > 0 && point[0] === points[index - 1]?.[0]);
}
function normalizeCurve(value: unknown): readonly HfColorCurvePoint[] {
if (!Array.isArray(value) || value.length < 2) return RGB_CURVE_IDENTITY;
const points: HfColorCurvePoint[] = [];
for (const valuePoint of value) {
const point = normalizeCurvePoint(valuePoint);
if (!point) return RGB_CURVE_IDENTITY;
points.push(point);
}
points.sort((a, b) => a[0] - b[0]);
if (hasDuplicateCurveInputs(points)) return RGB_CURVE_IDENTITY;
if (points[0]![0] > 0) points.unshift([0, 0]);
if (points[points.length - 1]![0] < 1) points.push([1, 1]);
if (points.length > COLOR_GRADING_MAX_CURVE_POINTS) return RGB_CURVE_IDENTITY;
return points;
}
function normalizeCurves(value: unknown): NormalizedHfColorGradingCurves {
const curves = isRecord(value) ? value : {};
return HF_COLOR_GRADING_CURVE_KEYS.reduce<NormalizedHfColorGradingCurves>(
(result, key) => {
result[key] = normalizeCurve(curves[key]);
return result;
},
{
master: RGB_CURVE_IDENTITY,
red: RGB_CURVE_IDENTITY,
green: RGB_CURVE_IDENTITY,
blue: RGB_CURVE_IDENTITY,
},
);
}
function normalizeHueCurvePoint(
value: unknown,
limit: { min: number; max: number },
): HfHueCurvePoint | null {
if (!Array.isArray(value) || value.length !== 2) return null;
const hue = Number(value[0]);
const delta = Number(value[1]);
return Number.isFinite(hue) && Number.isFinite(delta)
? [wrapDegrees(hue), clamp(delta, limit.min, limit.max)]
: null;
}
function normalizeHueCurve(
value: unknown,
limit: { min: number; max: number },
): readonly HfHueCurvePoint[] {
if (!Array.isArray(value) || value.length < 3 || value.length > COLOR_GRADING_MAX_CURVE_POINTS) {
return HUE_CURVE_IDENTITY;
}
const points: HfHueCurvePoint[] = [];
for (const candidate of value) {
const point = normalizeHueCurvePoint(candidate, limit);
if (!point) return HUE_CURVE_IDENTITY;
points.push(point);
}
points.sort((a, b) => a[0] - b[0]);
return hasDuplicateCurveInputs(points) ? HUE_CURVE_IDENTITY : points;
}
function normalizeHueCurves(value: unknown): NormalizedHfColorGradingHueCurves {
const curves = isRecord(value) ? value : {};
return HF_COLOR_GRADING_HUE_CURVE_KEYS.reduce<NormalizedHfColorGradingHueCurves>(
(result, key) => {
result[key] = normalizeHueCurve(curves[key], HUE_CURVE_LIMITS[key]);
return result;
},
{
hueVsHue: HUE_CURVE_IDENTITY,
hueVsSaturation: HUE_CURVE_IDENTITY,
hueVsLuma: HUE_CURVE_IDENTITY,
},
);
}
function normalizeSoftRange(value: unknown): Required<HfColorGradingSoftRange> {
const range = isRecord(value) ? value : {};
const first = readLimitedValue(range.min ?? SECONDARY_SOFT_RANGE_DEFAULT.min, UNIT_LIMIT);
const second = readLimitedValue(range.max ?? SECONDARY_SOFT_RANGE_DEFAULT.max, UNIT_LIMIT);
return {
min: Math.min(first, second),
max: Math.max(first, second),
softness: readLimitedValue(range.softness ?? SECONDARY_SOFT_RANGE_DEFAULT.softness, {
min: 0,
max: 0.5,
}),
};
}
function normalizeSecondaryHue(value: unknown): Required<HfColorGradingHueRange> {
const hue = isRecord(value) ? value : {};
const range = readLimitedValue(hue.range ?? SECONDARY_HUE_DEFAULT.range, {
min: 0,
max: 180,
});
return {
center: wrapDegrees(hue.center, SECONDARY_HUE_DEFAULT.center),
range,
softness: readLimitedValue(hue.softness ?? SECONDARY_HUE_DEFAULT.softness, {
min: 0,
max: 180 - range,
}),
};
}
function normalizeSecondaryCorrection(
value: Record<string, unknown>,
): Required<HfColorGradingSecondaryCorrection> {
return {
hueShift: readLimitedValue(value.hueShift ?? 0, { min: -180, max: 180 }),
saturation: readLimitedValue(value.saturation ?? 0, { min: -1, max: 1 }),
luma: readLimitedValue(value.luma ?? 0, { min: -1, max: 1 }),
temperature: readLimitedValue(value.temperature ?? 0, { min: -1, max: 1 }),
tint: readLimitedValue(value.tint ?? 0, { min: -1, max: 1 }),
};
}
function normalizeSecondary(value: unknown): NormalizedHfColorGradingSecondary | null {
if (!isRecord(value) || !isRecord(value.key) || !isRecord(value.correction)) return null;
return {
enabled: value.enabled !== false,
key: {
hue: normalizeSecondaryHue(value.key.hue),
saturation: normalizeSoftRange(value.key.saturation),
luma: normalizeSoftRange(value.key.luma),
},
correction: normalizeSecondaryCorrection(value.correction),
};
}
function normalizeSecondaries(value: unknown): readonly NormalizedHfColorGradingSecondary[] {
if (!Array.isArray(value)) return [];
const result: NormalizedHfColorGradingSecondary[] = [];
for (const candidate of value.slice(0, COLOR_GRADING_MAX_SECONDARIES)) {
const secondary = normalizeSecondary(candidate);
if (secondary) result.push(secondary);
}
return result;
}
function normalizePresetId(value: unknown): HfColorGradingPresetId | string | null { function normalizePresetId(value: unknown): HfColorGradingPresetId | string | null {
if (value == null) return null; if (value == null) return null;
const preset = String(value).trim(); const preset = String(value).trim();
@@ -962,6 +1337,9 @@ export function resolveHfColorGradingVariables(
return raw; return raw;
} }
} }
if (Array.isArray(raw)) {
return raw.map((value) => resolveHfColorGradingVariables(value, variables));
}
if (!isRecord(raw)) return raw; if (!isRecord(raw)) return raw;
const resolved: Record<string, unknown> = {}; const resolved: Record<string, unknown> = {};
@@ -976,7 +1354,7 @@ function getHfColorGradingPreset(id: string | null | undefined): HfColorGradingP
return PRESETS_BY_ID.get(id) ?? null; return PRESETS_BY_ID.get(id) ?? null;
} }
export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading | null { export function normalizeHfColorGrading(raw: unknown): ResolvedHfColorGrading | null {
const grading = readColorGradingObject(raw); const grading = readColorGradingObject(raw);
if (!grading) return null; if (!grading) return null;
if (grading.enabled === false) return null; if (grading.enabled === false) return null;
@@ -1019,6 +1397,10 @@ export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading
preset: presetId, preset: presetId,
intensity: clampUnit(grading.intensity, preset?.intensity ?? 1), intensity: clampUnit(grading.intensity, preset?.intensity ?? 1),
adjust, adjust,
wheels: normalizeWheels(grading.wheels),
curves: normalizeCurves(grading.curves),
hueCurves: normalizeHueCurves(grading.hueCurves),
secondaries: normalizeSecondaries(grading.secondaries),
details, details,
effects, effects,
palette: normalizePalette(grading.palette), palette: normalizePalette(grading.palette),
@@ -1033,17 +1415,64 @@ export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading
export function normalizeHfColorGradingWithVariables( export function normalizeHfColorGradingWithVariables(
raw: unknown, raw: unknown,
variables: HfColorGradingVariableMap, variables: HfColorGradingVariableMap,
): NormalizedHfColorGrading | null { ): ResolvedHfColorGrading | null {
return normalizeHfColorGrading(resolveHfColorGradingVariables(raw, variables)); return normalizeHfColorGrading(resolveHfColorGradingVariables(raw, variables));
} }
function hasWheelGrade(wheels: NormalizedHfColorGradingWheels): boolean {
return HF_COLOR_GRADING_WHEEL_KEYS.some(
(key) => Math.abs(wheels[key].amount) > 0.0001 || Math.abs(wheels[key].level) > 0.0001,
);
}
export function hasHfColorGradingRgbCurveValues(curves: NormalizedHfColorGradingCurves): boolean {
return HF_COLOR_GRADING_CURVE_KEYS.some((key) =>
curves[key].some(([input, output]) => Math.abs(input - output) > 0.0001),
);
}
export function hasHfColorGradingHueCurveValues(
curves: NormalizedHfColorGradingHueCurves,
): boolean {
return HF_COLOR_GRADING_HUE_CURVE_KEYS.some((key) =>
curves[key].some(([, delta]) => Math.abs(delta) > 0.0001),
);
}
export function hasHfColorGradingSecondaryValues(
secondaries: readonly NormalizedHfColorGradingSecondary[],
): boolean {
return secondaries.some(
(secondary) =>
secondary.enabled &&
Object.values(secondary.correction).some((value) => Math.abs(value) > 0.0001),
);
}
export function serializeHfColorGrading( export function serializeHfColorGrading(
grading: NormalizedHfColorGrading | HfColorGrading | null, grading: NormalizedHfColorGrading | HfColorGrading | null,
): string { ): string {
const normalized = normalizeHfColorGrading(grading); const normalized = normalizeHfColorGrading(grading);
if (!normalized) return ""; if (!normalized) return "";
const { enabled: _enabled, palette, ...serializable } = normalized; const {
return JSON.stringify(palette ? { ...serializable, palette } : serializable); enabled: _enabled,
wheels,
curves,
hueCurves,
secondaries,
palette,
...serializable
} = normalized;
return JSON.stringify({
...serializable,
...(hasWheelGrade(wheels) ? { wheels } : {}),
...(hasHfColorGradingRgbCurveValues(curves) ? { curves } : {}),
...(hasHfColorGradingHueCurveValues(hueCurves) ? { hueCurves } : {}),
// Preserve a keyed secondary even before it changes pixels so Studio and
// CLI can author its qualifier and correction in separate transactions.
...(secondaries.length > 0 ? { secondaries } : {}),
...(palette ? { palette } : {}),
});
} }
export function isHfColorGradingActive( export function isHfColorGradingActive(
@@ -1057,5 +1486,19 @@ export function isHfColorGradingActive(
if (hasIndependentTreatment) return true; if (hasIndependentTreatment) return true;
if (grading.intensity === 0) return false; if (grading.intensity === 0) return false;
if (grading.lut && grading.lut.intensity !== 0) return true; if (grading.lut && grading.lut.intensity !== 0) return true;
return HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001); return (
HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001) ||
(grading.wheels ? hasWheelGrade(grading.wheels) : false) ||
(grading.curves ? hasHfColorGradingRgbCurveValues(grading.curves) : false) ||
(grading.hueCurves ? hasHfColorGradingHueCurveValues(grading.hueCurves) : false) ||
(grading.secondaries ? hasHfColorGradingSecondaryValues(grading.secondaries) : false)
);
}
export function hasHfColorGradingAuthoredValues(
grading: NormalizedHfColorGrading | null,
): grading is NormalizedHfColorGrading {
if (!grading) return false;
if (grading.secondaries?.length) return true;
return isHfColorGradingActive(grading);
} }
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { compileHfColorCurve, compileHfHueCurve } from "./colorGradingCurves";
describe("color grading curves", () => {
it("compiles an identity curve", () => {
expect([
...compileHfColorCurve(
[
[0, 0],
[1, 1],
],
5,
),
]).toEqual([0, 0.25, 0.5, 0.75, 1]);
});
it("compiles a shape-preserving S-curve", () => {
const samples = compileHfColorCurve(
[
[0, 0],
[0.25, 0.18],
[0.75, 0.82],
[1, 1],
],
9,
);
expect(samples[1]).toBeCloseTo(0.0787356322, 6);
expect(samples[4]).toBeCloseTo(0.5, 6);
expect(samples[7]).toBeCloseTo(0.9212643678, 6);
});
it("does not overshoot a non-monotone curve", () => {
const samples = compileHfColorCurve(
[
[0, 0],
[0.3, 0.8],
[0.6, 0.2],
[1, 1],
],
65,
);
expect(Math.min(...samples)).toBeGreaterThanOrEqual(0);
expect(Math.max(...samples)).toBeLessThanOrEqual(1);
});
it("rejects malformed input rather than guessing", () => {
expect(() =>
compileHfColorCurve([
[0, 0],
[0, 0.5],
[1, 1],
]),
).toThrow("strictly increasing");
expect(() => compileHfColorCurve([[0, 0]], 1)).toThrow("at least two points");
expect(() =>
compileHfColorCurve([
[-0.1, 0],
[1, 1],
]),
).toThrow("between 0 and 1");
expect(() =>
compileHfColorCurve([
[0.1, 0],
[1, 1],
]),
).toThrow("endpoints 0 and 1");
expect(() =>
compileHfColorCurve(
[
[0, -0.1],
[1, 1],
],
16,
),
).toThrow("outputs must be between 0 and 1");
expect(() =>
compileHfColorCurve(
Array.from({ length: 17 }, (_, index) => [index / 16, index / 16] as const),
),
).toThrow("at most 16 points");
expect(() =>
compileHfHueCurve(
[
[0, 0],
[120, 0],
[240, 0],
],
1,
-1,
),
).toThrow("bounds must be finite and increasing");
});
it("samples hue curves continuously across red", () => {
const samples = compileHfHueCurve(
[
[0, 1],
[120, 0],
[240, 0],
],
-1,
1,
360,
);
expect(samples[0]).toBeCloseTo(1, 6);
expect(samples[359]).toBeCloseTo(samples[0] ?? 0, 2);
});
it("sorts valid hue points but rejects duplicate hues", () => {
expect(
compileHfHueCurve(
[
[240, 0],
[0, 1],
[120, 0],
],
-1,
1,
4,
),
).toHaveLength(4);
expect(() =>
compileHfHueCurve(
[
[0, 0],
[0, 1],
[180, 0],
],
-1,
1,
),
).toThrow("unique");
});
it("supports degree-valued hue rotation without clipping it", () => {
const samples = compileHfHueCurve(
[
[0, 90],
[120, 0],
[240, -90],
],
-180,
180,
360,
);
expect(samples[0]).toBeCloseTo(90, 5);
expect(Math.min(...samples)).toBeGreaterThanOrEqual(-180);
expect(Math.max(...samples)).toBeLessThanOrEqual(180);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { COLOR_GRADING_MAX_CURVE_POINTS } from "@hyperframes/parsers/color-grading-contract";
export type HfColorCurvePoint = readonly [input: number, output: number];
export type HfHueCurvePoint = readonly [hueDegrees: number, delta: number];
export const HF_COLOR_CURVE_LUT_SIZE = 1024;
export const HF_COLOR_CURVE_MAX_POINTS = COLOR_GRADING_MAX_CURVE_POINTS;
function endpointSlope(
firstSpan: number,
secondSpan: number,
firstSlope: number,
secondSlope: number,
): number {
const slope =
((2 * firstSpan + secondSpan) * firstSlope - firstSpan * secondSlope) /
(firstSpan + secondSpan);
if (Math.sign(slope) !== Math.sign(firstSlope)) return 0;
if (
Math.sign(firstSlope) !== Math.sign(secondSlope) &&
Math.abs(slope) > 3 * Math.abs(firstSlope)
) {
return 3 * firstSlope;
}
return slope;
}
function pointSlopes(points: readonly HfColorCurvePoint[]): number[] {
const spans = points.slice(1).map(([x], index) => x - points[index]![0]);
const slopes = spans.map((span, index) => (points[index + 1]![1] - points[index]![1]) / span);
if (points.length === 2) return [slopes[0]!, slopes[0]!];
const result = new Array<number>(points.length);
result[0] = endpointSlope(spans[0]!, spans[1]!, slopes[0]!, slopes[1]!);
result[result.length - 1] = endpointSlope(
spans[spans.length - 1]!,
spans[spans.length - 2]!,
slopes[slopes.length - 1]!,
slopes[slopes.length - 2]!,
);
for (let index = 1; index < points.length - 1; index += 1) {
const before = slopes[index - 1]!;
const after = slopes[index]!;
if (before === 0 || after === 0 || Math.sign(before) !== Math.sign(after)) {
result[index] = 0;
continue;
}
const beforeSpan = spans[index - 1]!;
const afterSpan = spans[index]!;
const beforeWeight = 2 * afterSpan + beforeSpan;
const afterWeight = afterSpan + 2 * beforeSpan;
result[index] = (beforeWeight + afterWeight) / (beforeWeight / before + afterWeight / after);
}
return result;
}
function validateCurvePoints(points: readonly HfColorCurvePoint[], size: number): void {
if (points.length < 2) throw new RangeError("A color curve requires at least two points");
if (!Number.isInteger(size) || size < 2) {
throw new RangeError("Curve LUT size must be at least 2");
}
let previousInput = Number.NEGATIVE_INFINITY;
for (const [input, output] of points) {
if (!Number.isFinite(input) || !Number.isFinite(output)) {
throw new TypeError("Color curve points must be finite");
}
if (input <= previousInput) {
throw new RangeError("Color curve inputs must be strictly increasing");
}
previousInput = input;
}
}
function validateOutputRange(
points: readonly HfColorCurvePoint[],
outputMin: number,
outputMax: number,
): void {
if (!Number.isFinite(outputMin) || !Number.isFinite(outputMax) || outputMin >= outputMax) {
throw new RangeError("Curve output bounds must be finite and increasing");
}
if (points.some(([, output]) => output < outputMin || output > outputMax)) {
throw new RangeError(`Curve outputs must be between ${outputMin} and ${outputMax}`);
}
}
function interpolateCurveSegment(
input: number,
start: HfColorCurvePoint,
end: HfColorCurvePoint,
startTangent: number,
endTangent: number,
): number {
const span = end[0] - start[0];
const t = Math.min(1, Math.max(0, (input - start[0]) / span));
const t2 = t * t;
const t3 = t2 * t;
return (
(2 * t3 - 3 * t2 + 1) * start[1] +
(t3 - 2 * t2 + t) * span * startTangent +
(-2 * t3 + 3 * t2) * end[1] +
(t3 - t2) * span * endTangent
);
}
function compileCurveSamples(
points: readonly HfColorCurvePoint[],
size: number,
inputAt: (index: number) => number,
outputMin: number,
outputMax: number,
): Float32Array {
validateCurvePoints(points, size);
validateOutputRange(points, outputMin, outputMax);
const tangents = pointSlopes(points);
const samples = new Float32Array(size);
let segment = 0;
for (let index = 0; index < size; index += 1) {
const input = inputAt(index);
while (segment < points.length - 2 && input > points[segment + 1]![0]) {
segment += 1;
}
const start = points[segment]!;
const end = points[segment + 1]!;
const output = interpolateCurveSegment(
input,
start,
end,
tangents[segment]!,
tangents[segment + 1]!,
);
samples[index] = Math.min(outputMax, Math.max(outputMin, output));
}
return samples;
}
/** Samples a shape-preserving cubic curve into a GPU-ready 1D lookup table. */
export function compileHfColorCurve(
points: readonly HfColorCurvePoint[],
size = HF_COLOR_CURVE_LUT_SIZE,
): Float32Array {
if (points.length < 2) throw new RangeError("A color curve requires at least two points");
if (points.length > HF_COLOR_CURVE_MAX_POINTS) {
throw new RangeError(`A color curve supports at most ${HF_COLOR_CURVE_MAX_POINTS} points`);
}
if (points.some(([input]) => input < 0 || input > 1)) {
throw new RangeError("Color curve inputs must be between 0 and 1");
}
if (points[0]?.[0] !== 0 || points[points.length - 1]?.[0] !== 1) {
throw new RangeError("Color curves must include input endpoints 0 and 1");
}
return compileCurveSamples(points, size, (index) => index / (size - 1), 0, 1);
}
/** Samples a periodic hue curve without duplicating the 0/360-degree texel. */
export function compileHfHueCurve(
points: readonly HfHueCurvePoint[],
outputMin: number,
outputMax: number,
size = HF_COLOR_CURVE_LUT_SIZE,
): Float32Array {
if (points.length < 3) throw new RangeError("A hue curve requires at least three points");
if (points.length > HF_COLOR_CURVE_MAX_POINTS) {
throw new RangeError(`A hue curve supports at most ${HF_COLOR_CURVE_MAX_POINTS} points`);
}
const sorted = [...points].sort((a, b) => a[0] - b[0]);
for (let index = 0; index < sorted.length; index += 1) {
const point = sorted[index];
if (!point || point[0] < 0 || point[0] >= 360) {
throw new RangeError("Hue curve inputs must be from 0 up to 360 degrees");
}
if (index > 0 && point[0] === sorted[index - 1]?.[0]) {
throw new RangeError("Hue curve inputs must be unique");
}
}
const before = sorted.slice(-2).map(([hue, delta]) => [hue - 360, delta] as const);
const after = sorted.slice(0, 2).map(([hue, delta]) => [hue + 360, delta] as const);
return compileCurveSamples(
[...before, ...sorted, ...after],
size,
(index) => (index / size) * 360,
outputMin,
outputMax,
);
}
+24
View File
@@ -172,16 +172,22 @@ export {
HF_COLOR_GRADING_ANIMATABLE_PROPERTIES, HF_COLOR_GRADING_ANIMATABLE_PROPERTIES,
HF_COLOR_GRADING_CANVAS_ID_PREFIX, HF_COLOR_GRADING_CANVAS_ID_PREFIX,
HF_COLOR_GRADING_COLOR_SPACE, HF_COLOR_GRADING_COLOR_SPACE,
HF_COLOR_GRADING_CURVE_KEYS,
HF_COLOR_GRADING_DETAIL_KEYS, HF_COLOR_GRADING_DETAIL_KEYS,
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS, HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
HF_COLOR_GRADING_EFFECT_KEYS, HF_COLOR_GRADING_EFFECT_KEYS,
HF_COLOR_GRADING_EFFECT_PRESETS, HF_COLOR_GRADING_EFFECT_PRESETS,
HF_COLOR_GRADING_GRADE_PRESETS, HF_COLOR_GRADING_GRADE_PRESETS,
HF_COLOR_GRADING_HUE_CURVE_KEYS,
HF_COLOR_GRADING_LUT_KEYS, HF_COLOR_GRADING_LUT_KEYS,
HF_COLOR_GRADING_PALETTES, HF_COLOR_GRADING_PALETTES,
HF_COLOR_GRADING_PRESETS, HF_COLOR_GRADING_PRESETS,
HF_COLOR_GRADING_TOP_LEVEL_KEYS, HF_COLOR_GRADING_TOP_LEVEL_KEYS,
HF_COLOR_GRADING_WHEEL_KEYS,
compileHfColorCurve,
compileHfHueCurve,
getHfColorGradingCapabilities, getHfColorGradingCapabilities,
hasHfColorGradingAuthoredValues,
isHfColorGradingActive, isHfColorGradingActive,
normalizeHfColorGrading, normalizeHfColorGrading,
normalizeHfColorGradingWithVariables, normalizeHfColorGradingWithVariables,
@@ -202,7 +208,25 @@ export {
type HfColorGradingTarget, type HfColorGradingTarget,
type HfColorGradingVariableMap, type HfColorGradingVariableMap,
type HfColorGradingCapabilities, type HfColorGradingCapabilities,
type HfColorGradingCurveKey,
type HfColorGradingCurves,
type HfColorGradingHueCurveKey,
type HfColorGradingHueCurves,
type HfColorGradingHueRange,
type HfColorGradingSecondary,
type HfColorGradingSecondaryCorrection,
type HfColorGradingSoftRange,
type HfColorGradingTonalWheel,
type HfColorGradingWheelKey,
type HfColorGradingWheels,
type HfColorCurvePoint,
type HfHueCurvePoint,
type NormalizedHfColorGrading, type NormalizedHfColorGrading,
type NormalizedHfColorGradingCurves,
type NormalizedHfColorGradingHueCurves,
type NormalizedHfColorGradingSecondary,
type NormalizedHfColorGradingWheels,
type ResolvedHfColorGrading,
} from "./colorGrading"; } from "./colorGrading";
export { parseCubeLut, CubeLutParseError, type ParseCubeLutOptions } from "./colorLuts"; export { parseCubeLut, CubeLutParseError, type ParseCubeLutOptions } from "./colorLuts";
+225 -1
View File
@@ -58,6 +58,7 @@ function createMockWebGl(
TEXTURE2: 0x84c2, TEXTURE2: 0x84c2,
TEXTURE3: 0x84c3, TEXTURE3: 0x84c3,
TEXTURE4: 0x84c4, TEXTURE4: 0x84c4,
TEXTURE5: 0x84c5,
FLOAT: 0x1406, FLOAT: 0x1406,
TRIANGLE_STRIP: 0x0005, TRIANGLE_STRIP: 0x0005,
UNPACK_FLIP_Y_WEBGL: 0x9240, UNPACK_FLIP_Y_WEBGL: 0x9240,
@@ -489,6 +490,48 @@ describe("createColorGradingRuntime", () => {
toDataUrl.mockRestore(); toDataUrl.mockRestore();
}); });
it("serializes preview batches while a LUT is loading", async () => {
const video = makeDrawableVideo();
video.removeAttribute(HF_COLOR_GRADING_ATTR);
document.body.appendChild(video);
let resolveText: ((value: string) => void) | null = null;
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
text: () =>
new Promise<string>((resolve) => {
resolveText = resolve;
}),
}),
),
);
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(
"data:image/png;base64,preview",
);
runtime = createColorGradingRuntime();
const first = runtime.renderPreviews(
"#hero-video",
[{ id: "lut", grading: { lut: { src: "/looks/queued.cube" } } }],
{ maxDimension: 160 },
);
await Promise.resolve();
await Promise.resolve();
const second = runtime.renderPreviews("#hero-video", [{ id: "plain", grading: "neutral" }], {
maxDimension: 320,
});
await Promise.resolve();
expect(texImage2DCalls.filter((args) => args.length === 6)).toHaveLength(1);
resolveText?.(IDENTITY_2);
await expect(first).resolves.toMatchObject({ width: 160, height: 90 });
await expect(second).resolves.toMatchObject({ width: 320, height: 180 });
expect(texImage2DCalls.filter((args) => args.length === 6)).toHaveLength(2);
});
it("resolves grading values from the nearest sub-composition variable scope", () => { it("resolves grading values from the nearest sub-composition variable scope", () => {
window.__hfVariables = { window.__hfVariables = {
exposure: -0.25, exposure: -0.25,
@@ -1099,6 +1142,7 @@ describe("createColorGradingRuntime", () => {
runtime = createColorGradingRuntime(); runtime = createColorGradingRuntime();
const fragment = lastShaderSources.find((source) => source.includes("sampleMedia")); const fragment = lastShaderSources.find((source) => source.includes("sampleMedia"));
if (!fragment) throw new Error("Expected the media-treatment fragment shader");
expect(fragment).toContain("vec4 originalSample = sampleSource(uv);"); expect(fragment).toContain("vec4 originalSample = sampleSource(uv);");
expect(fragment).toContain("vec4 sampleColor = sampleMedia(uv);"); expect(fragment).toContain("vec4 sampleColor = sampleMedia(uv);");
expect(fragment).toContain("uniform sampler2D u_blurSource;"); expect(fragment).toContain("uniform sampler2D u_blurSource;");
@@ -1111,9 +1155,34 @@ describe("createColorGradingRuntime", () => {
expect(fragment).toContain("float blackPoint = clamp(u_blacks * 0.18"); expect(fragment).toContain("float blackPoint = clamp(u_blacks * 0.18");
expect(fragment).toContain("float whitePoint = clamp(1.0 - u_whites * 0.18"); expect(fragment).toContain("float whitePoint = clamp(1.0 - u_whites * 0.18");
expect(fragment).toContain("vec3 applyPrimaryGrade(vec3 color)"); expect(fragment).toContain("vec3 applyPrimaryGrade(vec3 color)");
expect(fragment).toContain("vec3 applyTonalWheels(vec3 color)");
expect(fragment).toContain("vec3 applyRgbCurves(vec3 color)");
expect(fragment).toContain("vec3 applyHueCurves(vec3 color)");
expect(fragment).toContain("vec3 applySecondary(vec3 color, float index)");
expect(fragment).toContain("vec3 applyColorGrade(vec3 color)");
expect(fragment).toContain( expect(fragment).toContain(
"vec3 color = mix(sampleColor.rgb, applyPrimaryGrade(sampleColor.rgb), u_intensity);", "float decodeSigned(float value){ return (value * 255.0 - 128.0) / 127.0; }",
); );
expect(fragment).toContain(
"vec3 color = mix(sampleColor.rgb, applyColorGrade(sampleColor.rgb), u_intensity);",
);
expect(fragment).toContain("vec3 cellColor = applyColorGrade(sampleMedia(cellUv).rgb);");
const advancedStart = fragment.indexOf("vec3 applyAdvancedGrade");
const wheels = fragment.indexOf("color = applyTonalWheels(color);", advancedStart);
const rgbCurves = fragment.indexOf("color = applyRgbCurves(color);", advancedStart);
const hueCurves = fragment.indexOf("color = applyHueCurves(color);", advancedStart);
const secondary = fragment.indexOf("color = applySecondary(color, 0.0);", advancedStart);
expect(wheels).toBeGreaterThan(advancedStart);
expect(rgbCurves).toBeGreaterThan(wheels);
expect(hueCurves).toBeGreaterThan(rgbCurves);
expect(secondary).toBeGreaterThan(hueCurves);
const gradeStart = fragment.indexOf("vec3 applyColorGrade");
const primary = fragment.indexOf("color = applyPrimaryGrade(color);", gradeStart);
const advanced = fragment.indexOf("color = applyAdvancedGrade(color);", gradeStart);
const lut = fragment.indexOf("applyLut(clamp(color, 0.0, 1.0))", gradeStart);
expect(primary).toBeGreaterThan(gradeStart);
expect(advanced).toBeGreaterThan(primary);
expect(lut).toBeGreaterThan(advanced);
expect(fragment).not.toContain("mix(original, color, u_intensity)"); expect(fragment).not.toContain("mix(original, color, u_intensity)");
expect(fragment).not.toContain("sampleSoft"); expect(fragment).not.toContain("sampleSoft");
const blurFragment = lastShaderSources.find((source) => const blurFragment = lastShaderSources.find((source) =>
@@ -1124,6 +1193,161 @@ describe("createColorGradingRuntime", () => {
expect(blurFragment).toContain("color.rgb /= color.a;"); expect(blurFragment).toContain("color.rgb /= color.a;");
}); });
it("does not upload the advanced texture for a basic-only grade", () => {
const video = makeDrawableVideo();
document.body.appendChild(video);
runtime = createColorGradingRuntime();
expect(texImage2DCalls.some((args) => args[3] === 1024 && args[4] === 3)).toBe(false);
expect(lastUniform1f).toHaveBeenCalledWith("u_rgbCurvesEnabled", 0);
expect(lastUniform1f).toHaveBeenCalledWith("u_hueCurvesEnabled", 0);
expect(lastUniform1f).toHaveBeenCalledWith("u_secondaryCount", 0);
});
it("does not upload identity curves with redundant diagonal points", () => {
const video = makeDrawableVideo();
video.setAttribute(
HF_COLOR_GRADING_ATTR,
serializeHfColorGrading({
curves: {
master: [
[0, 0],
[0.5, 0.5],
[1, 1],
],
},
details: { grain: 0.1 },
}),
);
document.body.appendChild(video);
runtime = createColorGradingRuntime();
expect(texImage2DCalls.some((args) => args[3] === 1024 && args[4] === 3)).toBe(false);
expect(lastUniform1f).toHaveBeenCalledWith("u_rgbCurvesEnabled", 0);
});
it("uploads and enables wheels, curves, hue curves, and ordered secondaries", () => {
const video = makeDrawableVideo();
video.setAttribute(
HF_COLOR_GRADING_ATTR,
serializeHfColorGrading({
wheels: {
shadows: { hue: 180, amount: 0.25, level: -0.1 },
midtones: { hue: 30, amount: 0.1, level: 0.05 },
},
curves: {
master: [
[0, 0],
[0.5, 0.6],
[1, 1],
],
red: [
[0, 0],
[0.5, 0.45],
[1, 1],
],
},
hueCurves: {
hueVsHue: [
[0, 0],
[120, 12],
[240, 0],
],
hueVsSaturation: [
[0, 0],
[120, 0.2],
[240, 0],
],
},
secondaries: [
{
key: {
hue: { center: 350, range: 20, softness: 10 },
saturation: { min: 0.2, max: 0.9, softness: 0.1 },
luma: { min: 0.1, max: 0.8, softness: 0.05 },
},
correction: {
hueShift: 8,
saturation: 0.15,
luma: 0.04,
temperature: 0.05,
tint: -0.03,
},
},
],
}),
);
document.body.appendChild(video);
runtime = createColorGradingRuntime();
const advancedUpload = texImage2DCalls.find(
(args) =>
args[3] === 1024 && args[4] === 3 && args[7] === 0x1401 && args[8] instanceof Uint8Array,
);
expect(advancedUpload).toBeDefined();
const pixels = advancedUpload?.[8] as Uint8Array;
expect(pixels[1024 * 4]).toBe(128);
expect(lastUniform3f).toHaveBeenCalledWith("u_shadowWheel", 0.5, 0.25, -0.1);
expect(lastUniform3f).toHaveBeenCalledWith("u_midtoneWheel", 30 / 360, 0.1, 0.05);
expect(lastUniform1f).toHaveBeenCalledWith("u_rgbCurvesEnabled", 1);
expect(lastUniform1f).toHaveBeenCalledWith("u_hueCurvesEnabled", 1);
expect(lastUniform1f).toHaveBeenCalledWith("u_secondaryCount", 1);
});
it("packs only enabled secondaries into the shader lookup", () => {
const video = makeDrawableVideo();
video.setAttribute(
HF_COLOR_GRADING_ATTR,
serializeHfColorGrading({
secondaries: [
{
enabled: false,
key: { hue: { center: 20, range: 15 } },
correction: { saturation: 0.5 },
},
{
key: { hue: { center: 210, range: 20 } },
correction: { luma: 0.1 },
},
],
}),
);
document.body.appendChild(video);
runtime = createColorGradingRuntime();
expect(lastUniform1f).toHaveBeenCalledWith("u_secondaryCount", 1);
const advancedUpload = texImage2DCalls.find(
(args) =>
args[3] === 1024 && args[4] === 3 && args[7] === 0x1401 && args[8] instanceof Uint8Array,
);
const pixels = advancedUpload?.[8] as Uint8Array;
expect(pixels[1024 * 4 * 2]).toBeCloseTo(Math.round((210 / 360) * 255), 0);
});
it("reuses an unchanged advanced texture while basic adjustments change", () => {
const video = makeDrawableVideo();
const curves = {
master: [
[0, 0],
[0.5, 0.6],
[1, 1],
],
} as const;
video.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ curves }));
document.body.appendChild(video);
runtime = createColorGradingRuntime();
const uploads = () =>
texImage2DCalls.filter((args) => args[3] === 1024 && args[4] === 3).length;
expect(uploads()).toBe(1);
runtime.setGrading(`#${video.id}`, { curves, adjust: { exposure: 0.2 } });
expect(uploads()).toBe(1);
});
it("renders blur passes at media resolution to avoid blocky high-strength blur", () => { it("renders blur passes at media resolution to avoid blocky high-strength blur", () => {
const video = makeDrawableVideo(); const video = makeDrawableVideo();
video.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ effects: { blur: 1 } })); video.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ effects: { blur: 1 } }));
+387 -36
View File
@@ -5,6 +5,9 @@ import {
HF_COLOR_GRADING_CANVAS_ID_PREFIX, HF_COLOR_GRADING_CANVAS_ID_PREFIX,
HF_COLOR_GRADING_DETAIL_KEYS, HF_COLOR_GRADING_DETAIL_KEYS,
HF_COLOR_GRADING_EFFECT_KEYS, HF_COLOR_GRADING_EFFECT_KEYS,
hasHfColorGradingHueCurveValues,
hasHfColorGradingRgbCurveValues,
hasHfColorGradingSecondaryValues,
isHfColorGradingActive, isHfColorGradingActive,
normalizeHfColorGrading, normalizeHfColorGrading,
normalizeHfColorGradingWithVariables, normalizeHfColorGradingWithVariables,
@@ -13,10 +16,20 @@ import {
type HfColorGradingDetailKey, type HfColorGradingDetailKey,
type HfColorGradingEffectKey, type HfColorGradingEffectKey,
type HfColorGradingTarget, type HfColorGradingTarget,
type NormalizedHfColorGrading, type NormalizedHfColorGradingCurves,
type NormalizedHfColorGradingHueCurves,
type NormalizedHfColorGradingSecondary,
type NormalizedHfColorGradingWheels,
type ResolvedHfColorGrading,
COLOR_GRADING_SOURCE_HIDDEN_ATTR, COLOR_GRADING_SOURCE_HIDDEN_ATTR,
COLOR_GRADING_AUTHORED_OPACITY_ATTR, COLOR_GRADING_AUTHORED_OPACITY_ATTR,
} from "../colorGrading"; } from "../colorGrading";
import {
compileHfColorCurve,
compileHfHueCurve,
HF_COLOR_CURVE_LUT_SIZE,
type HfHueCurvePoint,
} from "../colorGradingCurves";
import { import {
DEFAULT_MAX_CUBE_LUT_SIZE, DEFAULT_MAX_CUBE_LUT_SIZE,
packCubeLutToRgba8, packCubeLutToRgba8,
@@ -53,6 +66,8 @@ interface ProgramInfo {
program: WebGLProgram; program: WebGLProgram;
texture: WebGLTexture; texture: WebGLTexture;
lutTexture: WebGLTexture; lutTexture: WebGLTexture;
advancedTexture: WebGLTexture;
advancedSignature: string | null;
quad: WebGLBuffer; quad: WebGLBuffer;
position: number; position: number;
source: WebGLUniformLocation | null; source: WebGLUniformLocation | null;
@@ -60,6 +75,7 @@ interface ProgramInfo {
bloomSource: WebGLUniformLocation | null; bloomSource: WebGLUniformLocation | null;
kuwaharaSource: WebGLUniformLocation | null; kuwaharaSource: WebGLUniformLocation | null;
lut: WebGLUniformLocation | null; lut: WebGLUniformLocation | null;
advanced: WebGLUniformLocation | null;
resolution: WebGLUniformLocation | null; resolution: WebGLUniformLocation | null;
uvScale: WebGLUniformLocation | null; uvScale: WebGLUniformLocation | null;
uvOffset: WebGLUniformLocation | null; uvOffset: WebGLUniformLocation | null;
@@ -72,6 +88,12 @@ interface ProgramInfo {
lutDomainMin: WebGLUniformLocation | null; lutDomainMin: WebGLUniformLocation | null;
lutDomainMax: WebGLUniformLocation | null; lutDomainMax: WebGLUniformLocation | null;
lutIntensity: WebGLUniformLocation | null; lutIntensity: WebGLUniformLocation | null;
shadowWheel: WebGLUniformLocation | null;
midtoneWheel: WebGLUniformLocation | null;
highlightWheel: WebGLUniformLocation | null;
rgbCurvesEnabled: WebGLUniformLocation | null;
hueCurvesEnabled: WebGLUniformLocation | null;
secondaryCount: WebGLUniformLocation | null;
adjustUniforms: readonly FloatUniformBinding<HfColorGradingAdjustKey>[]; adjustUniforms: readonly FloatUniformBinding<HfColorGradingAdjustKey>[];
detailUniforms: readonly FloatUniformBinding<HfColorGradingDetailKey>[]; detailUniforms: readonly FloatUniformBinding<HfColorGradingDetailKey>[];
effectUniforms: readonly FloatUniformBinding<HfColorGradingEffectKey>[]; effectUniforms: readonly FloatUniformBinding<HfColorGradingEffectKey>[];
@@ -171,7 +193,7 @@ interface ColorGradingRenderer extends EffectRenderState {
interface ColorGradingEntry extends ColorGradingRenderer { interface ColorGradingEntry extends ColorGradingRenderer {
element: ColorGradingMediaElement; element: ColorGradingMediaElement;
grading: NormalizedHfColorGrading; grading: ResolvedHfColorGrading;
compare: RuntimeColorGradingCompareState; compare: RuntimeColorGradingCompareState;
lut: RuntimeLutTexture | null; lut: RuntimeLutTexture | null;
lutLoadingSrc: string | null; lutLoadingSrc: string | null;
@@ -314,8 +336,10 @@ const DEFAULT_COMPARE: RuntimeColorGradingCompareState = {
}; };
const DEFAULT_EFFECT_PALETTE = ["#000000", "#ffffff"] as const; const DEFAULT_EFFECT_PALETTE = ["#000000", "#ffffff"] as const;
const DEFAULT_ART_PALETTE = ["#1a1a1a", "#f5f5dc"] as const; const DEFAULT_ART_PALETTE = ["#1a1a1a", "#f5f5dc"] as const;
const ADVANCED_TEXTURE_HEIGHT = 3;
const ADVANCED_SECONDARY_TEXELS = 5;
function readColorGradingAttribute(element: Element): NormalizedHfColorGrading | null { function readColorGradingAttribute(element: Element): ResolvedHfColorGrading | null {
const raw = element.getAttribute(HF_COLOR_GRADING_ATTR); const raw = element.getAttribute(HF_COLOR_GRADING_ATTR);
if (raw == null) return null; if (raw == null) return null;
return normalizeHfColorGradingWithVariables(raw, readVariablesForElement(element)); return normalizeHfColorGradingWithVariables(raw, readVariablesForElement(element));
@@ -342,6 +366,7 @@ const FRAGMENT_SHADER = [
"uniform sampler2D u_bloomSource;", "uniform sampler2D u_bloomSource;",
"uniform sampler2D u_kuwaharaSource;", "uniform sampler2D u_kuwaharaSource;",
"uniform sampler2D u_lut;", "uniform sampler2D u_lut;",
"uniform sampler2D u_advanced;",
"uniform vec2 u_resolution;", "uniform vec2 u_resolution;",
"uniform vec2 u_uvScale;", "uniform vec2 u_uvScale;",
"uniform vec2 u_uvOffset;", "uniform vec2 u_uvOffset;",
@@ -354,6 +379,12 @@ const FRAGMENT_SHADER = [
"uniform vec3 u_lutDomainMin;", "uniform vec3 u_lutDomainMin;",
"uniform vec3 u_lutDomainMax;", "uniform vec3 u_lutDomainMax;",
"uniform float u_lutIntensity;", "uniform float u_lutIntensity;",
"uniform vec3 u_shadowWheel;",
"uniform vec3 u_midtoneWheel;",
"uniform vec3 u_highlightWheel;",
"uniform float u_rgbCurvesEnabled;",
"uniform float u_hueCurvesEnabled;",
"uniform float u_secondaryCount;",
"uniform float u_exposure;", "uniform float u_exposure;",
"uniform float u_contrast;", "uniform float u_contrast;",
"uniform float u_highlights;", "uniform float u_highlights;",
@@ -931,6 +962,135 @@ const FRAGMENT_SHADER = [
" if (brightness < 0.83) return mod(slash, 1.5) < 0.5 || mod(backslash, 1.5) < 0.5 ? 1.0 : 0.2;", " if (brightness < 0.83) return mod(slash, 1.5) < 0.5 || mod(backslash, 1.5) < 0.5 ? 1.0 : 0.2;",
" return mod(slash, 1.0) < 0.6 || mod(backslash, 1.0) < 0.6 ? 1.0 : 0.5;", " return mod(slash, 1.0) < 0.6 || mod(backslash, 1.0) < 0.6 ? 1.0 : 0.5;",
"}", "}",
"vec3 rgbToHsv(vec3 color){",
" float maximum = max(max(color.r, color.g), color.b);",
" float minimum = min(min(color.r, color.g), color.b);",
" float delta = maximum - minimum;",
" float hue = 0.0;",
" if (delta > 0.00001) {",
" if (maximum == color.r) hue = mod((color.g - color.b) / delta, 6.0);",
" else if (maximum == color.g) hue = (color.b - color.r) / delta + 2.0;",
" else hue = (color.r - color.g) / delta + 4.0;",
" hue = fract(hue / 6.0);",
" }",
" return vec3(hue, maximum > 0.00001 ? delta / maximum : 0.0, maximum);",
"}",
"vec3 hsvToRgb(vec3 color){",
" vec3 bands = abs(fract(color.xxx + vec3(0.0, 0.6666667, 0.3333333)) * 6.0 - 3.0);",
" return color.z * mix(vec3(1.0), clamp(bands - 1.0, 0.0, 1.0), color.y);",
"}",
"vec4 sampleAdvancedCurve(float coordinate, float row){",
" float position = clamp(coordinate, 0.0, 1.0) * 1023.0;",
" float lower = floor(position);",
" float upper = min(lower + 1.0, 1023.0);",
" float y = (row + 0.5) / 3.0;",
" vec4 before = texture2D(u_advanced, vec2((lower + 0.5) / 1024.0, y));",
" vec4 after = texture2D(u_advanced, vec2((upper + 0.5) / 1024.0, y));",
" return mix(before, after, position - lower);",
"}",
"vec4 advancedConfig(float index){",
" return texture2D(u_advanced, vec2((index + 0.5) / 1024.0, 0.8333333));",
"}",
"vec3 wheelDirection(float hue){",
" vec3 direction = hsvToRgb(vec3(fract(hue), 1.0, 1.0));",
" direction -= vec3(lumaOf(direction));",
" return direction / max(max(max(abs(direction.r), abs(direction.g)), abs(direction.b)), 0.0001);",
"}",
"vec3 applyTonalWheels(vec3 color){",
" float luma = lumaOf(color);",
" float shadows = 1.0 - smoothstep(0.0, 0.6, luma);",
" float highlights = smoothstep(0.4, 1.0, luma);",
" float midtones = max(0.0, 1.0 - shadows - highlights);",
" float total = max(shadows + midtones + highlights, 0.0001);",
" vec3 weights = vec3(shadows, midtones, highlights) / total;",
" color += wheelDirection(u_shadowWheel.x) * u_shadowWheel.y * weights.x * 0.18;",
" color += wheelDirection(u_midtoneWheel.x) * u_midtoneWheel.y * weights.y * 0.18;",
" color += wheelDirection(u_highlightWheel.x) * u_highlightWheel.y * weights.z * 0.18;",
" color += u_shadowWheel.z * weights.x * 0.25;",
" color += u_midtoneWheel.z * weights.y * 0.25;",
" color += u_highlightWheel.z * weights.z * 0.25;",
" return color;",
"}",
"vec3 applyRgbCurves(vec3 color){",
" if (u_rgbCurvesEnabled < 0.5) return color;",
" vec3 master = vec3(",
" sampleAdvancedCurve(color.r, 0.0).a,",
" sampleAdvancedCurve(color.g, 0.0).a,",
" sampleAdvancedCurve(color.b, 0.0).a",
" );",
" return vec3(",
" sampleAdvancedCurve(master.r, 0.0).r,",
" sampleAdvancedCurve(master.g, 0.0).g,",
" sampleAdvancedCurve(master.b, 0.0).b",
" );",
"}",
"float decodeSigned(float value){ return (value * 255.0 - 128.0) / 127.0; }",
"vec3 applyHueCurves(vec3 color){",
" if (u_hueCurvesEnabled < 0.5) return color;",
" vec3 hsv = rgbToHsv(clamp(color, 0.0, 1.0));",
" vec3 curves = sampleAdvancedCurve(hsv.x, 1.0).rgb;",
" float originalLuma = lumaOf(color);",
" hsv.x = fract(hsv.x + decodeSigned(curves.r) * 0.5);",
" hsv.y = clamp(hsv.y * max(0.0, 1.0 + decodeSigned(curves.g)), 0.0, 1.0);",
" vec3 shifted = hsvToRgb(hsv);",
" shifted += vec3(originalLuma - lumaOf(shifted) + decodeSigned(curves.b));",
" return shifted;",
"}",
"float softRangeMask(float value, float minimum, float maximum, float softness){",
" if (value < minimum) {",
" if (softness <= 0.0) return 0.0;",
" return smoothstep(minimum - softness, minimum, value);",
" }",
" if (value > maximum) {",
" if (softness <= 0.0) return 0.0;",
" return 1.0 - smoothstep(maximum, maximum + softness, value);",
" }",
" return 1.0;",
"}",
"float hueRangeMask(float hue, float saturation, vec3 key){",
" float distance = abs(fract(hue - key.x + 0.5) - 0.5) * 360.0;",
" float range = key.y * 180.0;",
" float softness = key.z * 180.0;",
" if (range < 179.999 && saturation < 0.001) return 0.0;",
" if (distance <= range) return 1.0;",
" if (softness <= 0.0) return 0.0;",
" return 1.0 - smoothstep(range, range + softness, distance);",
"}",
"vec3 applySecondary(vec3 color, float index){",
" float base = index * 5.0;",
" vec4 hueKey = advancedConfig(base);",
" vec4 saturationKey = advancedConfig(base + 1.0);",
" vec4 lumaKey = advancedConfig(base + 2.0);",
" vec4 correction = advancedConfig(base + 3.0);",
" vec4 tintCorrection = advancedConfig(base + 4.0);",
" vec3 hsv = rgbToHsv(clamp(color, 0.0, 1.0));",
" float luma = lumaOf(color);",
" float mask = hueRangeMask(hsv.x, hsv.y, hueKey.rgb);",
" mask *= softRangeMask(hsv.y, saturationKey.x, saturationKey.y, saturationKey.z * 0.5);",
" mask *= softRangeMask(luma, lumaKey.x, lumaKey.y, lumaKey.z * 0.5);",
" if (mask <= 0.0) return color;",
" hsv.x = fract(hsv.x + decodeSigned(correction.x) * 0.5);",
" vec3 corrected = hsvToRgb(hsv);",
" float correctedLuma = lumaOf(corrected);",
" corrected = mix(vec3(correctedLuma), corrected, max(0.0, 1.0 + decodeSigned(correction.y)));",
" corrected += vec3(decodeSigned(correction.z));",
" float temperature = decodeSigned(correction.w);",
" float tint = decodeSigned(tintCorrection.x);",
" corrected.r += temperature * 0.08 + tint * 0.04;",
" corrected.b -= temperature * 0.08 - tint * 0.04;",
" corrected.g -= tint * 0.08;",
" return mix(color, corrected, mask);",
"}",
"vec3 applyAdvancedGrade(vec3 color){",
" color = applyTonalWheels(color);",
" color = applyRgbCurves(color);",
" color = applyHueCurves(color);",
" if (u_secondaryCount > 0.5) color = applySecondary(color, 0.0);",
" if (u_secondaryCount > 1.5) color = applySecondary(color, 1.0);",
" if (u_secondaryCount > 2.5) color = applySecondary(color, 2.0);",
" if (u_secondaryCount > 3.5) color = applySecondary(color, 3.0);",
" return color;",
"}",
"vec3 sampleLut(float r, float g, float b){", "vec3 sampleLut(float r, float g, float b){",
" float size = max(u_lutSize, 2.0);", " float size = max(u_lutSize, 2.0);",
" float x = (r + b * size + 0.5) / max(u_lutTextureSize.x, 1.0);", " float x = (r + b * size + 0.5) / max(u_lutTextureSize.x, 1.0);",
@@ -982,6 +1142,11 @@ const FRAGMENT_SHADER = [
" float vibranceWeight = (1.0 - currentSat * 0.72) * mix(1.0, 0.55, skinLike);", " float vibranceWeight = (1.0 - currentSat * 0.72) * mix(1.0, 0.55, skinLike);",
" color = mix(vec3(satLuma), color, max(0.0, 1.0 + u_vibrance * vibranceWeight));", " color = mix(vec3(satLuma), color, max(0.0, 1.0 + u_vibrance * vibranceWeight));",
" color = mix(vec3(satLuma), color, max(0.0, 1.0 + u_saturation));", " color = mix(vec3(satLuma), color, max(0.0, 1.0 + u_saturation));",
" return color;",
"}",
"vec3 applyColorGrade(vec3 color){",
" color = applyPrimaryGrade(color);",
" color = applyAdvancedGrade(color);",
" return clamp(applyLut(clamp(color, 0.0, 1.0)), 0.0, 1.0);", " return clamp(applyLut(clamp(color, 0.0, 1.0)), 0.0, 1.0);",
"}", "}",
"vec3 applyDither(vec3 source, float amount){", "vec3 applyDither(vec3 source, float amount){",
@@ -1021,7 +1186,7 @@ const FRAGMENT_SHADER = [
" vec2 cellVuv = (cell + 0.5) * cellSize / max(u_resolution, vec2(1.0));", " vec2 cellVuv = (cell + 0.5) * cellSize / max(u_resolution, vec2(1.0));",
" vec2 cellUv = (cellVuv - u_uvOffset) / u_uvScale;", " vec2 cellUv = (cellVuv - u_uvOffset) / u_uvScale;",
" cellUv = applyCrtWarp(cellUv);", " cellUv = applyCrtWarp(cellUv);",
" vec3 cellColor = applyPrimaryGrade(sampleMedia(cellUv).rgb);", " vec3 cellColor = applyColorGrade(sampleMedia(cellUv).rgb);",
" float brightness = bt601Luma(cellColor);", " float brightness = bt601Luma(cellColor);",
" float invert = step(0.5, u_asciiInvert);", " float invert = step(0.5, u_asciiInvert);",
" brightness = mix(brightness, 1.0 - brightness, invert);", " brightness = mix(brightness, 1.0 - brightness, invert);",
@@ -1060,7 +1225,7 @@ const FRAGMENT_SHADER = [
" sampleColor = sampleChromaticMedia(uv, sampleColor);", " sampleColor = sampleChromaticMedia(uv, sampleColor);",
" sampleColor.rgb = applyDigitalGlitch(uv, sampleColor.rgb);", " sampleColor.rgb = applyDigitalGlitch(uv, sampleColor.rgb);",
" vec3 original = originalSample.rgb;", " vec3 original = originalSample.rgb;",
" vec3 color = mix(sampleColor.rgb, applyPrimaryGrade(sampleColor.rgb), u_intensity);", " vec3 color = mix(sampleColor.rgb, applyColorGrade(sampleColor.rgb), u_intensity);",
" float grainAmount = clamp(u_grain, 0.0, 1.0);", " float grainAmount = clamp(u_grain, 0.0, 1.0);",
" if (grainAmount > 0.0) {", " if (grainAmount > 0.0) {",
" float grainPixelSize = mix(1.0, 6.0, clamp(u_grainSize, 0.0, 1.0));", " float grainPixelSize = mix(1.0, 6.0, clamp(u_grainSize, 0.0, 1.0));",
@@ -1462,6 +1627,17 @@ function createFloatUniformBindings<K extends string>(
return bindings; return bindings;
} }
function deleteProgramResources(
gl: WebGLRenderingContext,
program: WebGLProgram | null,
textures: readonly (WebGLTexture | null)[],
): void {
if (program) gl.deleteProgram(program);
for (const texture of textures) {
if (texture) gl.deleteTexture(texture);
}
}
function createProgramInfo(canvas: HTMLCanvasElement): { function createProgramInfo(canvas: HTMLCanvasElement): {
gl: WebGLRenderingContext; gl: WebGLRenderingContext;
program: ProgramInfo; program: ProgramInfo;
@@ -1474,17 +1650,14 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
const program = createProgram(gl); const program = createProgram(gl);
const texture = createTexture(gl); const texture = createTexture(gl);
const lutTexture = createTexture(gl, gl.NEAREST); const lutTexture = createTexture(gl, gl.NEAREST);
if (!program || !texture || !lutTexture) { const advancedTexture = createTexture(gl, gl.NEAREST);
if (program) gl.deleteProgram(program); if (!program || !texture || !lutTexture || !advancedTexture) {
if (texture) gl.deleteTexture(texture); deleteProgramResources(gl, program, [texture, lutTexture, advancedTexture]);
if (lutTexture) gl.deleteTexture(lutTexture);
return null; return null;
} }
const quad = gl.createBuffer(); const quad = gl.createBuffer();
if (!quad) { if (!quad) {
gl.deleteProgram(program); deleteProgramResources(gl, program, [texture, lutTexture, advancedTexture]);
gl.deleteTexture(texture);
gl.deleteTexture(lutTexture);
return null; return null;
} }
gl.bindBuffer(gl.ARRAY_BUFFER, quad); gl.bindBuffer(gl.ARRAY_BUFFER, quad);
@@ -1496,6 +1669,8 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
program, program,
texture, texture,
lutTexture, lutTexture,
advancedTexture,
advancedSignature: null,
quad, quad,
position: gl.getAttribLocation(program, "a_pos"), position: gl.getAttribLocation(program, "a_pos"),
source: gl.getUniformLocation(program, "u_source"), source: gl.getUniformLocation(program, "u_source"),
@@ -1503,6 +1678,7 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
bloomSource: gl.getUniformLocation(program, "u_bloomSource"), bloomSource: gl.getUniformLocation(program, "u_bloomSource"),
kuwaharaSource: gl.getUniformLocation(program, "u_kuwaharaSource"), kuwaharaSource: gl.getUniformLocation(program, "u_kuwaharaSource"),
lut: gl.getUniformLocation(program, "u_lut"), lut: gl.getUniformLocation(program, "u_lut"),
advanced: gl.getUniformLocation(program, "u_advanced"),
resolution: gl.getUniformLocation(program, "u_resolution"), resolution: gl.getUniformLocation(program, "u_resolution"),
uvScale: gl.getUniformLocation(program, "u_uvScale"), uvScale: gl.getUniformLocation(program, "u_uvScale"),
uvOffset: gl.getUniformLocation(program, "u_uvOffset"), uvOffset: gl.getUniformLocation(program, "u_uvOffset"),
@@ -1515,6 +1691,12 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
lutDomainMin: gl.getUniformLocation(program, "u_lutDomainMin"), lutDomainMin: gl.getUniformLocation(program, "u_lutDomainMin"),
lutDomainMax: gl.getUniformLocation(program, "u_lutDomainMax"), lutDomainMax: gl.getUniformLocation(program, "u_lutDomainMax"),
lutIntensity: gl.getUniformLocation(program, "u_lutIntensity"), lutIntensity: gl.getUniformLocation(program, "u_lutIntensity"),
shadowWheel: gl.getUniformLocation(program, "u_shadowWheel"),
midtoneWheel: gl.getUniformLocation(program, "u_midtoneWheel"),
highlightWheel: gl.getUniformLocation(program, "u_highlightWheel"),
rgbCurvesEnabled: gl.getUniformLocation(program, "u_rgbCurvesEnabled"),
hueCurvesEnabled: gl.getUniformLocation(program, "u_hueCurvesEnabled"),
secondaryCount: gl.getUniformLocation(program, "u_secondaryCount"),
adjustUniforms: createFloatUniformBindings(gl, program, HF_COLOR_GRADING_ADJUST_KEYS), adjustUniforms: createFloatUniformBindings(gl, program, HF_COLOR_GRADING_ADJUST_KEYS),
detailUniforms: createFloatUniformBindings(gl, program, HF_COLOR_GRADING_DETAIL_KEYS), detailUniforms: createFloatUniformBindings(gl, program, HF_COLOR_GRADING_DETAIL_KEYS),
effectUniforms: createFloatUniformBindings(gl, program, HF_COLOR_GRADING_EFFECT_KEYS), effectUniforms: createFloatUniformBindings(gl, program, HF_COLOR_GRADING_EFFECT_KEYS),
@@ -1738,6 +1920,7 @@ function destroyProgramResources(renderer: ColorGradingRenderer, loseContext = f
function destroyMainProgramResources(gl: WebGLRenderingContext, program: ProgramInfo): void { function destroyMainProgramResources(gl: WebGLRenderingContext, program: ProgramInfo): void {
gl.deleteTexture(program.texture); gl.deleteTexture(program.texture);
gl.deleteTexture(program.lutTexture); gl.deleteTexture(program.lutTexture);
gl.deleteTexture(program.advancedTexture);
gl.deleteBuffer(program.quad); gl.deleteBuffer(program.quad);
gl.deleteProgram(program.program); gl.deleteProgram(program.program);
} }
@@ -1965,7 +2148,7 @@ function renderKuwaharaTexture(
layout: { width: number; height: number }, layout: { width: number; height: number },
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number }, uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
blurReady: boolean, blurReady: boolean,
effects: NormalizedHfColorGrading["effects"], effects: ResolvedHfColorGrading["effects"],
): void { ): void {
resizeRenderTarget(gl, targets.moments, layout.width, layout.height); resizeRenderTarget(gl, targets.moments, layout.width, layout.height);
resizeRenderTarget(gl, targets.output, layout.width, layout.height); resizeRenderTarget(gl, targets.output, layout.width, layout.height);
@@ -2065,7 +2248,7 @@ function prepareBloomTexture(
function prepareBlurAndBloomTextures( function prepareBlurAndBloomTextures(
state: EffectRenderState, state: EffectRenderState,
grading: NormalizedHfColorGrading, grading: ResolvedHfColorGrading,
layout: { width: number; height: number }, layout: { width: number; height: number },
releaseIdleTargets: boolean, releaseIdleTargets: boolean,
): Pick<PreparedEffectTextures, "blurReady" | "bloomReady" | "blurTexture" | "bloomTexture"> { ): Pick<PreparedEffectTextures, "blurReady" | "bloomReady" | "blurTexture" | "bloomTexture"> {
@@ -2111,7 +2294,7 @@ function prepareBlurAndBloomTextures(
function prepareKuwaharaTexture( function prepareKuwaharaTexture(
state: EffectRenderState, state: EffectRenderState,
grading: NormalizedHfColorGrading, grading: ResolvedHfColorGrading,
layout: { width: number; height: number }, layout: { width: number; height: number },
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number }, uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
blurReady: boolean, blurReady: boolean,
@@ -2146,7 +2329,7 @@ function prepareKuwaharaTexture(
function prepareEffectTextures( function prepareEffectTextures(
state: EffectRenderState, state: EffectRenderState,
grading: NormalizedHfColorGrading, grading: ResolvedHfColorGrading,
layout: { width: number; height: number }, layout: { width: number; height: number },
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number }, uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
options: { preserveKuwahara?: boolean; releaseIdleTargets?: boolean } = {}, options: { preserveKuwahara?: boolean; releaseIdleTargets?: boolean } = {},
@@ -2462,11 +2645,152 @@ function setPaletteColorUniform(
); );
} }
function writeAdvancedTexel(
data: Float32Array,
row: number,
column: number,
value: readonly [number, number, number, number],
): void {
data.set(value, (row * HF_COLOR_CURVE_LUT_SIZE + column) * 4);
}
function signedUnit(value: number, range: number): number {
return Math.min(1, Math.max(1 / 255, (value * (127 / range) + 128) / 255));
}
function compileHueCurveOrIdentity(
points: readonly HfHueCurvePoint[],
outputMin: number,
outputMax: number,
): Float32Array {
return points.length >= 3
? compileHfHueCurve(points, outputMin, outputMax)
: new Float32Array(HF_COLOR_CURVE_LUT_SIZE);
}
function sampleAt(samples: Float32Array, index: number): number {
return samples[index] ?? 0;
}
function writeAdvancedCurveRows(
data: Float32Array,
curves: NormalizedHfColorGradingCurves,
hueCurves: NormalizedHfColorGradingHueCurves,
): void {
const red = compileHfColorCurve(curves.red);
const green = compileHfColorCurve(curves.green);
const blue = compileHfColorCurve(curves.blue);
const master = compileHfColorCurve(curves.master);
const hueVsHue = compileHueCurveOrIdentity(hueCurves.hueVsHue, -180, 180);
const hueVsSaturation = compileHueCurveOrIdentity(hueCurves.hueVsSaturation, -1, 1);
const hueVsLuma = compileHueCurveOrIdentity(hueCurves.hueVsLuma, -1, 1);
for (let index = 0; index < HF_COLOR_CURVE_LUT_SIZE; index += 1) {
writeAdvancedTexel(data, 0, index, [
sampleAt(red, index),
sampleAt(green, index),
sampleAt(blue, index),
sampleAt(master, index),
]);
writeAdvancedTexel(data, 1, index, [
signedUnit(sampleAt(hueVsHue, index), 180),
signedUnit(sampleAt(hueVsSaturation, index), 1),
signedUnit(sampleAt(hueVsLuma, index), 1),
1,
]);
}
}
function writeAdvancedSecondary(
data: Float32Array,
secondary: NormalizedHfColorGradingSecondary,
index: number,
): void {
const base = index * ADVANCED_SECONDARY_TEXELS;
writeAdvancedTexel(data, 2, base, [
secondary.key.hue.center / 360,
secondary.key.hue.range / 180,
secondary.key.hue.softness / 180,
1,
]);
writeAdvancedTexel(data, 2, base + 1, [
secondary.key.saturation.min,
secondary.key.saturation.max,
secondary.key.saturation.softness / 0.5,
0,
]);
writeAdvancedTexel(data, 2, base + 2, [
secondary.key.luma.min,
secondary.key.luma.max,
secondary.key.luma.softness / 0.5,
0,
]);
writeAdvancedTexel(data, 2, base + 3, [
signedUnit(secondary.correction.hueShift, 180),
signedUnit(secondary.correction.saturation, 1),
signedUnit(secondary.correction.luma, 1),
signedUnit(secondary.correction.temperature, 1),
]);
writeAdvancedTexel(data, 2, base + 4, [signedUnit(secondary.correction.tint, 1), 0.5, 0.5, 1]);
}
function buildAdvancedTextureData(
curves: NormalizedHfColorGradingCurves,
hueCurves: NormalizedHfColorGradingHueCurves,
secondaries: readonly NormalizedHfColorGradingSecondary[],
): Float32Array {
const data = new Float32Array(HF_COLOR_CURVE_LUT_SIZE * ADVANCED_TEXTURE_HEIGHT * 4);
writeAdvancedCurveRows(data, curves, hueCurves);
secondaries
.slice(0, 4)
.forEach((secondary, index) => writeAdvancedSecondary(data, secondary, index));
return data;
}
function ensureAdvancedTexture(
gl: WebGLRenderingContext,
program: ProgramInfo,
grading: ResolvedHfColorGrading,
secondaries: readonly NormalizedHfColorGradingSecondary[],
): void {
const { curves, hueCurves } = grading;
const signature = JSON.stringify([curves, hueCurves, secondaries]);
if (program.advancedSignature === signature) return;
const pixels = Uint8Array.from(
buildAdvancedTextureData(curves, hueCurves, secondaries),
(value) => Math.round(Math.min(1, Math.max(0, value)) * 255),
);
gl.activeTexture(gl.TEXTURE5);
gl.bindTexture(gl.TEXTURE_2D, program.advancedTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
HF_COLOR_CURVE_LUT_SIZE,
ADVANCED_TEXTURE_HEIGHT,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
pixels,
);
program.advancedSignature = signature;
}
function setWheelUniform(
gl: WebGLRenderingContext,
location: WebGLUniformLocation | null,
wheel: NormalizedHfColorGradingWheels["shadows"],
): void {
gl.uniform3f(location, wheel.hue / 360, wheel.amount, wheel.level);
}
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
function applyUniforms( function applyUniforms(
gl: WebGLRenderingContext, gl: WebGLRenderingContext,
program: ProgramInfo, program: ProgramInfo,
grading: NormalizedHfColorGrading, grading: ResolvedHfColorGrading,
lut: RuntimeLutTexture | null, lut: RuntimeLutTexture | null,
blurReady: boolean, blurReady: boolean,
bloomReady: boolean, bloomReady: boolean,
@@ -2482,6 +2806,7 @@ function applyUniforms(
gl.uniform1i(program.lut, 2); gl.uniform1i(program.lut, 2);
gl.uniform1i(program.kuwaharaSource, 3); gl.uniform1i(program.kuwaharaSource, 3);
gl.uniform1i(program.bloomSource, 4); gl.uniform1i(program.bloomSource, 4);
gl.uniform1i(program.advanced, 5);
gl.uniform2f(program.resolution, layout.width, layout.height); gl.uniform2f(program.resolution, layout.width, layout.height);
gl.uniform2f(program.uvScale, uv.scaleX, uv.scaleY); gl.uniform2f(program.uvScale, uv.scaleX, uv.scaleY);
gl.uniform2f(program.uvOffset, uv.offsetX, uv.offsetY); gl.uniform2f(program.uvOffset, uv.offsetX, uv.offsetY);
@@ -2504,6 +2829,22 @@ function applyUniforms(
lut?.domainMax[2] ?? 1, lut?.domainMax[2] ?? 1,
); );
gl.uniform1f(program.lutIntensity, grading.lut?.intensity ?? 0); gl.uniform1f(program.lutIntensity, grading.lut?.intensity ?? 0);
const { curves, hueCurves, secondaries } = grading;
const enabledSecondaries = secondaries.filter((secondary) => secondary.enabled);
const rgbCurvesEnabled = hasHfColorGradingRgbCurveValues(curves);
const hueCurvesEnabled = hasHfColorGradingHueCurveValues(hueCurves);
const secondaryCount = hasHfColorGradingSecondaryValues(secondaries)
? enabledSecondaries.length
: 0;
if (rgbCurvesEnabled || hueCurvesEnabled || secondaryCount > 0) {
ensureAdvancedTexture(gl, program, grading, enabledSecondaries);
}
setWheelUniform(gl, program.shadowWheel, grading.wheels.shadows);
setWheelUniform(gl, program.midtoneWheel, grading.wheels.midtones);
setWheelUniform(gl, program.highlightWheel, grading.wheels.highlights);
gl.uniform1f(program.rgbCurvesEnabled, rgbCurvesEnabled ? 1 : 0);
gl.uniform1f(program.hueCurvesEnabled, hueCurvesEnabled ? 1 : 0);
gl.uniform1f(program.secondaryCount, secondaryCount);
for (const [key, location] of program.adjustUniforms) { for (const [key, location] of program.adjustUniforms) {
gl.uniform1f(location, grading.adjust[key]); gl.uniform1f(location, grading.adjust[key]);
} }
@@ -2599,8 +2940,8 @@ function readAnimatedValue(element: HTMLElement, property: AnimatedProperty): nu
function isRuntimeColorGradingActive( function isRuntimeColorGradingActive(
element: ColorGradingMediaElement, element: ColorGradingMediaElement,
grading: NormalizedHfColorGrading | null, grading: ResolvedHfColorGrading | null,
): grading is NormalizedHfColorGrading { ): grading is ResolvedHfColorGrading {
return ( return (
grading !== null && grading !== null &&
(isHfColorGradingActive(grading) || (isHfColorGradingActive(grading) ||
@@ -2610,9 +2951,9 @@ function isRuntimeColorGradingActive(
function readAnimatedEffects( function readAnimatedEffects(
element: HTMLElement, element: HTMLElement,
grading: NormalizedHfColorGrading, grading: ResolvedHfColorGrading,
): NormalizedHfColorGrading["effects"] | null { ): ResolvedHfColorGrading["effects"] | null {
let effects: NormalizedHfColorGrading["effects"] | null = null; let effects: ResolvedHfColorGrading["effects"] | null = null;
for (const [key, property] of ANIMATED_EFFECT_PROPERTIES) { for (const [key, property] of ANIMATED_EFFECT_PROPERTIES) {
const value = readAnimatedValue(element, property); const value = readAnimatedValue(element, property);
if (value === null) continue; if (value === null) continue;
@@ -2622,7 +2963,7 @@ function readAnimatedEffects(
return effects; return effects;
} }
function readAnimatedGrading(entry: ColorGradingEntry): NormalizedHfColorGrading { function readAnimatedGrading(entry: ColorGradingEntry): ResolvedHfColorGrading {
const { element, grading } = entry; const { element, grading } = entry;
const intensity = readAnimatedValue(element, ANIMATED_INTENSITY_PROPERTY); const intensity = readAnimatedValue(element, ANIMATED_INTENSITY_PROPERTY);
const lutIntensity = readAnimatedValue(element, ANIMATED_LUT_INTENSITY_PROPERTY); const lutIntensity = readAnimatedValue(element, ANIMATED_LUT_INTENSITY_PROPERTY);
@@ -2666,6 +3007,7 @@ function bindProgramTextures(
program.lutTexture, program.lutTexture,
prepared.kuwaharaTexture, prepared.kuwaharaTexture,
prepared.bloomTexture, prepared.bloomTexture,
program.advancedTexture,
]; ];
for (const [unit, texture] of textures.entries()) { for (const [unit, texture] of textures.entries()) {
gl.activeTexture(gl.TEXTURE0 + unit); gl.activeTexture(gl.TEXTURE0 + unit);
@@ -2893,7 +3235,7 @@ async function renderPreviewBatch(
function renderPreviewCandidate( function renderPreviewCandidate(
renderer: ColorGradingPreviewRenderer, renderer: ColorGradingPreviewRenderer,
grading: NormalizedHfColorGrading, grading: ResolvedHfColorGrading,
lut: RuntimeLutTexture | null, lut: RuntimeLutTexture | null,
dimensions: { width: number; height: number }, dimensions: { width: number; height: number },
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number }, uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
@@ -3090,11 +3432,12 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
const idleRenderers: ColorGradingRenderer[] = []; const idleRenderers: ColorGradingRenderer[] = [];
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
let previewRenderer: ColorGradingPreviewRenderer | null = null; let previewRenderer: ColorGradingPreviewRenderer | null = null;
let previewQueue = Promise.resolve();
let destroyed = false; let destroyed = false;
const upsert = ( const upsert = (
element: ColorGradingMediaElement, element: ColorGradingMediaElement,
grading: NormalizedHfColorGrading, grading: ResolvedHfColorGrading,
source: EntrySource, source: EntrySource,
): boolean => { ): boolean => {
const existing = entries.get(element); const existing = entries.get(element);
@@ -3348,18 +3691,26 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
candidates: readonly RuntimeColorGradingPreviewCandidate[], candidates: readonly RuntimeColorGradingPreviewCandidate[],
options?: { maxDimension?: number; useMediaTime?: boolean }, options?: { maxDimension?: number; useMediaTime?: boolean },
): Promise<RuntimeColorGradingPreviewBatch | null> => { ): Promise<RuntimeColorGradingPreviewBatch | null> => {
if (destroyed || candidates.length === 0) return null; const run = async () => {
const element = resolveTarget(target); if (destroyed || candidates.length === 0) return null;
if (!element) return null; const element = resolveTarget(target);
previewRenderer ??= createPreviewRenderer(); if (!element) return null;
if (!previewRenderer) return null; previewRenderer ??= createPreviewRenderer();
return renderPreviewBatch( if (!previewRenderer) return null;
previewRenderer, return renderPreviewBatch(
element, previewRenderer,
candidates, element,
options?.maxDimension, candidates,
options?.useMediaTime, options?.maxDimension,
options?.useMediaTime,
);
};
const result = previewQueue.then(run, run);
previewQueue = result.then(
() => undefined,
() => undefined,
); );
return result;
}; };
const startPreviewPlayback = ( const startPreviewPlayback = (
@@ -3,8 +3,10 @@ import {
COLOR_GRADING_ADJUST_KEYS, COLOR_GRADING_ADJUST_KEYS,
COLOR_GRADING_DETAIL_KEYS, COLOR_GRADING_DETAIL_KEYS,
COLOR_GRADING_EFFECT_KEYS, COLOR_GRADING_EFFECT_KEYS,
COLOR_GRADING_HUE_CURVE_KEYS,
COLOR_GRADING_LUT_KEYS, COLOR_GRADING_LUT_KEYS,
COLOR_GRADING_TOP_LEVEL_KEYS, COLOR_GRADING_TOP_LEVEL_KEYS,
COLOR_GRADING_WHEEL_KEYS,
isColorGradingVariableRef, isColorGradingVariableRef,
validateColorGradingContract, validateColorGradingContract,
} from "./colorGradingContract"; } from "./colorGradingContract";
@@ -15,6 +17,8 @@ describe("color grading contract", () => {
expect(COLOR_GRADING_ADJUST_KEYS).toContain("exposure"); expect(COLOR_GRADING_ADJUST_KEYS).toContain("exposure");
expect(COLOR_GRADING_DETAIL_KEYS).toContain("grain"); expect(COLOR_GRADING_DETAIL_KEYS).toContain("grain");
expect(COLOR_GRADING_EFFECT_KEYS).toContain("kuwahara"); expect(COLOR_GRADING_EFFECT_KEYS).toContain("kuwahara");
expect(COLOR_GRADING_WHEEL_KEYS).toEqual(["shadows", "midtones", "highlights"]);
expect(COLOR_GRADING_HUE_CURVE_KEYS).toContain("hueVsSaturation");
expect(COLOR_GRADING_LUT_KEYS).toEqual(["src", "intensity"]); expect(COLOR_GRADING_LUT_KEYS).toEqual(["src", "intensity"]);
}); });
@@ -60,4 +64,104 @@ describe("color grading contract", () => {
]), ]),
); );
}); });
it("accepts wheels, color curves, hue curves, and HSL secondaries", () => {
expect(
validateColorGradingContract({
wheels: {
shadows: { hue: 205, amount: 0.08, level: -0.02 },
highlights: { hue: 35, amount: 0.06 },
},
curves: {
master: [
[0, 0],
[0.25, 0.18],
[0.75, 0.82],
[1, 1],
],
},
hueCurves: {
hueVsSaturation: [
[180, 0],
[215, 0.15],
[250, 0],
],
},
secondaries: [
{
enabled: false,
key: {
hue: { center: 215, range: 25, softness: 10 },
saturation: { min: 0.2, max: 1, softness: 0.08 },
luma: { min: 0.1, max: 0.9, softness: 0.08 },
},
correction: { saturation: 0.15, luma: 0.03 },
},
],
}),
).toEqual([]);
});
it("rejects malformed advanced controls instead of normalizing them", () => {
const points = Array.from({ length: 16 }, (_, index) => [(index + 1) / 17, (index + 1) / 17]);
const issues = validateColorGradingContract({
wheels: { shadows: { hue: 360, mystery: 1 } },
curves: {
master: points,
red: [
[0, 0],
[0, 1],
],
},
hueCurves: {
hueVsHue: [
[0, 0],
[120, 20],
],
},
secondaries: [
{
enabled: "yes",
key: {
hue: { center: 360, range: 20, softness: 170 },
saturation: { min: 0.8, max: 0.2, softness: 0.6 },
},
correction: { hueShift: 200 },
},
],
});
expect(issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "wheels.shadows" }),
expect.objectContaining({ path: "wheels.shadows.hue" }),
expect.objectContaining({
path: "curves.master",
message: expect.stringContaining("including inferred 0 and 1 endpoints"),
}),
expect.objectContaining({ path: "curves.red", message: "contains duplicate input 0" }),
expect.objectContaining({ path: "hueCurves.hueVsHue" }),
expect.objectContaining({ path: "secondaries[0].enabled" }),
expect.objectContaining({ path: "secondaries[0].key.hue.center" }),
expect.objectContaining({ path: "secondaries[0].key.saturation" }),
expect.objectContaining({ path: "secondaries[0].correction.hueShift" }),
]),
);
});
it("allows advanced sections to be supplied by variables", () => {
expect(
validateColorGradingContract({
wheels: "$wheels",
curves: "${curves}",
hueCurves: "$hueCurves",
secondaries: "$secondaries",
}),
).toEqual([]);
expect(
validateColorGradingContract({
secondaries: [{ enabled: "$secondaryEnabled", key: {}, correction: {} }],
}),
).toEqual([]);
});
}); });
+246 -9
View File
@@ -1,11 +1,17 @@
export const COLOR_GRADING_CONTRACT_VERSION = 1; export const COLOR_GRADING_CONTRACT_VERSION = 2;
export const COLOR_GRADING_COLOR_SPACE = "rec709"; export const COLOR_GRADING_COLOR_SPACE = "rec709";
export const COLOR_GRADING_MAX_CURVE_POINTS = 16;
export const COLOR_GRADING_MAX_SECONDARIES = 4;
export const COLOR_GRADING_TOP_LEVEL_KEYS = [ export const COLOR_GRADING_TOP_LEVEL_KEYS = [
"enabled", "enabled",
"preset", "preset",
"intensity", "intensity",
"adjust", "adjust",
"wheels",
"curves",
"hueCurves",
"secondaries",
"details", "details",
"effects", "effects",
"palette", "palette",
@@ -26,6 +32,23 @@ export const COLOR_GRADING_ADJUST_KEYS = [
"saturation", "saturation",
] as const; ] as const;
export const COLOR_GRADING_WHEEL_KEYS = ["shadows", "midtones", "highlights"] as const;
export const COLOR_GRADING_CURVE_KEYS = ["master", "red", "green", "blue"] as const;
export const COLOR_GRADING_HUE_CURVE_KEYS = ["hueVsHue", "hueVsSaturation", "hueVsLuma"] as const;
const COLOR_GRADING_WHEEL_CONTROL_KEYS = ["hue", "amount", "level"] as const;
const COLOR_GRADING_SECONDARY_KEYS = ["enabled", "key", "correction"] as const;
const COLOR_GRADING_SECONDARY_KEY_KEYS = ["hue", "saturation", "luma"] as const;
const COLOR_GRADING_HUE_RANGE_KEYS = ["center", "range", "softness"] as const;
const COLOR_GRADING_SOFT_RANGE_KEYS = ["min", "max", "softness"] as const;
const COLOR_GRADING_SECONDARY_CORRECTION_KEYS = [
"hueShift",
"saturation",
"luma",
"temperature",
"tint",
] as const;
export const COLOR_GRADING_DETAIL_KEYS = [ export const COLOR_GRADING_DETAIL_KEYS = [
"vignette", "vignette",
"vignetteMidpoint", "vignetteMidpoint",
@@ -176,28 +199,106 @@ function validateObject(
return value; return value;
} }
function isNumberInRange(value: unknown, limit: NumericLimit, inclusiveMax: boolean): boolean {
if (typeof value !== "number" || !Number.isFinite(value) || value < limit.min) return false;
return inclusiveMax ? value <= limit.max : value < limit.max;
}
function validateNumericField( function validateNumericField(
value: Record<string, unknown>, value: Record<string, unknown>,
key: string, key: string,
path: string, path: string,
limit: NumericLimit, limit: NumericLimit,
issues: ColorGradingContractIssue[], issues: ColorGradingContractIssue[],
inclusiveMax = true,
): void { ): void {
const candidate = value[key]; const candidate = value[key];
if (candidate === undefined || isColorGradingVariableRef(candidate)) return; if (candidate === undefined || isColorGradingVariableRef(candidate)) return;
if ( if (isNumberInRange(candidate, limit, inclusiveMax)) return;
typeof candidate !== "number" || issues.push({
!Number.isFinite(candidate) || path: path ? `${path}.${key}` : key,
candidate < limit.min || message: `must be a finite number from ${limit.min} ${inclusiveMax ? "through" : "up to"} ${limit.max}`,
candidate > limit.max });
) { }
function isFiniteTuple(value: unknown): value is readonly [number, number] {
return (
Array.isArray(value) &&
value.length === 2 &&
value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate))
);
}
function validateCurve(value: unknown, path: string, issues: ColorGradingContractIssue[]): void {
if (isColorGradingVariableRef(value)) return;
if (!Array.isArray(value) || value.length < 2 || value.length > COLOR_GRADING_MAX_CURVE_POINTS) {
issues.push({ issues.push({
path: path ? `${path}.${key}` : key, path,
message: `must be a finite number from ${limit.min} through ${limit.max}`, message: `must contain 2 to ${COLOR_GRADING_MAX_CURVE_POINTS} [input, output] points`,
});
return;
}
const inputs = new Set<number>();
value.forEach((point, index) => {
if (!isFiniteTuple(point)) {
issues.push({ path: `${path}[${index}]`, message: "must be a finite [input, output] tuple" });
return;
}
const [input, output] = point;
if (input < 0 || input > 1 || output < 0 || output > 1) {
issues.push({ path: `${path}[${index}]`, message: "coordinates must be between 0 and 1" });
}
if (inputs.has(input)) issues.push({ path, message: `contains duplicate input ${input}` });
inputs.add(input);
});
const inferredPointCount = value.length + (inputs.has(0) ? 0 : 1) + (inputs.has(1) ? 0 : 1);
if (inferredPointCount > COLOR_GRADING_MAX_CURVE_POINTS) {
issues.push({
path,
message: `must contain at most ${COLOR_GRADING_MAX_CURVE_POINTS} points including inferred 0 and 1 endpoints`,
}); });
} }
} }
function validateHueCurve(
value: unknown,
path: string,
outputMin: number,
outputMax: number,
issues: ColorGradingContractIssue[],
): void {
if (isColorGradingVariableRef(value)) return;
if (!Array.isArray(value) || value.length < 3 || value.length > COLOR_GRADING_MAX_CURVE_POINTS) {
issues.push({
path,
message: `must contain 3 to ${COLOR_GRADING_MAX_CURVE_POINTS} [hueDegrees, delta] points`,
});
return;
}
const hues = new Set<number>();
value.forEach((point, index) => {
if (!isFiniteTuple(point)) {
issues.push({ path: `${path}[${index}]`, message: "must be a finite [hue, delta] tuple" });
return;
}
const [hue, delta] = point;
if (hue < 0 || hue >= 360) {
issues.push({ path: `${path}[${index}][0]`, message: "must be from 0 up to 360 degrees" });
}
if (delta < outputMin || delta > outputMax) {
issues.push({
path: `${path}[${index}][1]`,
message: `must be between ${outputMin} and ${outputMax}`,
});
}
if (hues.has(hue)) issues.push({ path, message: `contains duplicate hue ${hue}` });
hues.add(hue);
});
}
function validateNumericSection( function validateNumericSection(
value: Record<string, unknown>, value: Record<string, unknown>,
path: string, path: string,
@@ -338,6 +439,138 @@ function validateSections(
for (const [key, keys] of OBJECT_SECTIONS) validateSection(grading, key, keys, issues); for (const [key, keys] of OBJECT_SECTIONS) validateSection(grading, key, keys, issues);
} }
function validateWheels(value: unknown, issues: ColorGradingContractIssue[]): void {
if (value === undefined || isColorGradingVariableRef(value)) return;
const wheels = validateObject(value, "wheels", COLOR_GRADING_WHEEL_KEYS, issues);
if (!wheels) return;
for (const wheel of COLOR_GRADING_WHEEL_KEYS) {
if (wheels[wheel] === undefined) continue;
const path = `wheels.${wheel}`;
const controls = validateObject(wheels[wheel], path, COLOR_GRADING_WHEEL_CONTROL_KEYS, issues);
if (!controls) continue;
validateNumericField(controls, "hue", path, { min: 0, max: 360 }, issues, false);
validateNumericField(controls, "amount", path, UNIT_LIMIT, issues);
validateNumericField(controls, "level", path, SIGNED_UNIT_LIMIT, issues);
}
}
function validateCurves(value: unknown, issues: ColorGradingContractIssue[]): void {
if (value === undefined || isColorGradingVariableRef(value)) return;
const curves = validateObject(value, "curves", COLOR_GRADING_CURVE_KEYS, issues);
if (!curves) return;
for (const key of COLOR_GRADING_CURVE_KEYS) {
if (curves[key] !== undefined) validateCurve(curves[key], `curves.${key}`, issues);
}
}
function validateHueCurves(value: unknown, issues: ColorGradingContractIssue[]): void {
if (value === undefined || isColorGradingVariableRef(value)) return;
const curves = validateObject(value, "hueCurves", COLOR_GRADING_HUE_CURVE_KEYS, issues);
if (!curves) return;
const limits = {
hueVsHue: [-180, 180],
hueVsSaturation: [-1, 1],
hueVsLuma: [-1, 1],
} as const;
for (const key of COLOR_GRADING_HUE_CURVE_KEYS) {
if (curves[key] !== undefined) {
validateHueCurve(curves[key], `hueCurves.${key}`, limits[key][0], limits[key][1], issues);
}
}
}
function validateSoftRange(
value: unknown,
path: string,
issues: ColorGradingContractIssue[],
): void {
const range = validateObject(value, path, COLOR_GRADING_SOFT_RANGE_KEYS, issues);
if (!range) return;
validateNumericField(range, "min", path, UNIT_LIMIT, issues);
validateNumericField(range, "max", path, UNIT_LIMIT, issues);
validateNumericField(range, "softness", path, { min: 0, max: 0.5 }, issues);
if (typeof range.min === "number" && typeof range.max === "number" && range.min >= range.max) {
issues.push({ path, message: "min must be smaller than max" });
}
}
function validateSecondaryHue(
value: unknown,
path: string,
issues: ColorGradingContractIssue[],
): void {
const hue = validateObject(value, path, COLOR_GRADING_HUE_RANGE_KEYS, issues);
if (!hue) return;
validateNumericField(hue, "center", path, { min: 0, max: 360 }, issues, false);
validateNumericField(hue, "range", path, { min: 0, max: 180 }, issues);
validateNumericField(hue, "softness", path, { min: 0, max: 180 }, issues);
if (
typeof hue.range === "number" &&
typeof hue.softness === "number" &&
hue.range + hue.softness > 180
) {
issues.push({ path, message: "range plus softness must not exceed 180 degrees" });
}
}
function validateSecondaryKey(
value: unknown,
path: string,
issues: ColorGradingContractIssue[],
): void {
const key = validateObject(value, path, COLOR_GRADING_SECONDARY_KEY_KEYS, issues);
if (!key) return;
if (key.hue !== undefined) validateSecondaryHue(key.hue, `${path}.hue`, issues);
if (key.saturation !== undefined) validateSoftRange(key.saturation, `${path}.saturation`, issues);
if (key.luma !== undefined) validateSoftRange(key.luma, `${path}.luma`, issues);
}
function validateSecondaryCorrection(
value: unknown,
path: string,
issues: ColorGradingContractIssue[],
): void {
const correction = validateObject(value, path, COLOR_GRADING_SECONDARY_CORRECTION_KEYS, issues);
if (!correction) return;
validateNumericField(correction, "hueShift", path, { min: -180, max: 180 }, issues);
for (const key of ["saturation", "luma", "temperature", "tint"] as const) {
validateNumericField(correction, key, path, SIGNED_UNIT_LIMIT, issues);
}
}
function validateSecondary(
value: unknown,
index: number,
issues: ColorGradingContractIssue[],
): void {
const path = `secondaries[${index}]`;
const secondary = validateObject(value, path, COLOR_GRADING_SECONDARY_KEYS, issues);
if (!secondary) return;
if (
secondary.enabled !== undefined &&
typeof secondary.enabled !== "boolean" &&
!isColorGradingVariableRef(secondary.enabled)
) {
issues.push({ path: `${path}.enabled`, message: "must be a boolean" });
}
validateSecondaryKey(secondary.key, `${path}.key`, issues);
validateSecondaryCorrection(secondary.correction, `${path}.correction`, issues);
}
function validateSecondaries(value: unknown, issues: ColorGradingContractIssue[]): void {
if (value === undefined || isColorGradingVariableRef(value)) return;
if (!Array.isArray(value) || value.length > COLOR_GRADING_MAX_SECONDARIES) {
issues.push({
path: "secondaries",
message: `must be an array of at most ${COLOR_GRADING_MAX_SECONDARIES} selections`,
});
return;
}
value.forEach((secondary, index) => validateSecondary(secondary, index, issues));
}
/** Browser-safe structural validation shared by Lint, CLI, and Core consumers. */ /** Browser-safe structural validation shared by Lint, CLI, and Core consumers. */
export function validateColorGradingContract(value: unknown): ColorGradingContractIssue[] { export function validateColorGradingContract(value: unknown): ColorGradingContractIssue[] {
if (typeof value === "string") { if (typeof value === "string") {
@@ -350,6 +583,10 @@ export function validateColorGradingContract(value: unknown): ColorGradingContra
validateTopLevel(grading, issues); validateTopLevel(grading, issues);
validateSections(grading, issues); validateSections(grading, issues);
validateWheels(grading.wheels, issues);
validateCurves(grading.curves, issues);
validateHueCurves(grading.hueCurves, issues);
validateSecondaries(grading.secondaries, issues);
validatePalette(grading.palette, issues); validatePalette(grading.palette, issues);
return issues; return issues;
} }