diff --git a/packages/core/src/colorGrading.test.ts b/packages/core/src/colorGrading.test.ts index ff2b7f8972..6e5178b870 100644 --- a/packages/core/src/colorGrading.test.ts +++ b/packages/core/src/colorGrading.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; +import { COLOR_GRADING_ADVANCED_LIMITS } from "@hyperframes/parsers/color-grading-contract"; +import { unitFloatToByte } from "./colorLuts"; import { + calculateHfColorGradingSecondaryMask, HF_COLOR_GRADING_COLOR_SPACE, HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS, HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS, @@ -8,6 +11,7 @@ import { HF_COLOR_GRADING_PALETTES, HF_COLOR_GRADING_PRESETS, getHfColorGradingCapabilities, + hasHfColorGradingAuthoredValues, isHfColorGradingActive, normalizeHfColorGrading, normalizeHfColorGradingWithVariables, @@ -123,6 +127,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 +163,72 @@ 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); + expect(capabilities.secondaries).toMatchObject({ + hue: { + center: { + min: COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.min, + maxExclusive: COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.max, + }, + range: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueRange, + softness: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueSoftness, + rangePlusSoftnessMax: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueCombinedMax, + }, + saturation: { + softness: COLOR_GRADING_ADVANCED_LIMITS.secondarySoftRangeSoftness, + }, + correction: { + hueShift: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueShift, + saturation: COLOR_GRADING_ADVANCED_LIMITS.signedUnit, + }, + }); + }); + + it("matches the shader's packed secondary-softness convention", () => { + for (const axis of ["saturation", "luma"] as const) { + const grading = normalizeHfColorGrading({ + secondaries: [ + { + key: { + hue: { center: 0, range: 180, softness: 0 }, + saturation: { min: axis === "saturation" ? 0.5 : 0, max: 1, softness: 0.4 }, + luma: { min: axis === "luma" ? 0.5 : 0, max: 1, softness: 0.4 }, + }, + correction: {}, + }, + ], + }); + const key = grading?.secondaries[0]?.key; + if (!key) throw new Error("Expected a normalized secondary key"); + + const packedSoftness = unitFloatToByte(key[axis].softness / 0.5); + const shaderDecodedSoftness = (packedSoftness / 255) * 0.5; + const decodedKey = { + ...key, + [axis]: { ...key[axis], softness: shaderDecodedSoftness }, + }; + const productMask = calculateHfColorGradingSecondaryMask(0, 0.2, 0.2, key); + const shaderMask = calculateHfColorGradingSecondaryMask(0, 0.2, 0.2, decodedKey); + + expect(productMask, axis).toBeGreaterThan(0); + expect(productMask, axis).toBeCloseTo(shaderMask, 2); + } }); it("merges manual adjustments over preset values", () => { @@ -617,4 +688,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); + }); }); diff --git a/packages/core/src/colorGrading.ts b/packages/core/src/colorGrading.ts index 08c84a23b9..0d24a00377 100644 --- a/packages/core/src/colorGrading.ts +++ b/packages/core/src/colorGrading.ts @@ -1,13 +1,28 @@ import { DEFAULT_MAX_CUBE_LUT_SIZE } from "./colorLuts"; +import type { HfColorCurvePoint, HfHueCurvePoint } from "./colorGradingCurves"; import { COLOR_GRADING_ADJUST_KEYS, + COLOR_GRADING_ADVANCED_LIMITS, 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 +78,80 @@ const ADJUST_ZERO = { export type HfColorGradingAdjust = Partial>; +export type HfColorGradingWheelKey = (typeof COLOR_GRADING_WHEEL_KEYS)[number]; + +export interface HfColorGradingTonalWheel { + hue?: number; + amount?: number; + level?: number; +} + +export type HfColorGradingWheels = Partial< + Record +>; + +export type NormalizedHfColorGradingWheels = Record< + HfColorGradingWheelKey, + Required +>; + +export type HfColorGradingCurveKey = (typeof COLOR_GRADING_CURVE_KEYS)[number]; +export type HfColorGradingHueCurveKey = (typeof COLOR_GRADING_HUE_CURVE_KEYS)[number]; +export type HfColorGradingCurves = Partial< + Record +>; +export type HfColorGradingHueCurves = Partial< + Record +>; +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; + saturation: Required; + luma: Required; + }; + correction: Required; +} + // Sub-controls use useful identity defaults rather than raw mathematical zeroes. export type HfColorGradingDetailKey = (typeof COLOR_GRADING_DETAIL_KEYS)[number]; @@ -159,6 +248,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 +270,10 @@ export interface NormalizedHfColorGrading { preset: HfColorGradingPresetId | string | null; intensity: number; adjust: Record; + wheels?: NormalizedHfColorGradingWheels; + curves?: NormalizedHfColorGradingCurves; + hueCurves?: NormalizedHfColorGradingHueCurves; + secondaries?: readonly NormalizedHfColorGradingSecondary[]; details: Record; effects: Record; palette: readonly string[] | null; @@ -184,6 +281,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 +397,15 @@ export type HfColorGradingVariableMap = Record; 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,25 +645,49 @@ const ADJUST_LIMITS: Record = { + hue: 0, + amount: 0, + level: 0, +}; +const WHEEL_LIMITS = { + amount: COLOR_GRADING_ADVANCED_LIMITS.unit, + level: COLOR_GRADING_ADVANCED_LIMITS.signedUnit, +}; +const RGB_CURVE_IDENTITY: readonly HfColorCurvePoint[] = [ + [0, 0], + [1, 1], +]; +const HUE_CURVE_IDENTITY: readonly HfHueCurvePoint[] = []; +const HUE_CURVE_LIMITS: Record = { + hueVsHue: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueShift, + hueVsSaturation: COLOR_GRADING_ADVANCED_LIMITS.signedUnit, + hueVsLuma: COLOR_GRADING_ADVANCED_LIMITS.signedUnit, +}; +const SECONDARY_HUE_DEFAULT: Required = { + center: 0, + range: 180, + softness: 0, +}; +const SECONDARY_SOFT_RANGE_DEFAULT: Required = { + min: 0, + max: 1, + softness: 0.05, +}; const DETAIL_LIMITS: Record = { - vignette: { min: 0, max: 1 }, - vignetteMidpoint: { min: 0, max: 1 }, - vignetteRoundness: { min: -1, max: 1 }, - vignetteFeather: { min: 0, max: 1 }, - grain: { min: 0, max: 1 }, - grainSize: { min: 0, max: 1 }, - grainRoughness: { min: 0, max: 1 }, + vignette: COLOR_GRADING_ADVANCED_LIMITS.unit, + vignetteMidpoint: COLOR_GRADING_ADVANCED_LIMITS.unit, + vignetteRoundness: COLOR_GRADING_ADVANCED_LIMITS.signedUnit, + vignetteFeather: COLOR_GRADING_ADVANCED_LIMITS.unit, + grain: COLOR_GRADING_ADVANCED_LIMITS.unit, + grainSize: COLOR_GRADING_ADVANCED_LIMITS.unit, + grainRoughness: COLOR_GRADING_ADVANCED_LIMITS.unit, }; -const UNIT_LIMIT = { min: 0, max: 1 }; +const UNIT_LIMIT = COLOR_GRADING_ADVANCED_LIMITS.unit; const EFFECT_LIMIT_OVERRIDES: Partial< Record -> = { - asciiStyle: { min: 0, max: 7 }, - bloom: { min: 0, max: 3 }, - bloomRadius: { min: 1, max: 100 }, - monoScreenShape: { min: 0, max: 4 }, -}; +> = COLOR_GRADING_ADVANCED_LIMITS.effects; export const HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS = [ "blur", @@ -813,7 +950,7 @@ const MULTIPASS_EFFECT_KEYS = new Set(["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 +972,71 @@ 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: COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.min, + maxExclusive: COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.max, + }, + 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: COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.min, + maxExclusive: COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.max, + unit: "degrees", + wrap: true, + }, + range: { ...COLOR_GRADING_ADVANCED_LIMITS.secondaryHueRange, unit: "degrees" }, + softness: { ...COLOR_GRADING_ADVANCED_LIMITS.secondaryHueSoftness, unit: "degrees" }, + rangePlusSoftnessMax: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueCombinedMax, + }, + saturation: { + min: { ...UNIT_LIMIT }, + max: { ...UNIT_LIMIT }, + relation: "min < max", + softness: { ...COLOR_GRADING_ADVANCED_LIMITS.secondarySoftRangeSoftness }, + }, + luma: { + min: { ...UNIT_LIMIT }, + max: { ...UNIT_LIMIT }, + relation: "min < max", + softness: { ...COLOR_GRADING_ADVANCED_LIMITS.secondarySoftRangeSoftness }, + }, + correction: { + hueShift: { ...COLOR_GRADING_ADVANCED_LIMITS.secondaryHueShift, unit: "degrees" }, + saturation: { ...COLOR_GRADING_ADVANCED_LIMITS.signedUnit }, + luma: { ...COLOR_GRADING_ADVANCED_LIMITS.signedUnit }, + temperature: { ...COLOR_GRADING_ADVANCED_LIMITS.signedUnit }, + tint: { ...COLOR_GRADING_ADVANCED_LIMITS.signedUnit }, + }, + }, effectFamilies: EFFECT_FAMILIES, finishing: HF_COLOR_GRADING_DETAIL_KEYS.map((key) => ({ key, @@ -902,6 +1104,234 @@ 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 smoothstep(min: number, max: number, value: number): number { + const normalized = clamp((value - min) / (max - min), 0, 1); + return normalized * normalized * (3 - 2 * normalized); +} + +function softRangeMask(value: number, min: number, max: number, softness: number): number { + if (value < min) { + return softness <= 0 ? 0 : smoothstep(min - softness, min, value); + } + if (value > max) { + return softness <= 0 ? 0 : 1 - smoothstep(max, max + softness, value); + } + return 1; +} + +/** + * Mirrors the runtime shader's HSL-secondary qualifier for Studio mattes and + * other non-WebGL verification surfaces. + */ +export function calculateHfColorGradingSecondaryMask( + hue: number, + saturation: number, + luma: number, + key: NormalizedHfColorGradingSecondary["key"], +): number { + const hueDistance = Math.abs(((((hue - key.hue.center + 540) % 360) + 360) % 360) - 180); + const hueMask = + key.hue.range < 179.999 && saturation < 0.001 + ? 0 + : hueDistance <= key.hue.range + ? 1 + : key.hue.softness <= 0 + ? 0 + : 1 - smoothstep(key.hue.range, key.hue.range + key.hue.softness, hueDistance); + return ( + hueMask * + softRangeMask(saturation, key.saturation.min, key.saturation.max, key.saturation.softness) * + softRangeMask(luma, key.luma.min, key.luma.max, key.luma.softness) + ); +} + +function normalizeWheels(value: unknown): NormalizedHfColorGradingWheels { + const wheels = isRecord(value) ? value : {}; + return HF_COLOR_GRADING_WHEEL_KEYS.reduce( + (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 completeCurveEndpoints(points: HfColorCurvePoint[]): void { + if ((points[0]?.[0] ?? 0) > 0) points.unshift([0, 0]); + if ((points.at(-1)?.[0] ?? 1) < 1) points.push([1, 1]); +} + +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; + completeCurveEndpoints(points); + 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( + (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( + (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 { + 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, + COLOR_GRADING_ADVANCED_LIMITS.secondarySoftRangeSoftness, + ), + }; +} + +function normalizeSecondaryHue(value: unknown): Required { + const hue = isRecord(value) ? value : {}; + const range = readLimitedValue( + hue.range ?? SECONDARY_HUE_DEFAULT.range, + COLOR_GRADING_ADVANCED_LIMITS.secondaryHueRange, + ); + return { + center: wrapDegrees(hue.center, SECONDARY_HUE_DEFAULT.center), + range, + softness: readLimitedValue(hue.softness ?? SECONDARY_HUE_DEFAULT.softness, { + min: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueSoftness.min, + max: COLOR_GRADING_ADVANCED_LIMITS.secondaryHueCombinedMax - range, + }), + }; +} + +function normalizeSecondaryCorrection( + value: Record, +): Required { + return { + hueShift: readLimitedValue( + value.hueShift ?? 0, + COLOR_GRADING_ADVANCED_LIMITS.secondaryHueShift, + ), + saturation: readLimitedValue(value.saturation ?? 0, COLOR_GRADING_ADVANCED_LIMITS.signedUnit), + luma: readLimitedValue(value.luma ?? 0, COLOR_GRADING_ADVANCED_LIMITS.signedUnit), + temperature: readLimitedValue(value.temperature ?? 0, COLOR_GRADING_ADVANCED_LIMITS.signedUnit), + tint: readLimitedValue(value.tint ?? 0, COLOR_GRADING_ADVANCED_LIMITS.signedUnit), + }; +} + +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 +1392,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 = {}; @@ -976,7 +1409,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 +1452,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 +1470,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 +1541,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); } diff --git a/packages/core/src/colorGradingCurves.test.ts b/packages/core/src/colorGradingCurves.test.ts new file mode 100644 index 0000000000..2445e85ef5 --- /dev/null +++ b/packages/core/src/colorGradingCurves.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/colorGradingCurves.ts b/packages/core/src/colorGradingCurves.ts new file mode 100644 index 0000000000..cbf322fb90 --- /dev/null +++ b/packages/core/src/colorGradingCurves.ts @@ -0,0 +1,196 @@ +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 valueAt(values: readonly T[], index: number): T { + const value = values[index]; + if (value === undefined) throw new RangeError("Color curve points must be contiguous"); + return value; +} + +function pointSlopes(points: readonly HfColorCurvePoint[]): number[] { + const spans: number[] = []; + const slopes: number[] = []; + let previous = valueAt(points, 0); + for (const point of points.slice(1)) { + const span = point[0] - previous[0]; + spans.push(span); + slopes.push((point[1] - previous[1]) / span); + previous = point; + } + const firstSlope = valueAt(slopes, 0); + if (points.length === 2) return [firstSlope, firstSlope]; + + const result = new Array(points.length); + result[0] = endpointSlope(valueAt(spans, 0), valueAt(spans, 1), firstSlope, valueAt(slopes, 1)); + result[result.length - 1] = endpointSlope( + valueAt(spans, spans.length - 1), + valueAt(spans, spans.length - 2), + valueAt(slopes, slopes.length - 1), + valueAt(slopes, slopes.length - 2), + ); + + for (let index = 1; index < points.length - 1; index += 1) { + const before = valueAt(slopes, index - 1); + const after = valueAt(slopes, index); + if (before === 0 || after === 0 || Math.sign(before) !== Math.sign(after)) { + result[index] = 0; + continue; + } + const beforeSpan = valueAt(spans, index - 1); + const afterSpan = valueAt(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 { + 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 > valueAt(points, segment + 1)[0]) { + segment += 1; + } + const start = valueAt(points, segment); + const end = valueAt(points, segment + 1); + const output = interpolateCurveSegment( + input, + start, + end, + valueAt(tangents, segment), + valueAt(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 { + validateCurvePoints(points, size); + 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); + const periodic = [...before, ...sorted, ...after]; + validateCurvePoints(periodic, size); + return compileCurveSamples(periodic, size, (index) => (index / size) * 360, outputMin, outputMax); +} diff --git a/packages/core/src/colorLuts.ts b/packages/core/src/colorLuts.ts index 36cf554e1f..6f5bd4cf95 100644 --- a/packages/core/src/colorLuts.ts +++ b/packages/core/src/colorLuts.ts @@ -199,7 +199,7 @@ function clampUnit(value: number): number { return Math.min(1, Math.max(0, value)); } -function toByte(value: number): number { +export function unitFloatToByte(value: number): number { return Math.round(clampUnit(value) * 255); } @@ -214,9 +214,9 @@ export function packCubeLutToRgba8(lut: CubeLut3D): PackedCubeLut2D { for (let r = 0; r < size; r++) { const lutIndex = ((b * size + g) * size + r) * 3; const pixelIndex = (g * width + b * size + r) * 4; - packed[pixelIndex] = toByte(lut.data[lutIndex] ?? 0); - packed[pixelIndex + 1] = toByte(lut.data[lutIndex + 1] ?? 0); - packed[pixelIndex + 2] = toByte(lut.data[lutIndex + 2] ?? 0); + packed[pixelIndex] = unitFloatToByte(lut.data[lutIndex] ?? 0); + packed[pixelIndex + 1] = unitFloatToByte(lut.data[lutIndex + 1] ?? 0); + packed[pixelIndex + 2] = unitFloatToByte(lut.data[lutIndex + 2] ?? 0); packed[pixelIndex + 3] = 255; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d1a10310c..b23ea509ff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -172,16 +172,20 @@ 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, getHfColorGradingCapabilities, + hasHfColorGradingAuthoredValues, isHfColorGradingActive, normalizeHfColorGrading, normalizeHfColorGradingWithVariables, @@ -202,7 +206,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"; diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index a2be6c460e..3042564b21 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -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((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 } })); diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 09bdbdc2ab..a5050a7227 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -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,14 +16,25 @@ 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, parseCubeLut, + unitFloatToByte, type CubeLut3D, type CubeLutVec3, } from "../colorLuts"; @@ -53,6 +67,8 @@ interface ProgramInfo { program: WebGLProgram; texture: WebGLTexture; lutTexture: WebGLTexture; + advancedTexture: WebGLTexture; + advancedSignature: string | null; quad: WebGLBuffer; position: number; source: WebGLUniformLocation | null; @@ -60,6 +76,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 +89,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[]; detailUniforms: readonly FloatUniformBinding[]; effectUniforms: readonly FloatUniformBinding[]; @@ -171,7 +194,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 +337,15 @@ const DEFAULT_COMPARE: RuntimeColorGradingCompareState = { }; const DEFAULT_EFFECT_PALETTE = ["#000000", "#ffffff"] as const; const DEFAULT_ART_PALETTE = ["#1a1a1a", "#f5f5dc"] as const; - -function readColorGradingAttribute(element: Element): NormalizedHfColorGrading | null { +const ADVANCED_TEXTURE_HEIGHT = 3; +const ADVANCED_CONFIG_ROW = ADVANCED_TEXTURE_HEIGHT - 1; +const ADVANCED_SECONDARY_TEXELS = 5; +const ADVANCED_TEXTURE_WIDTH_GLSL = `${HF_COLOR_CURVE_LUT_SIZE}.0`; +const ADVANCED_TEXTURE_MAX_X_GLSL = `${HF_COLOR_CURVE_LUT_SIZE - 1}.0`; +const ADVANCED_TEXTURE_HEIGHT_GLSL = `${ADVANCED_TEXTURE_HEIGHT}.0`; +const ADVANCED_CONFIG_Y_GLSL = `${(ADVANCED_CONFIG_ROW + 0.5) / ADVANCED_TEXTURE_HEIGHT}`; + +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 +372,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 +385,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 +968,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) * ${ADVANCED_TEXTURE_MAX_X_GLSL};`, + " float lower = floor(position);", + ` float upper = min(lower + 1.0, ${ADVANCED_TEXTURE_MAX_X_GLSL});`, + ` float y = (row + 0.5) / ${ADVANCED_TEXTURE_HEIGHT_GLSL};`, + ` vec4 before = texture2D(u_advanced, vec2((lower + 0.5) / ${ADVANCED_TEXTURE_WIDTH_GLSL}, y));`, + ` vec4 after = texture2D(u_advanced, vec2((upper + 0.5) / ${ADVANCED_TEXTURE_WIDTH_GLSL}, y));`, + " return mix(before, after, position - lower);", + "}", + "vec4 advancedConfig(float index){", + ` return texture2D(u_advanced, vec2((index + 0.5) / ${ADVANCED_TEXTURE_WIDTH_GLSL}, ${ADVANCED_CONFIG_Y_GLSL}));`, + "}", + "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 +1148,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 +1192,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 +1231,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 +1633,17 @@ function createFloatUniformBindings( 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 +1656,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 +1675,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 +1684,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 +1697,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 +1926,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 +2154,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 +2254,7 @@ function prepareBloomTexture( function prepareBlurAndBloomTextures( state: EffectRenderState, - grading: NormalizedHfColorGrading, + grading: ResolvedHfColorGrading, layout: { width: number; height: number }, releaseIdleTargets: boolean, ): Pick { @@ -2111,7 +2300,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 +2335,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 +2651,179 @@ 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); + let index = 0; + for (const secondary of secondaries) { + if (!secondary.enabled) continue; + writeAdvancedSecondary(data, secondary, index); + index += 1; + } + return data; +} + +const ADVANCED_SIGNATURES = new WeakMap< + NormalizedHfColorGradingCurves, + { + hueCurves: NormalizedHfColorGradingHueCurves; + secondaries: readonly NormalizedHfColorGradingSecondary[]; + signature: string; + } +>(); +// Identity-keyed: normalized grading objects must be replaced, never mutated. + +function advancedTextureSignature( + curves: NormalizedHfColorGradingCurves, + hueCurves: NormalizedHfColorGradingHueCurves, + secondaries: readonly NormalizedHfColorGradingSecondary[], +): string { + const cached = ADVANCED_SIGNATURES.get(curves); + if (cached?.hueCurves === hueCurves && cached.secondaries === secondaries) { + return cached.signature; + } + const signature = JSON.stringify([curves, hueCurves, secondaries]); + ADVANCED_SIGNATURES.set(curves, { hueCurves, secondaries, signature }); + return signature; +} + +function ensureAdvancedTexture( + gl: WebGLRenderingContext, + program: ProgramInfo, + grading: ResolvedHfColorGrading, + secondaries: readonly NormalizedHfColorGradingSecondary[], +): void { + const { curves, hueCurves } = grading; + const signature = advancedTextureSignature(curves, hueCurves, secondaries); + if (program.advancedSignature === signature) return; + + const pixels = Uint8Array.from( + buildAdvancedTextureData(curves, hueCurves, secondaries), + unitFloatToByte, + ); + 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 +2839,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 +2862,21 @@ function applyUniforms( lut?.domainMax[2] ?? 1, ); gl.uniform1f(program.lutIntensity, grading.lut?.intensity ?? 0); + const { curves, hueCurves, secondaries } = grading; + const rgbCurvesEnabled = hasHfColorGradingRgbCurveValues(curves); + const hueCurvesEnabled = hasHfColorGradingHueCurveValues(hueCurves); + const secondaryCount = hasHfColorGradingSecondaryValues(secondaries) + ? secondaries.reduce((count, secondary) => count + Number(secondary.enabled), 0) + : 0; + if (rgbCurvesEnabled || hueCurvesEnabled || secondaryCount > 0) { + ensureAdvancedTexture(gl, program, grading, secondaries); + } + 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 +2972,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 +2983,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 +2995,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 +3039,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 +3267,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 +3464,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 +3723,26 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi { candidates: readonly RuntimeColorGradingPreviewCandidate[], options?: { maxDimension?: number; useMediaTime?: boolean }, ): Promise => { - 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 = ( diff --git a/packages/parsers/src/colorGradingContract.test.ts b/packages/parsers/src/colorGradingContract.test.ts index 95c84e7676..b8412a6bf1 100644 --- a/packages/parsers/src/colorGradingContract.test.ts +++ b/packages/parsers/src/colorGradingContract.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { COLOR_GRADING_ADJUST_KEYS, + COLOR_GRADING_ADVANCED_LIMITS, 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,7 +18,21 @@ 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"]); + expect(COLOR_GRADING_ADVANCED_LIMITS).toMatchObject({ + hueDegrees: { min: 0, max: 360, inclusiveMax: false }, + secondaryHueRange: { min: 0, max: 180 }, + secondarySoftRangeSoftness: { min: 0, max: 0.5 }, + secondaryHueShift: { min: -180, max: 180 }, + effects: { + asciiStyle: { min: 0, max: 7 }, + bloom: { min: 0, max: 3 }, + bloomRadius: { min: 1, max: 100 }, + monoScreenShape: { min: 0, max: 4 }, + }, + }); }); it("accepts the complete current contract and variable references", () => { @@ -60,4 +77,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([]); + }); }); diff --git a/packages/parsers/src/colorGradingContract.ts b/packages/parsers/src/colorGradingContract.ts index cb2fc0b95a..7949da17cc 100644 --- a/packages/parsers/src/colorGradingContract.ts +++ b/packages/parsers/src/colorGradingContract.ts @@ -1,11 +1,33 @@ -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_ADVANCED_LIMITS = { + hueDegrees: { min: 0, max: 360, inclusiveMax: false }, + unit: { min: 0, max: 1 }, + signedUnit: { min: -1, max: 1 }, + secondaryHueRange: { min: 0, max: 180 }, + secondaryHueSoftness: { min: 0, max: 180 }, + secondaryHueCombinedMax: 180, + secondarySoftRangeSoftness: { min: 0, max: 0.5 }, + secondaryHueShift: { min: -180, max: 180 }, + effects: { + asciiStyle: { min: 0, max: 7 }, + bloom: { min: 0, max: 3 }, + bloomRadius: { min: 1, max: 100 }, + monoScreenShape: { min: 0, max: 4 }, + }, +} as const; export const COLOR_GRADING_TOP_LEVEL_KEYS = [ "enabled", "preset", "intensity", "adjust", + "wheels", + "curves", + "hueCurves", + "secondaries", "details", "effects", "palette", @@ -26,6 +48,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", @@ -107,14 +146,10 @@ export const COLOR_GRADING_LUT_KEYS = ["src", "intensity"] as const; type NumericLimit = Readonly<{ min: number; max: number }>; -const UNIT_LIMIT: NumericLimit = { min: 0, max: 1 }; -const SIGNED_UNIT_LIMIT: NumericLimit = { min: -1, max: 1 }; -const EFFECT_LIMIT_OVERRIDES: Readonly> = { - asciiStyle: { min: 0, max: 7 }, - bloom: { min: 0, max: 3 }, - bloomRadius: { min: 1, max: 100 }, - monoScreenShape: { min: 0, max: 4 }, -}; +const UNIT_LIMIT: NumericLimit = COLOR_GRADING_ADVANCED_LIMITS.unit; +const SIGNED_UNIT_LIMIT: NumericLimit = COLOR_GRADING_ADVANCED_LIMITS.signedUnit; +const EFFECT_LIMIT_OVERRIDES: Readonly> = + COLOR_GRADING_ADVANCED_LIMITS.effects; const VARIABLE_REF = /^\$(?:\{[A-Za-z0-9_.:-]+\}|[A-Za-z0-9_.:-]+)$/; const PALETTE_COLOR = /^#[0-9a-f]{6}$/i; @@ -176,26 +211,104 @@ 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, 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, + message: `must contain 2 to ${COLOR_GRADING_MAX_CURVE_POINTS} [input, output] points`, + }); + return; + } + + const inputs = new Set(); + 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: path ? `${path}.${key}` : key, - message: `must be a finite number from ${limit.min} through ${limit.max}`, + path, + message: `must contain 3 to ${COLOR_GRADING_MAX_CURVE_POINTS} [hueDegrees, delta] points`, }); + return; } + + const hues = new Set(); + 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( @@ -338,6 +451,173 @@ 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, + COLOR_GRADING_ADVANCED_LIMITS.hueDegrees, + issues, + COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.inclusiveMax, + ); + 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, + COLOR_GRADING_ADVANCED_LIMITS.secondarySoftRangeSoftness, + 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, + COLOR_GRADING_ADVANCED_LIMITS.hueDegrees, + issues, + COLOR_GRADING_ADVANCED_LIMITS.hueDegrees.inclusiveMax, + ); + validateNumericField(hue, "range", path, COLOR_GRADING_ADVANCED_LIMITS.secondaryHueRange, issues); + validateNumericField( + hue, + "softness", + path, + COLOR_GRADING_ADVANCED_LIMITS.secondaryHueSoftness, + issues, + ); + if ( + typeof hue.range === "number" && + typeof hue.softness === "number" && + hue.range + hue.softness > COLOR_GRADING_ADVANCED_LIMITS.secondaryHueCombinedMax + ) { + issues.push({ + path, + message: `range plus softness must not exceed ${COLOR_GRADING_ADVANCED_LIMITS.secondaryHueCombinedMax} 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, + COLOR_GRADING_ADVANCED_LIMITS.secondaryHueShift, + 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 +630,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; }