diff --git a/packages/core/src/colorGrading.test.ts b/packages/core/src/colorGrading.test.ts index 3840acb7d..6e5178b87 100644 --- a/packages/core/src/colorGrading.test.ts +++ b/packages/core/src/colorGrading.test.ts @@ -1,6 +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, @@ -198,6 +200,37 @@ describe("color grading", () => { }); }); + 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", () => { const grading = normalizeHfColorGrading({ preset: "warm-daylight", diff --git a/packages/core/src/colorGrading.ts b/packages/core/src/colorGrading.ts index a3903f695..0d24a0037 100644 --- a/packages/core/src/colorGrading.ts +++ b/packages/core/src/colorGrading.ts @@ -675,24 +675,19 @@ const SECONDARY_SOFT_RANGE_DEFAULT: Required = { 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 = 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", @@ -1115,6 +1110,47 @@ function wrapDegrees(value: unknown, fallback = 0): number { 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( diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 318f1a18f..a5050a722 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -2764,6 +2764,7 @@ const ADVANCED_SIGNATURES = new WeakMap< signature: string; } >(); +// Identity-keyed: normalized grading objects must be replaced, never mutated. function advancedTextureSignature( curves: NormalizedHfColorGradingCurves, diff --git a/packages/parsers/src/colorGradingContract.test.ts b/packages/parsers/src/colorGradingContract.test.ts index 7625ea92b..b8412a6bf 100644 --- a/packages/parsers/src/colorGradingContract.test.ts +++ b/packages/parsers/src/colorGradingContract.test.ts @@ -26,6 +26,12 @@ describe("color grading contract", () => { 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 }, + }, }); }); diff --git a/packages/parsers/src/colorGradingContract.ts b/packages/parsers/src/colorGradingContract.ts index a5a147105..7949da17c 100644 --- a/packages/parsers/src/colorGradingContract.ts +++ b/packages/parsers/src/colorGradingContract.ts @@ -11,6 +11,12 @@ export const COLOR_GRADING_ADVANCED_LIMITS = { 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 = [ @@ -142,12 +148,8 @@ type NumericLimit = Readonly<{ min: number; max: number }>; const UNIT_LIMIT: NumericLimit = COLOR_GRADING_ADVANCED_LIMITS.unit; const SIGNED_UNIT_LIMIT: NumericLimit = COLOR_GRADING_ADVANCED_LIMITS.signedUnit; -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 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;