fix(core): align secondary mask contracts

This commit is contained in:
ukimsanov
2026-07-26 01:25:16 -07:00
parent 9a515858c9
commit e446de6023
5 changed files with 97 additions and 19 deletions
+33
View File
@@ -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",
+49 -13
View File
@@ -675,24 +675,19 @@ const SECONDARY_SOFT_RANGE_DEFAULT: Required<HfColorGradingSoftRange> = {
softness: 0.05,
};
const DETAIL_LIMITS: Record<HfColorGradingDetailKey, { min: number; max: number }> = {
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<HfColorGradingEffectKey, { min: number; max: number }>
> = {
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<NormalizedHfColorGradingWheels>(
@@ -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,
@@ -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 },
},
});
});
+8 -6
View File
@@ -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<Record<string, NumericLimit>> = {
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<Record<string, NumericLimit>> =
COLOR_GRADING_ADVANCED_LIMITS.effects;
const VARIABLE_REF = /^\$(?:\{[A-Za-z0-9_.:-]+\}|[A-Za-z0-9_.:-]+)$/;
const PALETTE_COLOR = /^#[0-9a-f]{6}$/i;