mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): add professional color grading controls
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
HF_COLOR_GRADING_PALETTES,
|
||||
HF_COLOR_GRADING_PRESETS,
|
||||
getHfColorGradingCapabilities,
|
||||
hasHfColorGradingAuthoredValues,
|
||||
isHfColorGradingActive,
|
||||
normalizeHfColorGrading,
|
||||
normalizeHfColorGradingWithVariables,
|
||||
@@ -123,6 +124,7 @@ describe("color grading", () => {
|
||||
it("publishes a complete capability catalog for agent-built treatments", () => {
|
||||
const capabilities = getHfColorGradingCapabilities();
|
||||
|
||||
expect(capabilities.version).toBe(2);
|
||||
expect(capabilities.colorSpace).toBe("rec709");
|
||||
expect(capabilities.adjustments.map(({ key }) => key)).toEqual([
|
||||
"exposure",
|
||||
@@ -158,6 +160,23 @@ describe("color grading", () => {
|
||||
expect(capabilities.effects.find(({ key }) => key === "kuwahara")?.renderLane).toBe(
|
||||
"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", () => {
|
||||
@@ -617,4 +636,181 @@ describe("color grading", () => {
|
||||
expect(grading?.adjust.contrast).toBe(0.2);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { DEFAULT_MAX_CUBE_LUT_SIZE } from "./colorLuts";
|
||||
import type { HfColorCurvePoint, HfHueCurvePoint } from "./colorGradingCurves";
|
||||
import {
|
||||
COLOR_GRADING_ADJUST_KEYS,
|
||||
COLOR_GRADING_COLOR_SPACE,
|
||||
COLOR_GRADING_CONTRACT_VERSION,
|
||||
COLOR_GRADING_CURVE_KEYS,
|
||||
COLOR_GRADING_DETAIL_KEYS,
|
||||
COLOR_GRADING_EFFECT_KEYS,
|
||||
COLOR_GRADING_HUE_CURVE_KEYS,
|
||||
COLOR_GRADING_LUT_KEYS,
|
||||
COLOR_GRADING_MAX_CURVE_POINTS,
|
||||
COLOR_GRADING_MAX_SECONDARIES,
|
||||
COLOR_GRADING_TOP_LEVEL_KEYS,
|
||||
COLOR_GRADING_WHEEL_KEYS,
|
||||
} 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";
|
||||
|
||||
// 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 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.
|
||||
export type HfColorGradingDetailKey = (typeof COLOR_GRADING_DETAIL_KEYS)[number];
|
||||
|
||||
@@ -159,6 +247,10 @@ export interface HfColorGrading {
|
||||
preset?: HfColorGradingPresetId | string | null;
|
||||
intensity?: number;
|
||||
adjust?: HfColorGradingAdjust;
|
||||
wheels?: HfColorGradingWheels;
|
||||
curves?: HfColorGradingCurves;
|
||||
hueCurves?: HfColorGradingHueCurves;
|
||||
secondaries?: readonly HfColorGradingSecondary[];
|
||||
details?: HfColorGradingDetails;
|
||||
effects?: HfColorGradingEffects;
|
||||
palette?: readonly string[] | null;
|
||||
@@ -177,6 +269,10 @@ export interface NormalizedHfColorGrading {
|
||||
preset: HfColorGradingPresetId | string | null;
|
||||
intensity: number;
|
||||
adjust: Record<HfColorGradingAdjustKey, number>;
|
||||
wheels?: NormalizedHfColorGradingWheels;
|
||||
curves?: NormalizedHfColorGradingCurves;
|
||||
hueCurves?: NormalizedHfColorGradingHueCurves;
|
||||
secondaries?: readonly NormalizedHfColorGradingSecondary[];
|
||||
details: Record<HfColorGradingDetailKey, number>;
|
||||
effects: Record<HfColorGradingEffectKey, number>;
|
||||
palette: readonly string[] | null;
|
||||
@@ -184,6 +280,13 @@ export interface NormalizedHfColorGrading {
|
||||
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 {
|
||||
id?: string | null;
|
||||
hfId?: string | null;
|
||||
@@ -293,6 +396,15 @@ export type HfColorGradingVariableMap = Record<string, unknown>;
|
||||
export const HF_COLOR_GRADING_ADJUST_KEYS =
|
||||
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 =
|
||||
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 },
|
||||
};
|
||||
|
||||
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 }> = {
|
||||
vignette: { 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. */
|
||||
export function getHfColorGradingCapabilities() {
|
||||
return {
|
||||
version: 1,
|
||||
version: COLOR_GRADING_CONTRACT_VERSION,
|
||||
targetTags: ["img", "video"],
|
||||
colorSpace: HF_COLOR_GRADING_COLOR_SPACE,
|
||||
intensity: { identity: 1, min: 0, max: 1 },
|
||||
@@ -835,6 +976,60 @@ export function getHfColorGradingCapabilities() {
|
||||
identity: ADJUST_ZERO[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,
|
||||
finishing: HF_COLOR_GRADING_DETAIL_KEYS.map((key) => ({
|
||||
key,
|
||||
@@ -902,6 +1097,186 @@ function readLimitedValue(value: unknown, limit: { min: number; max: number }):
|
||||
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 {
|
||||
if (value == null) return null;
|
||||
const preset = String(value).trim();
|
||||
@@ -962,6 +1337,9 @@ export function resolveHfColorGradingVariables(
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((value) => resolveHfColorGradingVariables(value, variables));
|
||||
}
|
||||
if (!isRecord(raw)) return raw;
|
||||
|
||||
const resolved: Record<string, unknown> = {};
|
||||
@@ -976,7 +1354,7 @@ function getHfColorGradingPreset(id: string | null | undefined): HfColorGradingP
|
||||
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);
|
||||
if (!grading) return null;
|
||||
if (grading.enabled === false) return null;
|
||||
@@ -1019,6 +1397,10 @@ export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading
|
||||
preset: presetId,
|
||||
intensity: clampUnit(grading.intensity, preset?.intensity ?? 1),
|
||||
adjust,
|
||||
wheels: normalizeWheels(grading.wheels),
|
||||
curves: normalizeCurves(grading.curves),
|
||||
hueCurves: normalizeHueCurves(grading.hueCurves),
|
||||
secondaries: normalizeSecondaries(grading.secondaries),
|
||||
details,
|
||||
effects,
|
||||
palette: normalizePalette(grading.palette),
|
||||
@@ -1033,17 +1415,64 @@ export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading
|
||||
export function normalizeHfColorGradingWithVariables(
|
||||
raw: unknown,
|
||||
variables: HfColorGradingVariableMap,
|
||||
): NormalizedHfColorGrading | null {
|
||||
): ResolvedHfColorGrading | null {
|
||||
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(
|
||||
grading: NormalizedHfColorGrading | HfColorGrading | null,
|
||||
): string {
|
||||
const normalized = normalizeHfColorGrading(grading);
|
||||
if (!normalized) return "";
|
||||
const { enabled: _enabled, palette, ...serializable } = normalized;
|
||||
return JSON.stringify(palette ? { ...serializable, palette } : serializable);
|
||||
const {
|
||||
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(
|
||||
@@ -1057,5 +1486,19 @@ export function isHfColorGradingActive(
|
||||
if (hasIndependentTreatment) return true;
|
||||
if (grading.intensity === 0) return false;
|
||||
if (grading.lut && grading.lut.intensity !== 0) return true;
|
||||
return HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -172,16 +172,22 @@ export {
|
||||
HF_COLOR_GRADING_ANIMATABLE_PROPERTIES,
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
HF_COLOR_GRADING_COLOR_SPACE,
|
||||
HF_COLOR_GRADING_CURVE_KEYS,
|
||||
HF_COLOR_GRADING_DETAIL_KEYS,
|
||||
HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
|
||||
HF_COLOR_GRADING_EFFECT_KEYS,
|
||||
HF_COLOR_GRADING_EFFECT_PRESETS,
|
||||
HF_COLOR_GRADING_GRADE_PRESETS,
|
||||
HF_COLOR_GRADING_HUE_CURVE_KEYS,
|
||||
HF_COLOR_GRADING_LUT_KEYS,
|
||||
HF_COLOR_GRADING_PALETTES,
|
||||
HF_COLOR_GRADING_PRESETS,
|
||||
HF_COLOR_GRADING_TOP_LEVEL_KEYS,
|
||||
HF_COLOR_GRADING_WHEEL_KEYS,
|
||||
compileHfColorCurve,
|
||||
compileHfHueCurve,
|
||||
getHfColorGradingCapabilities,
|
||||
hasHfColorGradingAuthoredValues,
|
||||
isHfColorGradingActive,
|
||||
normalizeHfColorGrading,
|
||||
normalizeHfColorGradingWithVariables,
|
||||
@@ -202,7 +208,25 @@ export {
|
||||
type HfColorGradingTarget,
|
||||
type HfColorGradingVariableMap,
|
||||
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 NormalizedHfColorGradingCurves,
|
||||
type NormalizedHfColorGradingHueCurves,
|
||||
type NormalizedHfColorGradingSecondary,
|
||||
type NormalizedHfColorGradingWheels,
|
||||
type ResolvedHfColorGrading,
|
||||
} from "./colorGrading";
|
||||
export { parseCubeLut, CubeLutParseError, type ParseCubeLutOptions } from "./colorLuts";
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ function createMockWebGl(
|
||||
TEXTURE2: 0x84c2,
|
||||
TEXTURE3: 0x84c3,
|
||||
TEXTURE4: 0x84c4,
|
||||
TEXTURE5: 0x84c5,
|
||||
FLOAT: 0x1406,
|
||||
TRIANGLE_STRIP: 0x0005,
|
||||
UNPACK_FLIP_Y_WEBGL: 0x9240,
|
||||
@@ -489,6 +490,48 @@ describe("createColorGradingRuntime", () => {
|
||||
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", () => {
|
||||
window.__hfVariables = {
|
||||
exposure: -0.25,
|
||||
@@ -1099,6 +1142,7 @@ describe("createColorGradingRuntime", () => {
|
||||
runtime = createColorGradingRuntime();
|
||||
|
||||
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 sampleColor = sampleMedia(uv);");
|
||||
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 whitePoint = clamp(1.0 - u_whites * 0.18");
|
||||
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(
|
||||
"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("sampleSoft");
|
||||
const blurFragment = lastShaderSources.find((source) =>
|
||||
@@ -1124,6 +1193,161 @@ describe("createColorGradingRuntime", () => {
|
||||
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", () => {
|
||||
const video = makeDrawableVideo();
|
||||
video.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ effects: { blur: 1 } }));
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
HF_COLOR_GRADING_DETAIL_KEYS,
|
||||
HF_COLOR_GRADING_EFFECT_KEYS,
|
||||
hasHfColorGradingHueCurveValues,
|
||||
hasHfColorGradingRgbCurveValues,
|
||||
hasHfColorGradingSecondaryValues,
|
||||
isHfColorGradingActive,
|
||||
normalizeHfColorGrading,
|
||||
normalizeHfColorGradingWithVariables,
|
||||
@@ -13,10 +16,20 @@ import {
|
||||
type HfColorGradingDetailKey,
|
||||
type HfColorGradingEffectKey,
|
||||
type HfColorGradingTarget,
|
||||
type NormalizedHfColorGrading,
|
||||
type NormalizedHfColorGradingCurves,
|
||||
type NormalizedHfColorGradingHueCurves,
|
||||
type NormalizedHfColorGradingSecondary,
|
||||
type NormalizedHfColorGradingWheels,
|
||||
type ResolvedHfColorGrading,
|
||||
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
|
||||
COLOR_GRADING_AUTHORED_OPACITY_ATTR,
|
||||
} from "../colorGrading";
|
||||
import {
|
||||
compileHfColorCurve,
|
||||
compileHfHueCurve,
|
||||
HF_COLOR_CURVE_LUT_SIZE,
|
||||
type HfHueCurvePoint,
|
||||
} from "../colorGradingCurves";
|
||||
import {
|
||||
DEFAULT_MAX_CUBE_LUT_SIZE,
|
||||
packCubeLutToRgba8,
|
||||
@@ -53,6 +66,8 @@ interface ProgramInfo {
|
||||
program: WebGLProgram;
|
||||
texture: WebGLTexture;
|
||||
lutTexture: WebGLTexture;
|
||||
advancedTexture: WebGLTexture;
|
||||
advancedSignature: string | null;
|
||||
quad: WebGLBuffer;
|
||||
position: number;
|
||||
source: WebGLUniformLocation | null;
|
||||
@@ -60,6 +75,7 @@ interface ProgramInfo {
|
||||
bloomSource: WebGLUniformLocation | null;
|
||||
kuwaharaSource: WebGLUniformLocation | null;
|
||||
lut: WebGLUniformLocation | null;
|
||||
advanced: WebGLUniformLocation | null;
|
||||
resolution: WebGLUniformLocation | null;
|
||||
uvScale: WebGLUniformLocation | null;
|
||||
uvOffset: WebGLUniformLocation | null;
|
||||
@@ -72,6 +88,12 @@ interface ProgramInfo {
|
||||
lutDomainMin: WebGLUniformLocation | null;
|
||||
lutDomainMax: 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>[];
|
||||
detailUniforms: readonly FloatUniformBinding<HfColorGradingDetailKey>[];
|
||||
effectUniforms: readonly FloatUniformBinding<HfColorGradingEffectKey>[];
|
||||
@@ -171,7 +193,7 @@ interface ColorGradingRenderer extends EffectRenderState {
|
||||
|
||||
interface ColorGradingEntry extends ColorGradingRenderer {
|
||||
element: ColorGradingMediaElement;
|
||||
grading: NormalizedHfColorGrading;
|
||||
grading: ResolvedHfColorGrading;
|
||||
compare: RuntimeColorGradingCompareState;
|
||||
lut: RuntimeLutTexture | null;
|
||||
lutLoadingSrc: string | null;
|
||||
@@ -314,8 +336,10 @@ const DEFAULT_COMPARE: RuntimeColorGradingCompareState = {
|
||||
};
|
||||
const DEFAULT_EFFECT_PALETTE = ["#000000", "#ffffff"] 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);
|
||||
if (raw == null) return null;
|
||||
return normalizeHfColorGradingWithVariables(raw, readVariablesForElement(element));
|
||||
@@ -342,6 +366,7 @@ const FRAGMENT_SHADER = [
|
||||
"uniform sampler2D u_bloomSource;",
|
||||
"uniform sampler2D u_kuwaharaSource;",
|
||||
"uniform sampler2D u_lut;",
|
||||
"uniform sampler2D u_advanced;",
|
||||
"uniform vec2 u_resolution;",
|
||||
"uniform vec2 u_uvScale;",
|
||||
"uniform vec2 u_uvOffset;",
|
||||
@@ -354,6 +379,12 @@ const FRAGMENT_SHADER = [
|
||||
"uniform vec3 u_lutDomainMin;",
|
||||
"uniform vec3 u_lutDomainMax;",
|
||||
"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_contrast;",
|
||||
"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;",
|
||||
" 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){",
|
||||
" float size = max(u_lutSize, 2.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);",
|
||||
" 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));",
|
||||
" 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);",
|
||||
"}",
|
||||
"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 cellUv = (cellVuv - u_uvOffset) / u_uvScale;",
|
||||
" cellUv = applyCrtWarp(cellUv);",
|
||||
" vec3 cellColor = applyPrimaryGrade(sampleMedia(cellUv).rgb);",
|
||||
" vec3 cellColor = applyColorGrade(sampleMedia(cellUv).rgb);",
|
||||
" float brightness = bt601Luma(cellColor);",
|
||||
" float invert = step(0.5, u_asciiInvert);",
|
||||
" brightness = mix(brightness, 1.0 - brightness, invert);",
|
||||
@@ -1060,7 +1225,7 @@ const FRAGMENT_SHADER = [
|
||||
" sampleColor = sampleChromaticMedia(uv, sampleColor);",
|
||||
" sampleColor.rgb = applyDigitalGlitch(uv, sampleColor.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);",
|
||||
" if (grainAmount > 0.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;
|
||||
}
|
||||
|
||||
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): {
|
||||
gl: WebGLRenderingContext;
|
||||
program: ProgramInfo;
|
||||
@@ -1474,17 +1650,14 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
|
||||
const program = createProgram(gl);
|
||||
const texture = createTexture(gl);
|
||||
const lutTexture = createTexture(gl, gl.NEAREST);
|
||||
if (!program || !texture || !lutTexture) {
|
||||
if (program) gl.deleteProgram(program);
|
||||
if (texture) gl.deleteTexture(texture);
|
||||
if (lutTexture) gl.deleteTexture(lutTexture);
|
||||
const advancedTexture = createTexture(gl, gl.NEAREST);
|
||||
if (!program || !texture || !lutTexture || !advancedTexture) {
|
||||
deleteProgramResources(gl, program, [texture, lutTexture, advancedTexture]);
|
||||
return null;
|
||||
}
|
||||
const quad = gl.createBuffer();
|
||||
if (!quad) {
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteTexture(texture);
|
||||
gl.deleteTexture(lutTexture);
|
||||
deleteProgramResources(gl, program, [texture, lutTexture, advancedTexture]);
|
||||
return null;
|
||||
}
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
|
||||
@@ -1496,6 +1669,8 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
|
||||
program,
|
||||
texture,
|
||||
lutTexture,
|
||||
advancedTexture,
|
||||
advancedSignature: null,
|
||||
quad,
|
||||
position: gl.getAttribLocation(program, "a_pos"),
|
||||
source: gl.getUniformLocation(program, "u_source"),
|
||||
@@ -1503,6 +1678,7 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
|
||||
bloomSource: gl.getUniformLocation(program, "u_bloomSource"),
|
||||
kuwaharaSource: gl.getUniformLocation(program, "u_kuwaharaSource"),
|
||||
lut: gl.getUniformLocation(program, "u_lut"),
|
||||
advanced: gl.getUniformLocation(program, "u_advanced"),
|
||||
resolution: gl.getUniformLocation(program, "u_resolution"),
|
||||
uvScale: gl.getUniformLocation(program, "u_uvScale"),
|
||||
uvOffset: gl.getUniformLocation(program, "u_uvOffset"),
|
||||
@@ -1515,6 +1691,12 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
|
||||
lutDomainMin: gl.getUniformLocation(program, "u_lutDomainMin"),
|
||||
lutDomainMax: gl.getUniformLocation(program, "u_lutDomainMax"),
|
||||
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),
|
||||
detailUniforms: createFloatUniformBindings(gl, program, HF_COLOR_GRADING_DETAIL_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 {
|
||||
gl.deleteTexture(program.texture);
|
||||
gl.deleteTexture(program.lutTexture);
|
||||
gl.deleteTexture(program.advancedTexture);
|
||||
gl.deleteBuffer(program.quad);
|
||||
gl.deleteProgram(program.program);
|
||||
}
|
||||
@@ -1965,7 +2148,7 @@ function renderKuwaharaTexture(
|
||||
layout: { width: number; height: number },
|
||||
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
|
||||
blurReady: boolean,
|
||||
effects: NormalizedHfColorGrading["effects"],
|
||||
effects: ResolvedHfColorGrading["effects"],
|
||||
): void {
|
||||
resizeRenderTarget(gl, targets.moments, layout.width, layout.height);
|
||||
resizeRenderTarget(gl, targets.output, layout.width, layout.height);
|
||||
@@ -2065,7 +2248,7 @@ function prepareBloomTexture(
|
||||
|
||||
function prepareBlurAndBloomTextures(
|
||||
state: EffectRenderState,
|
||||
grading: NormalizedHfColorGrading,
|
||||
grading: ResolvedHfColorGrading,
|
||||
layout: { width: number; height: number },
|
||||
releaseIdleTargets: boolean,
|
||||
): Pick<PreparedEffectTextures, "blurReady" | "bloomReady" | "blurTexture" | "bloomTexture"> {
|
||||
@@ -2111,7 +2294,7 @@ function prepareBlurAndBloomTextures(
|
||||
|
||||
function prepareKuwaharaTexture(
|
||||
state: EffectRenderState,
|
||||
grading: NormalizedHfColorGrading,
|
||||
grading: ResolvedHfColorGrading,
|
||||
layout: { width: number; height: number },
|
||||
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
|
||||
blurReady: boolean,
|
||||
@@ -2146,7 +2329,7 @@ function prepareKuwaharaTexture(
|
||||
|
||||
function prepareEffectTextures(
|
||||
state: EffectRenderState,
|
||||
grading: NormalizedHfColorGrading,
|
||||
grading: ResolvedHfColorGrading,
|
||||
layout: { width: number; height: number },
|
||||
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
|
||||
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
|
||||
function applyUniforms(
|
||||
gl: WebGLRenderingContext,
|
||||
program: ProgramInfo,
|
||||
grading: NormalizedHfColorGrading,
|
||||
grading: ResolvedHfColorGrading,
|
||||
lut: RuntimeLutTexture | null,
|
||||
blurReady: boolean,
|
||||
bloomReady: boolean,
|
||||
@@ -2482,6 +2806,7 @@ function applyUniforms(
|
||||
gl.uniform1i(program.lut, 2);
|
||||
gl.uniform1i(program.kuwaharaSource, 3);
|
||||
gl.uniform1i(program.bloomSource, 4);
|
||||
gl.uniform1i(program.advanced, 5);
|
||||
gl.uniform2f(program.resolution, layout.width, layout.height);
|
||||
gl.uniform2f(program.uvScale, uv.scaleX, uv.scaleY);
|
||||
gl.uniform2f(program.uvOffset, uv.offsetX, uv.offsetY);
|
||||
@@ -2504,6 +2829,22 @@ function applyUniforms(
|
||||
lut?.domainMax[2] ?? 1,
|
||||
);
|
||||
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) {
|
||||
gl.uniform1f(location, grading.adjust[key]);
|
||||
}
|
||||
@@ -2599,8 +2940,8 @@ function readAnimatedValue(element: HTMLElement, property: AnimatedProperty): nu
|
||||
|
||||
function isRuntimeColorGradingActive(
|
||||
element: ColorGradingMediaElement,
|
||||
grading: NormalizedHfColorGrading | null,
|
||||
): grading is NormalizedHfColorGrading {
|
||||
grading: ResolvedHfColorGrading | null,
|
||||
): grading is ResolvedHfColorGrading {
|
||||
return (
|
||||
grading !== null &&
|
||||
(isHfColorGradingActive(grading) ||
|
||||
@@ -2610,9 +2951,9 @@ function isRuntimeColorGradingActive(
|
||||
|
||||
function readAnimatedEffects(
|
||||
element: HTMLElement,
|
||||
grading: NormalizedHfColorGrading,
|
||||
): NormalizedHfColorGrading["effects"] | null {
|
||||
let effects: NormalizedHfColorGrading["effects"] | null = null;
|
||||
grading: ResolvedHfColorGrading,
|
||||
): ResolvedHfColorGrading["effects"] | null {
|
||||
let effects: ResolvedHfColorGrading["effects"] | null = null;
|
||||
for (const [key, property] of ANIMATED_EFFECT_PROPERTIES) {
|
||||
const value = readAnimatedValue(element, property);
|
||||
if (value === null) continue;
|
||||
@@ -2622,7 +2963,7 @@ function readAnimatedEffects(
|
||||
return effects;
|
||||
}
|
||||
|
||||
function readAnimatedGrading(entry: ColorGradingEntry): NormalizedHfColorGrading {
|
||||
function readAnimatedGrading(entry: ColorGradingEntry): ResolvedHfColorGrading {
|
||||
const { element, grading } = entry;
|
||||
const intensity = readAnimatedValue(element, ANIMATED_INTENSITY_PROPERTY);
|
||||
const lutIntensity = readAnimatedValue(element, ANIMATED_LUT_INTENSITY_PROPERTY);
|
||||
@@ -2666,6 +3007,7 @@ function bindProgramTextures(
|
||||
program.lutTexture,
|
||||
prepared.kuwaharaTexture,
|
||||
prepared.bloomTexture,
|
||||
program.advancedTexture,
|
||||
];
|
||||
for (const [unit, texture] of textures.entries()) {
|
||||
gl.activeTexture(gl.TEXTURE0 + unit);
|
||||
@@ -2893,7 +3235,7 @@ async function renderPreviewBatch(
|
||||
|
||||
function renderPreviewCandidate(
|
||||
renderer: ColorGradingPreviewRenderer,
|
||||
grading: NormalizedHfColorGrading,
|
||||
grading: ResolvedHfColorGrading,
|
||||
lut: RuntimeLutTexture | null,
|
||||
dimensions: { width: number; height: number },
|
||||
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
|
||||
@@ -3090,11 +3432,12 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
const idleRenderers: ColorGradingRenderer[] = [];
|
||||
let observer: MutationObserver | null = null;
|
||||
let previewRenderer: ColorGradingPreviewRenderer | null = null;
|
||||
let previewQueue = Promise.resolve();
|
||||
let destroyed = false;
|
||||
|
||||
const upsert = (
|
||||
element: ColorGradingMediaElement,
|
||||
grading: NormalizedHfColorGrading,
|
||||
grading: ResolvedHfColorGrading,
|
||||
source: EntrySource,
|
||||
): boolean => {
|
||||
const existing = entries.get(element);
|
||||
@@ -3348,18 +3691,26 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
candidates: readonly RuntimeColorGradingPreviewCandidate[],
|
||||
options?: { maxDimension?: number; useMediaTime?: boolean },
|
||||
): Promise<RuntimeColorGradingPreviewBatch | null> => {
|
||||
if (destroyed || candidates.length === 0) return null;
|
||||
const element = resolveTarget(target);
|
||||
if (!element) return null;
|
||||
previewRenderer ??= createPreviewRenderer();
|
||||
if (!previewRenderer) return null;
|
||||
return renderPreviewBatch(
|
||||
previewRenderer,
|
||||
element,
|
||||
candidates,
|
||||
options?.maxDimension,
|
||||
options?.useMediaTime,
|
||||
const run = async () => {
|
||||
if (destroyed || candidates.length === 0) return null;
|
||||
const element = resolveTarget(target);
|
||||
if (!element) return null;
|
||||
previewRenderer ??= createPreviewRenderer();
|
||||
if (!previewRenderer) return null;
|
||||
return renderPreviewBatch(
|
||||
previewRenderer,
|
||||
element,
|
||||
candidates,
|
||||
options?.maxDimension,
|
||||
options?.useMediaTime,
|
||||
);
|
||||
};
|
||||
const result = previewQueue.then(run, run);
|
||||
previewQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const startPreviewPlayback = (
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
COLOR_GRADING_ADJUST_KEYS,
|
||||
COLOR_GRADING_DETAIL_KEYS,
|
||||
COLOR_GRADING_EFFECT_KEYS,
|
||||
COLOR_GRADING_HUE_CURVE_KEYS,
|
||||
COLOR_GRADING_LUT_KEYS,
|
||||
COLOR_GRADING_TOP_LEVEL_KEYS,
|
||||
COLOR_GRADING_WHEEL_KEYS,
|
||||
isColorGradingVariableRef,
|
||||
validateColorGradingContract,
|
||||
} from "./colorGradingContract";
|
||||
@@ -15,6 +17,8 @@ describe("color grading contract", () => {
|
||||
expect(COLOR_GRADING_ADJUST_KEYS).toContain("exposure");
|
||||
expect(COLOR_GRADING_DETAIL_KEYS).toContain("grain");
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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_MAX_CURVE_POINTS = 16;
|
||||
export const COLOR_GRADING_MAX_SECONDARIES = 4;
|
||||
|
||||
export const COLOR_GRADING_TOP_LEVEL_KEYS = [
|
||||
"enabled",
|
||||
"preset",
|
||||
"intensity",
|
||||
"adjust",
|
||||
"wheels",
|
||||
"curves",
|
||||
"hueCurves",
|
||||
"secondaries",
|
||||
"details",
|
||||
"effects",
|
||||
"palette",
|
||||
@@ -26,6 +32,23 @@ export const COLOR_GRADING_ADJUST_KEYS = [
|
||||
"saturation",
|
||||
] 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 = [
|
||||
"vignette",
|
||||
"vignetteMidpoint",
|
||||
@@ -176,28 +199,106 @@ function validateObject(
|
||||
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(
|
||||
value: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
limit: NumericLimit,
|
||||
issues: ColorGradingContractIssue[],
|
||||
inclusiveMax = true,
|
||||
): void {
|
||||
const candidate = value[key];
|
||||
if (candidate === undefined || isColorGradingVariableRef(candidate)) return;
|
||||
if (
|
||||
typeof candidate !== "number" ||
|
||||
!Number.isFinite(candidate) ||
|
||||
candidate < limit.min ||
|
||||
candidate > limit.max
|
||||
) {
|
||||
if (isNumberInRange(candidate, limit, inclusiveMax)) return;
|
||||
issues.push({
|
||||
path: path ? `${path}.${key}` : key,
|
||||
message: `must be a finite number from ${limit.min} ${inclusiveMax ? "through" : "up to"} ${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({
|
||||
path: path ? `${path}.${key}` : key,
|
||||
message: `must be a finite number from ${limit.min} through ${limit.max}`,
|
||||
path,
|
||||
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(
|
||||
value: Record<string, unknown>,
|
||||
path: string,
|
||||
@@ -338,6 +439,138 @@ function validateSections(
|
||||
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. */
|
||||
export function validateColorGradingContract(value: unknown): ColorGradingContractIssue[] {
|
||||
if (typeof value === "string") {
|
||||
@@ -350,6 +583,10 @@ export function validateColorGradingContract(value: unknown): ColorGradingContra
|
||||
|
||||
validateTopLevel(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);
|
||||
return issues;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user