fix(studio): align professional grading contracts

This commit is contained in:
ukimsanov
2026-07-26 01:47:06 -07:00
parent c1fcf7534f
commit 794930a075
10 changed files with 229 additions and 117 deletions
@@ -65,18 +65,20 @@ describe("colorGradingFrameAnalysis", () => {
expect(sampled?.saturation.max).toBeCloseTo(0.2);
});
it("matches the shader's half-softness edge convention", () => {
const key = secondaryKey({
it("matches the runtime softness on saturation and luma feather edges", () => {
const saturationKey = secondaryKey({
hue: { center: 0, range: 180, softness: 0 },
saturation: { min: 0.5, max: 1, softness: 0.4 },
luma: { min: 0, max: 1, softness: 0 },
});
const lumaKey = secondaryKey({
hue: { center: 0, range: 180, softness: 0 },
saturation: { min: 0, max: 1, softness: 0 },
luma: { min: 0.5, max: 1, softness: 0.4 },
});
const belowOuterEdge = colorGradingSecondaryMask(77, 77, 77, key);
const middleOfFeather = colorGradingSecondaryMask(112, 77, 77, key);
expect(belowOuterEdge).toBe(0);
expect(middleOfFeather).toBeGreaterThan(0);
expect(middleOfFeather).toBeLessThan(1);
expect(colorGradingSecondaryMask(255, 204, 204, saturationKey)).toBeCloseTo(0.15625, 5);
expect(colorGradingSecondaryMask(51, 51, 51, lumaKey)).toBeCloseTo(0.15625, 5);
});
it("builds an alpha-preserving black-and-white selection matte", () => {
@@ -1,4 +1,7 @@
import type { NormalizedHfColorGradingSecondary } from "@hyperframes/core/color-grading";
import {
calculateHfColorGradingSecondaryMask,
type NormalizedHfColorGradingSecondary,
} from "@hyperframes/core/color-grading";
import { clampNumber } from "../../utils/studioHelpers";
const BYTE_MAX = 255;
@@ -53,20 +56,6 @@ function rgbToHsv(red: number, green: number, blue: number) {
};
}
function softRangeMask(value: number, min: number, max: number, softness: number): number {
if (value < min) {
if (softness <= 0 || value <= min - softness) return 0;
const t = (value - (min - softness)) / softness;
return t * t * (3 - 2 * t);
}
if (value > max) {
if (softness <= 0 || value >= max + softness) return 0;
const t = (value - max) / softness;
return 1 - t * t * (3 - 2 * t);
}
return 1;
}
export function colorGradingSecondaryMask(
red: number,
green: number,
@@ -74,32 +63,11 @@ export function colorGradingSecondaryMask(
key: NormalizedHfColorGradingSecondary["key"],
): number {
const hsv = rgbToHsv(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX);
const hueDistance = Math.abs(((((hsv.hue - key.hue.center + 540) % 360) + 360) % 360) - 180);
const hueMask =
key.hue.range >= 179.999
? 1
: hsv.saturation < 0.001
? 0
: softRangeMask(
hueDistance,
0,
key.hue.range,
Math.min(key.hue.softness, 180 - key.hue.range),
);
return (
hueMask *
softRangeMask(
hsv.saturation,
key.saturation.min,
key.saturation.max,
key.saturation.softness * 0.5,
) *
softRangeMask(
rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX),
key.luma.min,
key.luma.max,
key.luma.softness * 0.5,
)
return calculateHfColorGradingSecondaryMask(
hsv.hue,
hsv.saturation,
rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX),
key,
);
}
@@ -131,4 +131,28 @@ describe("ColorCurves", () => {
expect(onCommit).toHaveBeenCalledOnce();
act(() => root.unmount());
});
it("does not restore a deleted point when an overlapping key gesture settles", () => {
const { host, root, onCommit } = renderCurves();
const graph = activate(host, "master");
act(() => {
graph.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onCommit.mock.calls.at(-1)?.[0]?.curves.master).toHaveLength(3);
onCommit.mockClear();
act(() => {
graph.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
act(() => {
graph.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete", bubbles: true }));
});
act(() => {
graph.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowDown", bubbles: true }));
});
expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit.mock.calls[0]?.[0]?.curves.master).toHaveLength(2);
act(() => root.unmount());
});
});
@@ -56,6 +56,7 @@ export function ColorCurves({
const deleteSelected = () => {
if (selectedIndex === null) return;
if (tab.kind === "rgb" && (selectedIndex === 0 || selectedIndex === points.length - 1)) return;
transaction.cancel();
const nextPoints =
tab.kind === "hue" && points.length <= 3
? []
@@ -117,6 +117,24 @@ describe("PropertyPanelColorSecondary", () => {
act(() => root.unmount());
});
it("preserves legal fractional hue centers at the wrap boundary", () => {
const grading = normalizeHfColorGrading({
secondaries: [
{
key: { hue: { center: 359.7, range: 20 } },
correction: {},
},
],
});
if (!grading?.secondaries) throw new Error("Expected normalized secondaries");
const { host, root } = renderSecondary({ secondaries: grading.secondaries });
const hue = host.querySelector<HTMLElement>('[role="slider"][aria-label="Hue"]');
expect(Number(hue?.getAttribute("aria-valuenow"))).toBeCloseTo(359.7);
expect(hue?.getAttribute("aria-valuemax")).toBe("359.99");
act(() => root.unmount());
});
it("shows a useful error when frame capture is unavailable", async () => {
const { host, root, onCommit } = renderSecondary({
captureFrame: vi.fn().mockRejectedValue(new Error("capture unavailable")),
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { COLOR_GRADING_MAX_SECONDARIES } from "@hyperframes/parsers/color-grading-contract";
import {
getHfColorGradingCapabilities,
normalizeHfColorGrading,
type NormalizedHfColorGradingSecondary,
} from "@hyperframes/core/color-grading";
@@ -16,6 +16,25 @@ import {
type Secondaries = readonly NormalizedHfColorGradingSecondary[];
const SECONDARY_CAPABILITIES = getHfColorGradingCapabilities().secondaries;
const PERCENT_SCALE = 100;
const RANGE_GAP = 0.01;
const HUE_CENTER_MAX = SECONDARY_CAPABILITIES.hue.center.maxExclusive - RANGE_GAP;
function wrapHueCenter(value: number): number {
const { min, maxExclusive } = SECONDARY_CAPABILITIES.hue.center;
const span = maxExclusive - min;
return ((((value - min) % span) + span) % span) + min;
}
const CORRECTION_CONTROLS = [
["hueShift", "Hue shift", 1, "°"],
["saturation", "Saturation", PERCENT_SCALE, "%"],
["luma", "Luma", PERCENT_SCALE, "%"],
["temperature", "Warmth", PERCENT_SCALE, "%"],
["tint", "Tint", PERCENT_SCALE, "%"],
] as const;
function defaultSecondary(): NormalizedHfColorGradingSecondary {
const secondary = normalizeHfColorGrading({
secondaries: [{ key: {}, correction: {} }],
@@ -84,7 +103,7 @@ export function PropertyPanelColorSecondary({
onCommit(secondaries.map((secondary, index) => (index === activeIndex ? next : secondary)));
};
const addSecondary = () => {
if (secondaries.length >= COLOR_GRADING_MAX_SECONDARIES) return;
if (secondaries.length >= SECONDARY_CAPABILITIES.max) return;
onCommit([...secondaries, DEFAULT_SECONDARY]);
setSelectedIndex(secondaries.length);
};
@@ -128,7 +147,7 @@ export function PropertyPanelColorSecondary({
type="button"
aria-label="Add secondary color selection"
title="Add secondary color selection"
disabled={secondaries.length >= COLOR_GRADING_MAX_SECONDARIES}
disabled={secondaries.length >= SECONDARY_CAPABILITIES.max}
onClick={addSecondary}
className="text-panel-text-3 hover:text-panel-text-1 disabled:opacity-35"
>
@@ -261,22 +280,26 @@ export function PropertyPanelColorSecondary({
<FlatSlider
label="Hue"
value={selected.key.hue.center}
min={0}
max={359}
min={SECONDARY_CAPABILITIES.hue.center.min}
max={HUE_CENTER_MAX}
step={0.1}
tier="explicitCustom"
displayValue={`${Math.round(selected.key.hue.center)}°`}
onCommit={(center) =>
replaceSelected({
...selected,
key: { ...selected.key, hue: { ...selected.key.hue, center } },
key: {
...selected.key,
hue: { ...selected.key.hue, center: wrapHueCenter(center) },
},
})
}
/>
<FlatSlider
label="Hue range"
value={selected.key.hue.range}
min={0}
max={180}
min={SECONDARY_CAPABILITIES.hue.range.min}
max={SECONDARY_CAPABILITIES.hue.range.max}
tier="explicitCustom"
displayValue={`${Math.round(selected.key.hue.range)}°`}
onCommit={(range) =>
@@ -287,7 +310,10 @@ export function PropertyPanelColorSecondary({
hue: {
...selected.key.hue,
range,
softness: Math.min(selected.key.hue.softness, 180 - range),
softness: Math.min(
selected.key.hue.softness,
SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - range,
),
},
},
})
@@ -296,8 +322,11 @@ export function PropertyPanelColorSecondary({
<FlatSlider
label="Hue softness"
value={selected.key.hue.softness}
min={0}
max={180 - selected.key.hue.range}
min={SECONDARY_CAPABILITIES.hue.softness.min}
max={Math.min(
SECONDARY_CAPABILITIES.hue.softness.max,
SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - selected.key.hue.range,
)}
tier="explicitCustom"
displayValue={`${Math.round(selected.key.hue.softness)}°`}
onCommit={(softness) =>
@@ -310,13 +339,14 @@ export function PropertyPanelColorSecondary({
{(["saturation", "luma"] as const).flatMap((key) => {
const label = key === "saturation" ? "Saturation" : "Luma";
const range = selected.key[key];
const capability = SECONDARY_CAPABILITIES[key];
return [
<FlatSlider
key={`${key}-min`}
label={`${label} min`}
value={range.min * 100}
min={0}
max={100}
value={range.min * PERCENT_SCALE}
min={capability.min.min * PERCENT_SCALE}
max={capability.min.max * PERCENT_SCALE}
tier="explicitCustom"
displayValue={percent(range.min)}
onCommit={(value) =>
@@ -326,7 +356,10 @@ export function PropertyPanelColorSecondary({
...selected.key,
[key]: {
...range,
min: Math.max(0, Math.min(value / 100, range.max - 0.01)),
min: Math.max(
capability.min.min,
Math.min(value / PERCENT_SCALE, range.max - RANGE_GAP),
),
},
},
})
@@ -335,9 +368,9 @@ export function PropertyPanelColorSecondary({
<FlatSlider
key={`${key}-max`}
label={`${label} max`}
value={range.max * 100}
min={0}
max={100}
value={range.max * PERCENT_SCALE}
min={capability.max.min * PERCENT_SCALE}
max={capability.max.max * PERCENT_SCALE}
tier="explicitCustom"
displayValue={percent(range.max)}
onCommit={(value) =>
@@ -347,7 +380,10 @@ export function PropertyPanelColorSecondary({
...selected.key,
[key]: {
...range,
max: Math.min(1, Math.max(value / 100, range.min + 0.01)),
max: Math.min(
capability.max.max,
Math.max(value / PERCENT_SCALE, range.min + RANGE_GAP),
),
},
},
})
@@ -356,15 +392,18 @@ export function PropertyPanelColorSecondary({
<FlatSlider
key={`${key}-softness`}
label={`${label} softness`}
value={range.softness * 100}
min={0}
max={50}
value={range.softness * PERCENT_SCALE}
min={capability.softness.min * PERCENT_SCALE}
max={capability.softness.max * PERCENT_SCALE}
tier="explicitCustom"
displayValue={percent(range.softness)}
onCommit={(value) =>
replaceSelected({
...selected,
key: { ...selected.key, [key]: { ...range, softness: value / 100 } },
key: {
...selected.key,
[key]: { ...range, softness: value / PERCENT_SCALE },
},
})
}
/>,
@@ -374,29 +413,19 @@ export function PropertyPanelColorSecondary({
<div className="pt-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Correction
</div>
{(
[
["hueShift", "Hue shift", -180, 180, "degree"],
["saturation", "Saturation", -100, 100, "percent"],
["luma", "Luma", -100, 100, "percent"],
["temperature", "Warmth", -100, 100, "percent"],
["tint", "Tint", -100, 100, "percent"],
] as const
).map(([key, label, min, max, format]) => {
const scale = format === "degree" ? 1 : 100;
{CORRECTION_CONTROLS.map(([key, label, scale, suffix]) => {
const limit = SECONDARY_CAPABILITIES.correction[key];
const value = selected.correction[key] * scale;
return (
<FlatSlider
key={key}
label={label}
value={value}
min={min}
max={max}
min={limit.min * scale}
max={limit.max * scale}
centerTick
tier={Math.abs(value) > 0.0001 ? "explicitCustom" : "default"}
displayValue={
format === "degree" ? `${Math.round(value)}°` : `${Math.round(value)}%`
}
displayValue={`${Math.round(value)}${suffix}`}
onCommit={(next) =>
replaceSelected({
...selected,
@@ -1,7 +1,8 @@
import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type {
HfColorGradingWheelKey,
NormalizedHfColorGradingWheels,
import {
getHfColorGradingCapabilities,
type HfColorGradingWheelKey,
type NormalizedHfColorGradingWheels,
} from "@hyperframes/core/color-grading";
import { RotateCcw } from "../../icons/SystemIcons";
import { clampNumber } from "../../utils/studioHelpers";
@@ -15,10 +16,19 @@ const WHEELS: ReadonlyArray<{ key: HfColorGradingWheelKey; label: string }> = [
];
type NormalizedTonalWheel = NormalizedHfColorGradingWheels[HfColorGradingWheelKey];
const RESET_WHEEL: NormalizedTonalWheel = { hue: 0, amount: 0, level: 0 };
const WHEEL_CONTROLS = getHfColorGradingCapabilities().wheels.controls;
const PERCENT_SCALE = 100;
const HUE_MAX = WHEEL_CONTROLS.hue.maxExclusive - 0.01;
const RESET_WHEEL: NormalizedTonalWheel = {
hue: WHEEL_CONTROLS.hue.identity,
amount: WHEEL_CONTROLS.amount.identity,
level: WHEEL_CONTROLS.level.identity,
};
function wrapHue(value: number): number {
return ((value % 360) + 360) % 360;
const { min, maxExclusive } = WHEEL_CONTROLS.hue;
const span = maxExclusive - min;
return ((((value - min) % span) + span) % span) + min;
}
function formatNumberInput(value: number, decimals: number): string {
@@ -39,7 +49,7 @@ function wheelFromPointer(
return {
...wheel,
hue: wrapHue((Math.atan2(y, x) * 180) / Math.PI),
amount: clampNumber(Math.hypot(x, y), 0, 1),
amount: clampNumber(Math.hypot(x, y), WHEEL_CONTROLS.amount.min, WHEEL_CONTROLS.amount.max),
};
}
@@ -64,13 +74,27 @@ function wheelFromKey(
case "ArrowRight":
return { ...wheel, hue: wrapHue(wheel.hue + hueStep) };
case "ArrowDown":
return { ...wheel, amount: clampNumber(wheel.amount - amountStep, 0, 1) };
return {
...wheel,
amount: clampNumber(
wheel.amount - amountStep,
WHEEL_CONTROLS.amount.min,
WHEEL_CONTROLS.amount.max,
),
};
case "ArrowUp":
return { ...wheel, amount: clampNumber(wheel.amount + amountStep, 0, 1) };
return {
...wheel,
amount: clampNumber(
wheel.amount + amountStep,
WHEEL_CONTROLS.amount.min,
WHEEL_CONTROLS.amount.max,
),
};
case "Home":
return { ...wheel, amount: 0 };
return { ...wheel, amount: WHEEL_CONTROLS.amount.min };
case "End":
return { ...wheel, amount: 1 };
return { ...wheel, amount: WHEEL_CONTROLS.amount.max };
default:
return null;
}
@@ -136,10 +160,12 @@ function TonalWheel({
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`${label} color`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(wheel.amount * 100)}
aria-valuetext={`${Math.round(wheel.hue)} degrees, ${Math.round(wheel.amount * 100)} percent`}
aria-valuemin={WHEEL_CONTROLS.amount.min * PERCENT_SCALE}
aria-valuemax={WHEEL_CONTROLS.amount.max * PERCENT_SCALE}
aria-valuenow={Math.round(wheel.amount * PERCENT_SCALE)}
aria-valuetext={`${Math.round(wheel.hue)} degrees, ${Math.round(
wheel.amount * PERCENT_SCALE,
)} percent`}
aria-disabled={disabled}
data-color-wheel-surface="true"
onDoubleClick={onReset}
@@ -193,8 +219,8 @@ function TonalWheel({
<input
type="range"
aria-label={`${label} level`}
min={-1}
max={1}
min={WHEEL_CONTROLS.level.min}
max={WHEEL_CONTROLS.level.max}
step={0.01}
value={wheel.level}
disabled={disabled}
@@ -218,39 +244,39 @@ function TonalWheel({
<GradingNumberField
label="Hue"
value={wheel.hue}
min={0}
max={359.99}
min={WHEEL_CONTROLS.hue.min}
max={HUE_MAX}
disabled={disabled}
formatValue={formatIntegerInput}
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
onBegin={onBegin}
onPreview={(hue) => onPreview({ ...wheel, hue })}
onPreview={(hue) => onPreview({ ...wheel, hue: wrapHue(hue) })}
onSettle={onSettle}
onCancel={onCancel}
/>
<GradingNumberField
label="Amount"
value={wheel.amount * 100}
min={0}
max={100}
value={wheel.amount * PERCENT_SCALE}
min={WHEEL_CONTROLS.amount.min * PERCENT_SCALE}
max={WHEEL_CONTROLS.amount.max * PERCENT_SCALE}
disabled={disabled}
formatValue={formatIntegerInput}
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
onBegin={onBegin}
onPreview={(amount) => onPreview({ ...wheel, amount: amount / 100 })}
onPreview={(amount) => onPreview({ ...wheel, amount: amount / PERCENT_SCALE })}
onSettle={onSettle}
onCancel={onCancel}
/>
<GradingNumberField
label="Level"
value={wheel.level * 100}
min={-100}
max={100}
value={wheel.level * PERCENT_SCALE}
min={WHEEL_CONTROLS.level.min * PERCENT_SCALE}
max={WHEEL_CONTROLS.level.max * PERCENT_SCALE}
disabled={disabled}
formatValue={formatIntegerInput}
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
onBegin={onBegin}
onPreview={(level) => onPreview({ ...wheel, level: level / 100 })}
onPreview={(level) => onPreview({ ...wheel, level: level / PERCENT_SCALE })}
onSettle={onSettle}
onCancel={onCancel}
/>
@@ -159,6 +159,7 @@ export function FlatColorGradingSection({
],
);
const actions = createColorGradingActions(grading, onCommitColorGrading);
const scopesRefreshKey = useMemo(() => serializeHfColorGrading(grading), [grading]);
useEffect(() => {
if (presetPreviews.status === "idle") onRequestPresetPreviews();
@@ -206,7 +207,7 @@ export function FlatColorGradingSection({
<HdrBanner metadata={mediaMetadata} />
<PropertyPanelColorScopes
captureFrame={() => captureGradedFrame()}
refreshKey={serializeHfColorGrading(grading)}
refreshKey={scopesRefreshKey}
/>
<div data-flat-grade-presets="true" className="space-y-1.5">
{presetPreviews.status === "unavailable" && (
@@ -56,22 +56,30 @@ function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelectio
} as DomEditSelection;
}
type ApplyScope = (
scope: "source-file" | "project",
value: string | null,
) => Promise<{ changedFiles: number; changedElements: number }>;
function HookHost({
onState,
onSetAttributeLive,
element,
previewIframeRef,
onApplyScope,
}: {
onState: (state: ReturnType<typeof useColorGradingController>) => void;
onSetAttributeLive: (attr: string, value: string | null) => void;
element: DomEditSelection;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
onApplyScope?: ApplyScope;
}) {
const state = useColorGradingController({
projectId: "proj",
element,
previewIframeRef,
onSetAttributeLive,
onApplyScope,
});
onState(state);
return null;
@@ -81,6 +89,7 @@ function renderHook(
onSetAttributeLive: (attr: string, value: string | null) => void,
initialElement: DomEditSelection = makeElement(),
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
onApplyScope?: ApplyScope,
) {
const host = document.createElement("div");
document.body.append(host);
@@ -94,6 +103,7 @@ function renderHook(
onSetAttributeLive,
element,
previewIframeRef,
onApplyScope,
}),
);
});
@@ -269,6 +279,36 @@ describe("useColorGradingController", () => {
vi.useRealTimers();
});
it("copies a disabled authored secondary to a broader scope", async () => {
const onApplyScope = vi.fn<ApplyScope>().mockResolvedValue({
changedFiles: 1,
changedElements: 1,
});
const grading = {
secondaries: [
{
enabled: false,
key: { hue: { center: 215, range: 25 } },
correction: { saturation: 0.15 },
},
],
};
const { root, getState } = renderHook(
vi.fn(),
makeElement({ dataAttributes: { "color-grading": JSON.stringify(grading) } }),
undefined,
onApplyScope,
);
await act(async () => getState().applyToScope());
expect(onApplyScope).toHaveBeenCalledWith(
"source-file",
expect.stringContaining('"enabled":false'),
);
act(() => root.unmount());
});
it("requests exact effect families and retains earlier family images", async () => {
vi.useFakeTimers();
const { contentWindow, iframe } = createPreviewFrame();
@@ -566,7 +606,7 @@ describe("useColorGradingController", () => {
vi.useRealTimers();
});
it("resetGrading resets Grade fields without clearing Effects or Palette", () => {
it("resetGrading resets Grade fields without clearing LUT, Effects, or Palette", () => {
const { root, getState } = renderHook(vi.fn());
const grading = normalizeHfColorGrading({
preset: "bright-pop",
@@ -583,7 +623,7 @@ describe("useColorGradingController", () => {
});
expect(getState().grading).toMatchObject({
preset: "neutral",
lut: null,
lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
effects: { pixelate: 0.5 },
palette: ["#112233", "#ffffff"],
});
@@ -524,7 +524,9 @@ export function useColorGradingController({
if (!onApplyScope || applyBusy) return;
setApplyBusy(true);
try {
const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null;
const value = hasHfColorGradingAuthoredValues(grading)
? serializeHfColorGrading(grading)
: null;
await onApplyScope(applyScope, value);
} finally {
setApplyBusy(false);
@@ -552,6 +554,7 @@ export function useColorGradingController({
const neutral = defaultColorGrading();
commitColorGrading({
...neutral,
lut: latestGradingRef.current.lut,
effects: latestGradingRef.current.effects,
palette: latestGradingRef.current.palette,
});