mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
fix(studio): align professional grading contracts
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user