Merge pull request #2797 from heygen-com/feat/studio-professional-color-grading

feat(studio): add professional grading controls
This commit is contained in:
Ular Kimsanov
2026-07-26 10:45:43 -07:00
committed by GitHub
24 changed files with 3747 additions and 356 deletions
@@ -427,6 +427,7 @@ export function PropertyPanelFlat({
mediaMetadata={colorGradingController.mediaMetadata}
presetPreviews={colorGradingController.presetPreviews}
onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
captureGradedFrame={colorGradingController.captureGradedFrame}
/>
),
});
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
normalizeHfColorGrading,
type HfColorGradingSecondary,
} from "@hyperframes/core/color-grading";
import {
analyzeColorGradingFrame,
buildColorGradingSecondaryMatte,
colorGradingSecondaryMask,
sampleColorGradingSecondary,
} from "./colorGradingFrameAnalysis";
function pixels(...rgba: number[]): Uint8ClampedArray {
return new Uint8ClampedArray(rgba);
}
function secondaryKey(key: HfColorGradingSecondary["key"]) {
const secondary = normalizeHfColorGrading({
secondaries: [{ key, correction: {} }],
})?.secondaries?.[0];
if (!secondary) throw new Error("Expected a normalized secondary");
return secondary.key;
}
describe("colorGradingFrameAnalysis", () => {
it("places black and white at the Rec.709 scope endpoints", () => {
const result = analyzeColorGradingFrame(pixels(0, 0, 0, 255, 255, 255, 255, 255), 2, 1);
expect(result.histogram[0]).toBe(255);
expect(result.histogram[255]).toBe(255);
expect(result.waveform[255]).toBe(255);
expect(result.waveform[256]).toBe(255);
expect(result.parade.reduce((sum, value) => sum + value, 0)).toBe(6 * 255);
});
it("alpha-weights all scopes and excludes transparent pixels", () => {
const result = analyzeColorGradingFrame(pixels(0, 0, 0, 0, 255, 255, 255, 64), 2, 1);
expect(result.histogram.reduce((sum, value) => sum + value, 0)).toBe(64);
expect(result.waveform.reduce((sum, value) => sum + value, 0)).toBe(64);
expect(result.parade.reduce((sum, value) => sum + value, 0)).toBe(3 * 64);
expect(result.vectorscope.reduce((sum, value) => sum + value, 0)).toBe(64);
});
it("averages hue circularly when an eyedropper sample crosses red", () => {
const sampled = sampleColorGradingSecondary(
pixels(255, 0, 10, 255, 255, 10, 0, 255),
2,
1,
0.5,
0,
1,
);
expect(sampled).not.toBeNull();
if (!sampled) throw new Error("Expected a sample");
expect(Math.min(sampled.hue.center, 360 - sampled.hue.center)).toBeLessThan(3);
expect(sampled.hue.range).toBe(18);
});
it("does not invent a hue qualifier for a neutral sample", () => {
const sampled = sampleColorGradingSecondary(pixels(128, 128, 128, 255), 1, 1, 0, 0);
expect(sampled?.hue).toEqual({ center: 0, range: 180, softness: 0 });
expect(sampled?.saturation.max).toBeCloseTo(0.2);
});
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 },
});
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", () => {
const key = secondaryKey({
hue: { center: 0, range: 15, softness: 0 },
saturation: { min: 0.5, max: 1, softness: 0 },
luma: { min: 0, max: 1, softness: 0 },
});
const matte = buildColorGradingSecondaryMatte(
pixels(255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 0, 0),
3,
1,
key,
);
expect([...matte.slice(0, 4)]).toEqual([255, 255, 255, 255]);
expect([...matte.slice(4, 8)]).toEqual([0, 0, 0, 128]);
expect([...matte.slice(8, 12)]).toEqual([0, 0, 0, 0]);
});
});
@@ -0,0 +1,203 @@
import {
calculateHfColorGradingSecondaryMask,
type NormalizedHfColorGradingSecondary,
} from "@hyperframes/core/color-grading";
import { clampNumber } from "../../utils/studioHelpers";
const BYTE_MAX = 255;
const SCOPE_SIZE = 256;
export type ColorGradingScopeMode = "histogram" | "waveform" | "parade" | "vectorscope";
export interface ColorGradingScopeAnalysis {
width: number;
histogram: Uint32Array;
waveform: Uint32Array;
parade: Uint32Array;
vectorscope: Uint32Array;
}
export async function readColorGradingFramePixels(frame: {
dataUrl: string;
width: number;
height: number;
}): Promise<Uint8ClampedArray | null> {
const image = new Image();
image.src = frame.dataUrl;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = frame.width;
canvas.height = frame.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return null;
context.drawImage(image, 0, 0, frame.width, frame.height);
return context.getImageData(0, 0, frame.width, frame.height).data;
}
function rec709Luma(red: number, green: number, blue: number): number {
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
}
function rgbToHsv(red: number, green: number, blue: number) {
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const chroma = max - min;
let hue = 0;
if (chroma > 0) {
if (max === red) hue = ((green - blue) / chroma) % 6;
else if (max === green) hue = (blue - red) / chroma + 2;
else hue = (red - green) / chroma + 4;
hue = (hue * 60 + 360) % 360;
}
return {
hue,
saturation: max === 0 ? 0 : chroma / max,
value: max,
};
}
export function colorGradingSecondaryMask(
red: number,
green: number,
blue: number,
key: NormalizedHfColorGradingSecondary["key"],
): number {
const hsv = rgbToHsv(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX);
return calculateHfColorGradingSecondaryMask(
hsv.hue,
hsv.saturation,
rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX),
key,
);
}
export function buildColorGradingSecondaryMatte(
pixels: Uint8ClampedArray,
width: number,
height: number,
key: NormalizedHfColorGradingSecondary["key"],
): Uint8ClampedArray {
const matte = new Uint8ClampedArray(width * height * 4);
for (let offset = 0; offset < pixels.length; offset += 4) {
const alpha = pixels[offset + 3] ?? 0;
const mask =
alpha === 0
? 0
: colorGradingSecondaryMask(
pixels[offset] ?? 0,
pixels[offset + 1] ?? 0,
pixels[offset + 2] ?? 0,
key,
);
const value = Math.round(mask * BYTE_MAX);
matte[offset] = value;
matte[offset + 1] = value;
matte[offset + 2] = value;
matte[offset + 3] = alpha;
}
return matte;
}
function pixelOffset(width: number, x: number, y: number): number {
return (y * width + x) * 4;
}
function byteIndex(value: number): number {
return Math.round(clampNumber(value, 0, 1) * BYTE_MAX);
}
export function analyzeColorGradingFrame(
pixels: Uint8ClampedArray,
width: number,
height: number,
): ColorGradingScopeAnalysis {
const histogram = new Uint32Array(SCOPE_SIZE);
const waveform = new Uint32Array(width * SCOPE_SIZE);
const parade = new Uint32Array(width * SCOPE_SIZE * 3);
const vectorscope = new Uint32Array(SCOPE_SIZE * SCOPE_SIZE);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const offset = pixelOffset(width, x, y);
const alpha = pixels[offset + 3] ?? 0;
if (alpha === 0) continue;
const red = (pixels[offset] ?? 0) / BYTE_MAX;
const green = (pixels[offset + 1] ?? 0) / BYTE_MAX;
const blue = (pixels[offset + 2] ?? 0) / BYTE_MAX;
const normalizedLuma = rec709Luma(red, green, blue);
const luma = byteIndex(normalizedLuma);
histogram[luma] += alpha;
waveform[x * SCOPE_SIZE + (BYTE_MAX - luma)] += alpha;
parade[x * SCOPE_SIZE + (BYTE_MAX - byteIndex(red))] += alpha;
parade[width * SCOPE_SIZE + x * SCOPE_SIZE + (BYTE_MAX - byteIndex(green))] += alpha;
parade[width * SCOPE_SIZE * 2 + x * SCOPE_SIZE + (BYTE_MAX - byteIndex(blue))] += alpha;
const chromaBlue = (blue - normalizedLuma) / 1.8556;
const chromaRed = (red - normalizedLuma) / 1.5748;
const vectorX = byteIndex(chromaBlue + 0.5);
const vectorY = BYTE_MAX - byteIndex(chromaRed + 0.5);
vectorscope[vectorY * SCOPE_SIZE + vectorX] += alpha;
}
}
return { width, histogram, waveform, parade, vectorscope };
}
export function sampleColorGradingSecondary(
pixels: Uint8ClampedArray,
width: number,
height: number,
x: number,
y: number,
radius = 2,
): NormalizedHfColorGradingSecondary["key"] | null {
const minX = clampNumber(Math.floor(x) - radius, 0, width - 1);
const maxX = clampNumber(Math.floor(x) + radius, 0, width - 1);
const minY = clampNumber(Math.floor(y) - radius, 0, height - 1);
const maxY = clampNumber(Math.floor(y) + radius, 0, height - 1);
let hueX = 0;
let hueY = 0;
let saturation = 0;
let luma = 0;
let weight = 0;
for (let sampleY = minY; sampleY <= maxY; sampleY += 1) {
for (let sampleX = minX; sampleX <= maxX; sampleX += 1) {
const offset = pixelOffset(width, sampleX, sampleY);
const alpha = (pixels[offset + 3] ?? 0) / BYTE_MAX;
if (alpha === 0) continue;
const red = (pixels[offset] ?? 0) / BYTE_MAX;
const green = (pixels[offset + 1] ?? 0) / BYTE_MAX;
const blue = (pixels[offset + 2] ?? 0) / BYTE_MAX;
const hsv = rgbToHsv(red, green, blue);
const hueRadians = (hsv.hue * Math.PI) / 180;
const hueWeight = hsv.saturation * alpha;
hueX += Math.cos(hueRadians) * hueWeight;
hueY += Math.sin(hueRadians) * hueWeight;
saturation += hsv.saturation * alpha;
luma += rec709Luma(red, green, blue) * alpha;
weight += alpha;
}
}
if (weight === 0) return null;
const averageHue = ((Math.atan2(hueY, hueX) * 180) / Math.PI + 360) % 360;
const averageSaturation = saturation / weight;
const averageLuma = luma / weight;
const neutralSample = averageSaturation < 0.02;
return {
hue: neutralSample
? { center: 0, range: 180, softness: 0 }
: { center: averageHue, range: 18, softness: 12 },
saturation: {
min: clampNumber(averageSaturation - 0.2, 0, 1),
max: clampNumber(averageSaturation + 0.2, 0, 1),
softness: 0.1,
},
luma: {
min: clampNumber(averageLuma - 0.2, 0, 1),
max: clampNumber(averageLuma + 0.2, 0, 1),
softness: 0.1,
},
};
}
@@ -0,0 +1,549 @@
import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
compileHfColorCurve,
compileHfHueCurve,
HF_COLOR_CURVE_MAX_POINTS,
type HfColorCurvePoint,
type HfColorGradingCurveKey,
type HfColorGradingHueCurveKey,
type HfHueCurvePoint,
type NormalizedHfColorGradingCurves,
type NormalizedHfColorGradingHueCurves,
} from "@hyperframes/core/color-grading";
import { clampNumber } from "../../utils/studioHelpers";
const GRAPH_SIZE = 160;
const GRAPH_PADDING = 8;
const SAMPLE_COUNT = 128;
const INPUT_EPSILON = 0.002;
type RgbTab = {
kind: "rgb";
key: HfColorGradingCurveKey;
label: string;
color: string;
min: 0;
max: 1;
};
type HueTab = {
kind: "hue";
key: HfColorGradingHueCurveKey;
label: string;
color: string;
min: number;
max: number;
};
export type CurveTab = RgbTab | HueTab;
export const TABS: readonly CurveTab[] = [
{ kind: "rgb", key: "master", label: "Master", color: "#e5e7eb", min: 0, max: 1 },
{ kind: "rgb", key: "red", label: "R", color: "#fb7185", min: 0, max: 1 },
{ kind: "rgb", key: "green", label: "G", color: "#4ade80", min: 0, max: 1 },
{ kind: "rgb", key: "blue", label: "B", color: "#60a5fa", min: 0, max: 1 },
{ kind: "hue", key: "hueVsHue", label: "Hue/Hue", color: "#f0abfc", min: -180, max: 180 },
{ kind: "hue", key: "hueVsSaturation", label: "Hue/Sat", color: "#facc15", min: -1, max: 1 },
{ kind: "hue", key: "hueVsLuma", label: "Hue/Luma", color: "#f8fafc", min: -1, max: 1 },
];
export const RGB_IDENTITY: readonly HfColorCurvePoint[] = [
[0, 0],
[1, 1],
];
export interface ColorCurveValues {
curves: NormalizedHfColorGradingCurves;
hueCurves: NormalizedHfColorGradingHueCurves;
}
function graphPoint(input: number, output: number, tab: CurveTab) {
const inner = GRAPH_SIZE - GRAPH_PADDING * 2;
const xRatio = tab.kind === "rgb" ? input : input / 360;
const yRatio = (output - tab.min) / (tab.max - tab.min);
return {
x: GRAPH_PADDING + xRatio * inner,
y: GRAPH_SIZE - GRAPH_PADDING - yRatio * inner,
};
}
function valueFromPointer(clientX: number, clientY: number, rect: DOMRect, tab: CurveTab) {
const inner = GRAPH_SIZE - GRAPH_PADDING * 2;
const graphX = ((clientX - rect.left) / Math.max(rect.width, 1)) * GRAPH_SIZE;
const graphY = ((clientY - rect.top) / Math.max(rect.height, 1)) * GRAPH_SIZE;
const xRatio = clampNumber((graphX - GRAPH_PADDING) / inner, 0, 1);
const yRatio = 1 - clampNumber((graphY - GRAPH_PADDING) / inner, 0, 1);
return {
input: tab.kind === "rgb" ? xRatio : Math.min(359.999, xRatio * 360),
output: tab.min + yRatio * (tab.max - tab.min),
};
}
function curvePath(samples: Float32Array, tab: CurveTab): string {
return [...samples]
.map((sample, index) => {
const input =
tab.kind === "rgb" ? index / (samples.length - 1) : (index / samples.length) * 360;
const point = graphPoint(input, sample, tab);
return `${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`;
})
.join(" ");
}
function samplesFor(points: readonly (HfColorCurvePoint | HfHueCurvePoint)[], tab: CurveTab) {
if (tab.kind === "rgb") {
return compileHfColorCurve(points as readonly HfColorCurvePoint[], SAMPLE_COUNT);
}
if (points.length < 3) return new Float32Array(SAMPLE_COUNT);
return compileHfHueCurve(points as readonly HfHueCurvePoint[], tab.min, tab.max, SAMPLE_COUNT);
}
export function pointsFor(value: ColorCurveValues, tab: CurveTab) {
return tab.kind === "rgb" ? value.curves[tab.key] : value.hueCurves[tab.key];
}
function circularHueDistance(left: number, right: number): number {
const distance = Math.abs(left - right) % 360;
return Math.min(distance, 360 - distance);
}
export function withPoints(
value: ColorCurveValues,
tab: CurveTab,
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
): ColorCurveValues {
return tab.kind === "rgb"
? {
...value,
curves: {
...value.curves,
[tab.key]: points as readonly HfColorCurvePoint[],
},
}
: {
...value,
hueCurves: {
...value.hueCurves,
[tab.key]: points as readonly HfHueCurvePoint[],
},
};
}
function nearestInputPointIndex(
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
input: number,
tab: CurveTab,
): number {
const inputSpan = tab.kind === "rgb" ? 1 : 360;
let closest = -1;
let distance = 12 / (GRAPH_SIZE - GRAPH_PADDING * 2);
points.forEach((point, index) => {
const nextDistance =
(tab.kind === "hue" ? circularHueDistance(point[0], input) : Math.abs(point[0] - input)) /
inputSpan;
if (nextDistance < distance) {
closest = index;
distance = nextDistance;
}
});
return closest;
}
function nearestGraphPointIndex(
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
input: number,
output: number,
tab: CurveTab,
): number {
const target = graphPoint(input, output, tab);
let closest = -1;
let distance = 12;
points.forEach((point, index) => {
const candidate = graphPoint(point[0], point[1], tab);
const xDistance =
tab.kind === "hue"
? (circularHueDistance(point[0], input) / 360) * (GRAPH_SIZE - GRAPH_PADDING * 2)
: candidate.x - target.x;
const nextDistance = Math.hypot(xDistance, candidate.y - target.y);
if (nextDistance < distance) {
closest = index;
distance = nextDistance;
}
});
return closest;
}
function insertPoint(
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
input: number,
output: number,
tab: CurveTab,
) {
if (points.length >= HF_COLOR_CURVE_MAX_POINTS) return null;
if (tab.kind === "hue" && points.length < 3) {
const result: HfHueCurvePoint[] = [
[((input + 240) % 360) as number, 0],
[input, output],
[((input + 120) % 360) as number, 0],
];
result.sort((a, b) => a[0] - b[0]);
return { points: result, selected: result.findIndex((point) => point[0] === input) };
}
const next = [...points];
const point =
tab.kind === "rgb"
? ([input, clampNumber(output, 0, 1)] as HfColorCurvePoint)
: ([input, clampNumber(output, tab.min, tab.max)] as HfHueCurvePoint);
next.push(point);
next.sort((a, b) => a[0] - b[0]);
return { points: next, selected: next.indexOf(point) };
}
function safeRgbInput(
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
index: number,
input: number,
): number {
if (index === 0) return 0;
if (index === points.length - 1) return 1;
return clampNumber(
input,
(points[index - 1]?.[0] ?? 0) + INPUT_EPSILON,
(points[index + 1]?.[0] ?? 1) - INPUT_EPSILON,
);
}
function hueInputCollides(
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
index: number,
input: number,
): boolean {
return points.some(
(point, pointIndex) =>
pointIndex !== index && circularHueDistance(point[0], input) < INPUT_EPSILON * 360,
);
}
function safeHueInput(
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
index: number,
input: number,
): number {
const initial = clampNumber(input, 0, 359.999);
if (!hueInputCollides(points, index, initial)) return initial;
const spacing = INPUT_EPSILON * 360;
for (let step = 1; step <= points.length + 1; step += 1) {
const clockwise = (initial + spacing * step) % 360;
if (!hueInputCollides(points, index, clockwise)) return clockwise;
const counterClockwise = (initial - spacing * step + 360) % 360;
if (!hueInputCollides(points, index, counterClockwise)) return counterClockwise;
}
return initial;
}
export function movePoint(
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
index: number,
input: number,
output: number,
tab: CurveTab,
) {
const next = [...points];
const safeInput =
tab.kind === "rgb" ? safeRgbInput(next, index, input) : safeHueInput(next, index, input);
const moved =
tab.kind === "rgb"
? ([safeInput, clampNumber(output, 0, 1)] as HfColorCurvePoint)
: ([safeInput, clampNumber(output, tab.min, tab.max)] as HfHueCurvePoint);
next[index] = moved;
next.sort((a, b) => a[0] - b[0]);
return { points: next, selected: next.indexOf(moved) };
}
const ARROW_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"] as const;
type ArrowKey = (typeof ARROW_KEYS)[number];
function isArrowKey(key: string): key is ArrowKey {
return ARROW_KEYS.includes(key as ArrowKey);
}
function curveOutputStep(tab: CurveTab, large: boolean): number {
if (tab.kind === "rgb") return large ? 0.05 : 0.01;
if (tab.key === "hueVsHue") return large ? 10 : 1;
return large ? 0.1 : 0.01;
}
function keyboardPointPosition(
point: HfColorCurvePoint | HfHueCurvePoint,
key: ArrowKey,
tab: CurveTab,
large: boolean,
) {
const inputStep = tab.kind === "rgb" ? (large ? 0.05 : 0.01) : large ? 10 : 1;
const outputStep = curveOutputStep(tab, large);
const inputDelta = key === "ArrowLeft" ? -inputStep : key === "ArrowRight" ? inputStep : 0;
const outputDelta = key === "ArrowDown" ? -outputStep : key === "ArrowUp" ? outputStep : 0;
return { input: point[0] + inputDelta, output: point[1] + outputDelta };
}
export function CurveGraph({
tab,
points,
selectedIndex,
disabled,
onBegin,
onPreview,
onSelect,
onDelete,
onSettle,
onCancel,
}: {
tab: CurveTab;
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[];
selectedIndex: number | null;
disabled?: boolean;
onBegin: () => void;
onPreview: (
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
selectedIndex: number,
) => void;
onSelect: (index: number | null) => void;
onDelete: () => void;
onSettle: () => void;
onCancel: () => void;
}) {
const pointerRef = useRef<{
pointerId: number;
index: number;
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[];
} | null>(null);
const samples = useMemo(() => samplesFor(points, tab), [points, tab]);
const path = useMemo(() => curvePath(samples, tab), [samples, tab]);
const selectRelativePoint = (offset: number) => {
if (points.length === 0) return;
const current = selectedIndex ?? (offset > 0 ? -1 : 0);
onSelect((current + offset + points.length) % points.length);
};
const addKeyboardPoint = () => {
const input = tab.kind === "rgb" ? 0.5 : 180;
const existing = nearestInputPointIndex(points, input, tab);
if (existing >= 0) {
onSelect(existing);
return;
}
const output =
tab.kind === "rgb"
? ((samples[Math.floor((samples.length - 1) / 2)] ?? input) +
(samples[Math.ceil((samples.length - 1) / 2)] ?? input)) /
2
: (samples[Math.round(samples.length / 2)] ?? 0);
const inserted = insertPoint(points, input, output, tab);
if (!inserted) return;
onBegin();
onPreview(inserted.points, inserted.selected);
onSettle();
};
const moveSelectedByKeyboard = (key: ArrowKey, large: boolean) => {
if (selectedIndex === null) return false;
const selected = points[selectedIndex];
if (!selected) return false;
const position = keyboardPointPosition(selected, key, tab, large);
const moved = movePoint(points, selectedIndex, position.input, position.output, tab);
onBegin();
onPreview(moved.points, moved.selected);
return true;
};
const previewFromPointer = (target: SVGSVGElement, clientX: number, clientY: number) => {
const active = pointerRef.current;
if (!active) return;
const nextValue = valueFromPointer(clientX, clientY, target.getBoundingClientRect(), tab);
const moved = movePoint(active.points, active.index, nextValue.input, nextValue.output, tab);
pointerRef.current = {
pointerId: active.pointerId,
index: moved.selected,
points: moved.points,
};
onPreview(moved.points, moved.selected);
};
const handleRemovalKey = (event: ReactKeyboardEvent<SVGSVGElement>) => {
if (event.key === "Escape") {
event.preventDefault();
pointerRef.current = null;
onCancel();
return true;
}
const deleteKey = event.key === "Delete" || event.key === "Backspace";
if (deleteKey) {
if (selectedIndex === null) return;
event.preventDefault();
onDelete();
return true;
}
return false;
};
const handleAddKey = (event: ReactKeyboardEvent<SVGSVGElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
addKeyboardPoint();
return true;
}
return false;
};
const handleSelectionKey = (event: ReactKeyboardEvent<SVGSVGElement>) => {
if (event.key === "PageUp" || event.key === "PageDown") {
event.preventDefault();
selectRelativePoint(event.key === "PageUp" ? -1 : 1);
return true;
}
if (event.key === "Home" || event.key === "End") {
if (points.length === 0) return true;
event.preventDefault();
onSelect(event.key === "Home" ? 0 : points.length - 1);
return true;
}
return false;
};
const handleArrowKey = (event: ReactKeyboardEvent<SVGSVGElement>) => {
if (!isArrowKey(event.key) || points.length === 0) return false;
event.preventDefault();
if (selectedIndex === null) {
const selectLast = event.key === "ArrowLeft" || event.key === "ArrowDown";
onSelect(selectLast ? points.length - 1 : 0);
return true;
}
moveSelectedByKeyboard(event.key, event.shiftKey);
return true;
};
const handleKeyDown = (event: ReactKeyboardEvent<SVGSVGElement>) => {
if (handleRemovalKey(event)) return;
if (handleAddKey(event)) return;
if (handleSelectionKey(event)) return;
handleArrowKey(event);
};
return (
<svg
viewBox={`0 0 ${GRAPH_SIZE} ${GRAPH_SIZE}`}
role="application"
aria-label={`${tab.label} curve`}
aria-keyshortcuts="Enter Space ArrowLeft ArrowRight ArrowUp ArrowDown PageUp PageDown Home End Delete"
tabIndex={disabled ? -1 : 0}
data-color-curve-graph={tab.key}
data-color-curve-mid-sample={(samples[Math.floor(samples.length / 2)] ?? 0).toFixed(5)}
onPointerDown={(event) => {
if (disabled) return;
const value = valueFromPointer(
event.clientX,
event.clientY,
event.currentTarget.getBoundingClientRect(),
tab,
);
let index = nearestGraphPointIndex(points, value.input, value.output, tab);
let nextPoints = points;
if (index < 0) {
const inserted = insertPoint(points, value.input, value.output, tab);
if (!inserted) return;
nextPoints = inserted.points;
index = inserted.selected;
}
event.currentTarget.setPointerCapture(event.pointerId);
pointerRef.current = { pointerId: event.pointerId, index, points: nextPoints };
onBegin();
onSelect(index);
const moved = movePoint(nextPoints, index, value.input, value.output, tab);
pointerRef.current.index = moved.selected;
pointerRef.current.points = moved.points;
onPreview(moved.points, moved.selected);
}}
onPointerMove={(event) => {
if (disabled || pointerRef.current?.pointerId !== event.pointerId) return;
previewFromPointer(event.currentTarget, event.clientX, event.clientY);
}}
onPointerUp={(event) => {
if (pointerRef.current?.pointerId !== event.pointerId) return;
previewFromPointer(event.currentTarget, event.clientX, event.clientY);
pointerRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
onSettle();
}}
onPointerCancel={() => {
pointerRef.current = null;
onCancel();
}}
onKeyDown={handleKeyDown}
onKeyUp={(event) => {
if (selectedIndex !== null && isArrowKey(event.key)) {
onSettle();
}
}}
className="mx-auto aspect-square w-full max-w-[200px] touch-none rounded border border-panel-border-input bg-black/20 outline-none focus:ring-1 focus:ring-panel-accent"
>
<defs>
<linearGradient id={`hf-hue-axis-${tab.key}`}>
<stop offset="0%" stopColor="#f33" />
<stop offset="16.7%" stopColor="#ff3" />
<stop offset="33.3%" stopColor="#3f3" />
<stop offset="50%" stopColor="#3ff" />
<stop offset="66.7%" stopColor="#33f" />
<stop offset="83.3%" stopColor="#f3f" />
<stop offset="100%" stopColor="#f33" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((ratio) => (
<g key={ratio} stroke="rgba(255,255,255,0.08)" strokeWidth="0.5">
<line
x1={GRAPH_PADDING}
y1={GRAPH_PADDING + ratio * (GRAPH_SIZE - GRAPH_PADDING * 2)}
x2={GRAPH_SIZE - GRAPH_PADDING}
y2={GRAPH_PADDING + ratio * (GRAPH_SIZE - GRAPH_PADDING * 2)}
/>
<line
x1={GRAPH_PADDING + ratio * (GRAPH_SIZE - GRAPH_PADDING * 2)}
y1={GRAPH_PADDING}
x2={GRAPH_PADDING + ratio * (GRAPH_SIZE - GRAPH_PADDING * 2)}
y2={GRAPH_SIZE - GRAPH_PADDING}
/>
</g>
))}
{tab.kind === "hue" && (
<line
x1={GRAPH_PADDING}
y1={GRAPH_SIZE - 3}
x2={GRAPH_SIZE - GRAPH_PADDING}
y2={GRAPH_SIZE - 3}
stroke={`url(#hf-hue-axis-${tab.key})`}
strokeWidth="3"
/>
)}
<path
data-color-curve-path="true"
d={path}
fill="none"
stroke={tab.color}
strokeWidth="1.5"
/>
{points.map(([input, output], index) => {
const point = graphPoint(input, output, tab);
return (
<circle
key={`${input}-${index}`}
data-color-curve-point={index}
cx={point.x}
cy={point.y}
r={selectedIndex === index ? 3.5 : 2.5}
fill={selectedIndex === index ? "#fff" : tab.color}
stroke="#111"
strokeWidth="1"
/>
);
})}
</svg>
);
}
export function formatPointValue(value: number, tab: CurveTab, axis: "input" | "output") {
if (tab.kind === "hue" && axis === "input") return Number(value.toFixed(1));
if (tab.kind === "rgb") return Number(value.toFixed(3));
return Number(value.toFixed(tab.key === "hueVsHue" ? 1 : 3));
}
@@ -0,0 +1,158 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ColorCurveValues } from "./propertyPanelColorCurves";
import { ColorCurves } from "./propertyPanelColorCurves";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const IDENTITY: ColorCurveValues = {
curves: {
master: [
[0, 0],
[1, 1],
],
red: [
[0, 0],
[1, 1],
],
green: [
[0, 0],
[1, 1],
],
blue: [
[0, 0],
[1, 1],
],
},
hueCurves: { hueVsHue: [], hueVsSaturation: [], hueVsLuma: [] },
};
afterEach(() => {
document.body.innerHTML = "";
});
function renderCurves(value = IDENTITY) {
const onPreview = vi.fn();
const onCommit = vi.fn();
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
act(() => root.render(<ColorCurves value={value} onPreview={onPreview} onCommit={onCommit} />));
return { host, root, onPreview, onCommit };
}
function activate(host: HTMLElement, key: string) {
act(() => {
host
.querySelector<HTMLButtonElement>(`[data-color-curve-tab="${key}"]`)
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const graph = host.querySelector<SVGSVGElement>(`[data-color-curve-graph="${key}"]`);
if (!graph) throw new Error(`Expected ${key} graph`);
Object.defineProperty(graph, "getBoundingClientRect", {
value: () => ({ left: 0, top: 0, width: 160, height: 160, right: 160, bottom: 160 }),
});
return graph;
}
describe("ColorCurves", () => {
it("renders the four RGB and three hue-selective curve tabs", () => {
const { host, root } = renderCurves();
expect(host.querySelectorAll("[data-color-curve-tab]")).toHaveLength(7);
expect(host.querySelector('[data-color-curve-graph="master"]')).not.toBeNull();
act(() => root.unmount());
});
it("treats points across the red seam as neighbors instead of adding a duplicate", () => {
const value: ColorCurveValues = {
...IDENTITY,
hueCurves: {
...IDENTITY.hueCurves,
hueVsSaturation: [
[120, 0],
[240, 0],
[359, 0.2],
],
},
};
const { host, root, onCommit } = renderCurves(value);
const graph = activate(host, "hueVsSaturation");
act(() => {
graph.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
pointerId: 5,
clientX: 8,
clientY: 66,
}),
);
graph.dispatchEvent(
new PointerEvent("pointerup", {
bubbles: true,
pointerId: 5,
clientX: 8,
clientY: 66,
}),
);
});
const points = onCommit.mock.calls[0]?.[0]?.hueCurves.hueVsSaturation;
expect(points).toHaveLength(3);
expect(points.some(([hue]: readonly [number, number]) => hue < 1)).toBe(true);
act(() => root.unmount());
});
it("previews pointer edits and commits once on release", () => {
const { host, root, onPreview, onCommit } = renderCurves();
const graph = activate(host, "master");
act(() => {
graph.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
pointerId: 2,
clientX: 80,
clientY: 120,
}),
);
graph.dispatchEvent(
new PointerEvent("pointerup", {
bubbles: true,
pointerId: 2,
clientX: 80,
clientY: 120,
}),
);
});
expect(onPreview.mock.calls[0]?.[0]?.curves.master).toHaveLength(3);
expect(onPreview.mock.calls.at(-1)?.[0]).toBe(IDENTITY);
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());
});
});
@@ -0,0 +1,185 @@
import { useState } from "react";
import type { HfColorCurvePoint, HfHueCurvePoint } from "@hyperframes/core/color-grading";
import { RotateCcw } from "../../icons/SystemIcons";
import {
CurveGraph,
formatPointValue,
movePoint,
pointsFor,
RGB_IDENTITY,
TABS,
type ColorCurveValues,
type CurveTab,
withPoints,
} from "./propertyPanelColorCurveGraph";
import { GradingNumberField } from "./propertyPanelGradingNumberField";
import { useInspectorGestureDraft } from "./useInspectorGestureTransaction";
export type { ColorCurveValues } from "./propertyPanelColorCurveGraph";
export function ColorCurves({
value,
disabled,
onPreview,
onCommit,
}: {
value: ColorCurveValues;
disabled?: boolean;
onPreview: (value: ColorCurveValues) => void;
onCommit: (value: ColorCurveValues) => void;
}) {
const [activeKey, setActiveKey] = useState<CurveTab["key"]>("master");
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const { draft, setDraft, transaction } = useInspectorGestureDraft({
sourceValue: value,
onPreview,
onCommit,
});
const tab = TABS.find((candidate) => candidate.key === activeKey) ?? TABS[0];
if (!tab) throw new Error("Color curve tabs are unavailable");
const points = pointsFor(draft, tab);
const previewPoints = (
nextPoints: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
nextSelectedIndex: number,
) => {
setSelectedIndex(nextSelectedIndex);
transaction.preview(withPoints(draft, tab, nextPoints));
};
const resetActive = () => {
transaction.cancel();
const next = withPoints(draft, tab, tab.kind === "rgb" ? RGB_IDENTITY : []);
setDraft(next);
setSelectedIndex(null);
onCommit(next);
};
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
? []
: points.filter((_, index) => index !== selectedIndex);
const next = withPoints(draft, tab, nextPoints);
setDraft(next);
setSelectedIndex(null);
onCommit(next);
};
const updateSelected = (axis: "input" | "output", rawValue: number) => {
if (selectedIndex === null || !Number.isFinite(rawValue)) return;
const point = points[selectedIndex];
if (!point) return;
const moved = movePoint(
points,
selectedIndex,
axis === "input" ? rawValue : point[0],
axis === "output" ? rawValue : point[1],
tab,
);
previewPoints(moved.points, moved.selected);
};
const selectedPoint = selectedIndex === null ? null : points[selectedIndex];
const endpointSelected =
tab.kind === "rgb" &&
selectedIndex !== null &&
(selectedIndex === 0 || selectedIndex === points.length - 1);
return (
<div data-color-curves="true" className="space-y-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-panel-hairline">
{TABS.map((candidate) => (
<button
key={candidate.key}
type="button"
data-color-curve-tab={candidate.key}
aria-pressed={candidate.key === tab.key}
disabled={disabled}
onClick={() => {
transaction.cancel();
setActiveKey(candidate.key);
setSelectedIndex(null);
}}
className={`border-b-2 px-0.5 py-1 text-[9px] ${
candidate.key === tab.key
? "border-panel-accent text-panel-text-1"
: "border-transparent text-panel-text-4 hover:text-panel-text-2"
}`}
>
{candidate.label}
</button>
))}
</div>
<CurveGraph
tab={tab}
points={points}
selectedIndex={selectedIndex}
disabled={disabled}
onBegin={transaction.begin}
onPreview={previewPoints}
onSelect={setSelectedIndex}
onDelete={deleteSelected}
onSettle={transaction.settle}
onCancel={transaction.cancel}
/>
<div className="flex min-h-7 items-end gap-2">
{selectedPoint ? (
<>
<GradingNumberField
label={tab.kind === "rgb" ? "Input" : "Hue"}
ariaLabel={`Curve point ${tab.kind === "rgb" ? "input" : "hue"}`}
value={formatPointValue(selectedPoint[0], tab, "input")}
min={0}
max={tab.kind === "rgb" ? 1 : 359.999}
disabled={disabled || endpointSelected}
labelClassName="min-w-0 flex-1"
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
inputClassName="block w-full"
onBegin={transaction.begin}
onPreview={(next) => updateSelected("input", next)}
onSettle={transaction.settle}
onCancel={transaction.cancel}
/>
<GradingNumberField
label="Output"
ariaLabel="Curve point output"
value={formatPointValue(selectedPoint[1], tab, "output")}
min={tab.min}
max={tab.max}
disabled={disabled}
labelClassName="min-w-0 flex-1"
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
inputClassName="block w-full"
onBegin={transaction.begin}
onPreview={(next) => updateSelected("output", next)}
onSettle={transaction.settle}
onCancel={transaction.cancel}
/>
<button
type="button"
disabled={disabled || endpointSelected}
onClick={deleteSelected}
className="pb-0.5 text-[9px] text-panel-text-4 hover:text-panel-text-1 disabled:opacity-30"
>
Delete
</button>
</>
) : (
<span className="flex-1 text-[9px] text-panel-text-5">
Click the graph or press Enter to add a point
</span>
)}
<button
type="button"
aria-label={`Reset ${tab.label} curve`}
title={`Reset ${tab.label} curve`}
disabled={disabled}
onClick={resetActive}
className="pb-0.5 text-panel-text-4 hover:text-panel-text-1 disabled:opacity-40"
>
<RotateCcw size={11} />
</button>
</div>
</div>
);
}
@@ -15,7 +15,7 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
const LUT_UPLOAD_DIR = "assets/luts";
const ADJUST_SLIDERS: Array<{
export const COLOR_GRADING_ADJUST_SLIDERS: Array<{
key: HfColorGradingAdjustKey;
label: string;
min: number;
@@ -60,7 +60,7 @@ const ADJUST_SLIDERS: Array<{
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
];
const DETAIL_SLIDERS: Array<{
export const COLOR_GRADING_DETAIL_SLIDERS: Array<{
key: HfColorGradingDetailKey;
label: string;
min: number;
@@ -123,7 +123,7 @@ const DETAIL_SLIDERS: Array<{
},
];
type DetailSlider = (typeof DETAIL_SLIDERS)[number];
type DetailSlider = (typeof COLOR_GRADING_DETAIL_SLIDERS)[number];
type SliderSettings = {
active?: boolean;
label: string;
@@ -143,28 +143,111 @@ const EFFECT_SLIDERS: Array<{
{ key: "pixelate", label: "Pixelate", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
];
const AMOUNT_DETAIL_SLIDERS = DETAIL_SLIDERS.filter(
const AMOUNT_DETAIL_SLIDERS = COLOR_GRADING_DETAIL_SLIDERS.filter(
(slider) => slider.key === "vignette" || slider.key === "grain",
);
const VIGNETTE_TUNE_SLIDERS = DETAIL_SLIDERS.filter(
export const VIGNETTE_TUNE_SLIDERS = COLOR_GRADING_DETAIL_SLIDERS.filter(
(slider) =>
slider.key === "vignetteMidpoint" ||
slider.key === "vignetteRoundness" ||
slider.key === "vignetteFeather",
);
const GRAIN_TUNE_SLIDERS = DETAIL_SLIDERS.filter(
export const GRAIN_TUNE_SLIDERS = COLOR_GRADING_DETAIL_SLIDERS.filter(
(slider) => slider.key === "grainSize" || slider.key === "grainRoughness",
);
function normalizedDefaultValue(slider: { defaultValue?: number; scale: number }): number {
export function normalizedColorGradingDefault(slider: {
defaultValue?: number;
scale: number;
}): number {
return (slider.defaultValue ?? 0) / slider.scale;
}
function visibleIntensity(grading: NormalizedHfColorGrading): number {
export function visibleColorGradingIntensity(grading: NormalizedHfColorGrading): number {
// Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
return grading.intensity === 0 ? 1 : grading.intensity;
}
function colorGradingWithLut(
grading: NormalizedHfColorGrading,
src: string | null,
intensity = 1,
): NormalizedHfColorGrading {
return {
...grading,
intensity: visibleColorGradingIntensity(grading),
lut: src ? { src, intensity } : null,
};
}
export function colorGradingWithDetail(
grading: NormalizedHfColorGrading,
key: HfColorGradingDetailKey,
value: number,
): NormalizedHfColorGrading {
return {
...grading,
intensity: visibleColorGradingIntensity(grading),
details: { ...grading.details, [key]: value },
};
}
function colorGradingWithIntensity(
grading: NormalizedHfColorGrading,
intensity: number,
): NormalizedHfColorGrading {
return { ...grading, intensity };
}
export function colorGradingWithAdjust(
grading: NormalizedHfColorGrading,
key: HfColorGradingAdjustKey,
value: number,
): NormalizedHfColorGrading {
return {
...grading,
intensity: visibleColorGradingIntensity(grading),
adjust: { ...grading.adjust, [key]: value },
};
}
async function importFirstLut(
files: FileList | null,
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>,
): Promise<string | null> {
if (!files?.length || !onImportAssets) return null;
const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR);
return uploaded.find((asset) => LUT_EXT.test(asset)) ?? null;
}
export function createColorGradingActions(
grading: NormalizedHfColorGrading,
onCommit: (grading: NormalizedHfColorGrading) => void,
) {
const applyLut = (src: string | null, intensity = 1) => {
onCommit(colorGradingWithLut(grading, src, intensity));
};
return {
setIntensityPercent(value: number) {
onCommit(colorGradingWithIntensity(grading, value / 100));
},
applyLut,
setLutIntensityPercent(value: number) {
if (grading.lut) applyLut(grading.lut.src, value / 100);
},
async importLut(
files: FileList | null,
onImportAssets: ((files: FileList, dir?: string) => Promise<string[]>) | undefined,
onImported: () => void,
) {
const src = await importFirstLut(files, onImportAssets);
if (!src) return;
onImported();
applyLut(src);
},
};
}
export function ColorGradingControls({
grading,
assets,
@@ -189,11 +272,14 @@ export function ColorGradingControls({
const detailSettingsSliders =
detailSettings === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS;
const vignetteSettingsActive = VIGNETTE_TUNE_SLIDERS.some(
(slider) => Math.abs(grading.details[slider.key] - normalizedDefaultValue(slider)) > 0.0001,
(slider) =>
Math.abs(grading.details[slider.key] - normalizedColorGradingDefault(slider)) > 0.0001,
);
const grainSettingsActive = GRAIN_TUNE_SLIDERS.some(
(slider) => Math.abs(grading.details[slider.key] - normalizedDefaultValue(slider)) > 0.0001,
(slider) =>
Math.abs(grading.details[slider.key] - normalizedColorGradingDefault(slider)) > 0.0001,
);
const actions = createColorGradingActions(grading, onCommitColorGrading);
const applyPreset = (preset: string) => {
const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
@@ -202,51 +288,13 @@ export function ColorGradingControls({
onCommitColorGrading(next);
}
};
const updateFilterIntensity = (value: number) => {
onCommitColorGrading({
...grading,
intensity: value / 100,
});
};
const applyLut = (src: string | null, intensity = 1) => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
lut: src ? { src, intensity } : null,
});
};
const updateLutIntensity = (value: number) => {
if (!grading.lut) return;
applyLut(grading.lut.src, value / 100);
};
const importLuts = async (files: FileList | null) => {
if (!files?.length || !onImportAssets) return;
const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR);
const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
if (firstLut) {
track("button", "Import LUT");
applyLut(firstLut, 1);
}
};
const commitDetailSlider = (slider: DetailSlider, next: number) => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: {
...grading.details,
[slider.key]: next / slider.scale,
},
});
onCommitColorGrading(colorGradingWithDetail(grading, slider.key, next / slider.scale));
};
const resetDetailSlider = (slider: DetailSlider) => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: {
...grading.details,
[slider.key]: normalizedDefaultValue(slider),
},
});
onCommitColorGrading(
colorGradingWithDetail(grading, slider.key, normalizedColorGradingDefault(slider)),
);
};
const renderDetailSlider = (slider: DetailSlider, settings?: SliderSettings) => {
const value = Math.round(grading.details[slider.key] * slider.scale);
@@ -293,8 +341,8 @@ export function ColorGradingControls({
neutral={0}
suffix="%"
displayValue={`${Math.round(grading.intensity * 100)}%`}
onCommit={updateFilterIntensity}
onReset={() => updateFilterIntensity(100)}
onCommit={actions.setIntensityPercent}
onReset={() => actions.setIntensityPercent(100)}
/>
<div className="min-w-0 rounded-md border border-panel-border/70 bg-panel-input/15">
@@ -322,7 +370,7 @@ export function ColorGradingControls({
onChange={(event) => {
const nextSrc = event.target.value;
track("select", "Custom LUT");
applyLut(
actions.applyLut(
nextSrc || null,
nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
);
@@ -361,7 +409,9 @@ export function ColorGradingControls({
multiple
className="hidden"
onChange={(event) => {
void importLuts(event.currentTarget.files);
void actions.importLut(event.currentTarget.files, onImportAssets, () =>
track("button", "Import LUT"),
);
event.currentTarget.value = "";
}}
/>
@@ -386,8 +436,8 @@ export function ColorGradingControls({
neutral={0}
suffix="%"
displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`}
onCommit={updateLutIntensity}
onReset={() => updateLutIntensity(100)}
onCommit={actions.setLutIntensityPercent}
onReset={() => actions.setLutIntensityPercent(100)}
/>
</div>
)}
@@ -398,7 +448,7 @@ export function ColorGradingControls({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Adjust</span>
<div className="grid min-w-0 grid-cols-2 gap-1.5">
{ADJUST_SLIDERS.map((slider) => {
{COLOR_GRADING_ADJUST_SLIDERS.map((slider) => {
const value = grading.adjust[slider.key] * slider.scale;
const isExposure = slider.key === "exposure";
return (
@@ -418,24 +468,12 @@ export function ColorGradingControls({
: `${Math.round(value)}%`
}
onCommit={(next) => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
adjust: {
...grading.adjust,
[slider.key]: next / slider.scale,
},
});
onCommitColorGrading(
colorGradingWithAdjust(grading, slider.key, next / slider.scale),
);
}}
onReset={() => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
adjust: {
...grading.adjust,
[slider.key]: 0,
},
});
onCommitColorGrading(colorGradingWithAdjust(grading, slider.key, 0));
}}
/>
);
@@ -499,7 +537,7 @@ export function ColorGradingControls({
onCommit={(next) => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
intensity: visibleColorGradingIntensity(grading),
effects: {
...grading.effects,
[slider.key]: next / slider.scale,
@@ -509,7 +547,7 @@ export function ColorGradingControls({
onReset={() => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
intensity: visibleColorGradingIntensity(grading),
effects: {
...grading.effects,
[slider.key]: 0,
@@ -0,0 +1,258 @@
import { useEffect, useRef, useState } from "react";
import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
import {
type ColorGradingScopeAnalysis,
type ColorGradingScopeMode,
} from "./colorGradingFrameAnalysis";
import { useColorGradingScopes } from "./useColorGradingScopes";
import { RotateCw } from "../../icons/SystemIcons";
const MODES: Array<{ id: ColorGradingScopeMode; label: string }> = [
{ id: "histogram", label: "Histogram" },
{ id: "waveform", label: "Waveform" },
{ id: "parade", label: "RGB Parade" },
{ id: "vectorscope", label: "Vectorscope" },
];
function densityAlpha(value: number, maximum: number): number {
if (value === 0 || maximum === 0) return 0;
return Math.min(1, Math.log2(value + 1) / Math.log2(maximum + 1));
}
function maximumDensity(values: Uint32Array): number {
let maximum = 0;
for (const value of values) maximum = Math.max(maximum, value);
return maximum;
}
function drawGrid(context: CanvasRenderingContext2D, width: number, height: number) {
context.fillStyle = "#090b0e";
context.fillRect(0, 0, width, height);
context.strokeStyle = "rgba(255,255,255,0.08)";
context.lineWidth = 1;
for (const fraction of [0.25, 0.5, 0.75]) {
context.beginPath();
context.moveTo(0, Math.round(height * fraction) + 0.5);
context.lineTo(width, Math.round(height * fraction) + 0.5);
context.stroke();
}
}
function drawHistogram(
context: CanvasRenderingContext2D,
analysis: ColorGradingScopeAnalysis,
width: number,
height: number,
) {
const maximum = maximumDensity(analysis.histogram);
context.beginPath();
context.moveTo(0, height);
analysis.histogram.forEach((count, index) => {
context.lineTo((index / 255) * width, height - densityAlpha(count, maximum) * height);
});
context.lineTo(width, height);
context.closePath();
context.fillStyle = "rgba(226, 232, 240, 0.2)";
context.fill();
context.strokeStyle = "rgba(226, 232, 240, 0.9)";
context.stroke();
}
function drawDensity(
context: CanvasRenderingContext2D,
density: Uint32Array,
columnOffset: number,
columnCount: number,
color: readonly [number, number, number],
xOffset: number,
outputWidth: number,
height: number,
) {
let maximum = 0;
const start = columnOffset * 256;
const end = (columnOffset + columnCount) * 256;
for (let index = start; index < end; index += 1) maximum = Math.max(maximum, density[index] ?? 0);
context.fillStyle = `rgb(${color.join(" ")})`;
for (let x = 0; x < columnCount; x += 1) {
for (let y = 0; y < 256; y += 1) {
const alpha = density[(columnOffset + x) * 256 + y] ?? 0;
if (!alpha) continue;
context.globalAlpha = densityAlpha(alpha, maximum);
context.fillRect(
xOffset + (x / columnCount) * outputWidth,
(y / 255) * height,
Math.max(1, outputWidth / columnCount),
Math.max(1, height / 256),
);
}
}
context.globalAlpha = 1;
}
function drawVectorscope(
context: CanvasRenderingContext2D,
analysis: ColorGradingScopeAnalysis,
width: number,
height: number,
) {
const maximum = maximumDensity(analysis.vectorscope);
const centerX = width / 2;
const centerY = height / 2;
context.strokeStyle = "rgba(255,255,255,0.12)";
context.beginPath();
context.arc(centerX, centerY, Math.min(width, height) * 0.42, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(centerX, 0);
context.lineTo(centerX, height);
context.moveTo(0, centerY);
context.lineTo(width, centerY);
context.stroke();
const skinAngle = (-123 * Math.PI) / 180;
context.strokeStyle = "rgba(251,191,36,0.3)";
context.beginPath();
context.moveTo(centerX, centerY);
context.lineTo(
centerX + Math.cos(skinAngle) * Math.min(width, height) * 0.45,
centerY + Math.sin(skinAngle) * Math.min(width, height) * 0.45,
);
context.stroke();
context.fillStyle = "rgb(110 231 183)";
for (let y = 0; y < 256; y += 1) {
for (let x = 0; x < 256; x += 1) {
const count = analysis.vectorscope[y * 256 + x] ?? 0;
if (!count) continue;
context.globalAlpha = densityAlpha(count, maximum);
context.fillRect(
(x / 255) * width,
(y / 255) * height,
Math.max(1, width / 256),
Math.max(1, height / 256),
);
}
}
context.globalAlpha = 1;
}
function drawScope(
canvas: HTMLCanvasElement,
mode: ColorGradingScopeMode,
analysis: ColorGradingScopeAnalysis,
) {
const context = canvas.getContext("2d");
if (!context) return;
const { width, height } = canvas;
drawGrid(context, width, height);
if (mode === "histogram") {
drawHistogram(context, analysis, width, height);
} else if (mode === "waveform") {
drawDensity(context, analysis.waveform, 0, analysis.width, [226, 232, 240], 0, width, height);
} else if (mode === "parade") {
const laneWidth = width / 3;
(
[
[0, [248, 113, 113]],
[1, [74, 222, 128]],
[2, [96, 165, 250]],
] as const
).forEach(([channel, color]) => {
drawDensity(
context,
analysis.parade,
channel * analysis.width,
analysis.width,
color,
channel * laneWidth,
laneWidth,
height,
);
});
} else {
drawVectorscope(context, analysis, width, height);
}
}
export function PropertyPanelColorScopes({
captureFrame,
refreshKey,
}: {
captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
refreshKey: string;
}) {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<ColorGradingScopeMode>("waveform");
const canvasRef = useRef<HTMLCanvasElement>(null);
const { analysis, status, refresh } = useColorGradingScopes({
open,
captureFrame,
refreshKey,
});
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (analysis) {
drawScope(canvas, mode, analysis);
return;
}
const context = canvas.getContext("2d");
if (context) drawGrid(context, canvas.width, canvas.height);
}, [analysis, mode]);
return (
<div data-flat-grade-scopes="true" className="border-b border-panel-hairline pb-1.5">
<div className="flex min-h-7 items-center justify-between">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex min-h-7 flex-1 items-center justify-between text-left"
>
<span className="text-[11px] text-panel-text-2">Scopes</span>
<span className="text-[9px] text-panel-text-5">{open ? status : "Off"}</span>
</button>
<span className="flex items-center gap-2">
{open && (
<button
type="button"
aria-label="Refresh scopes"
title="Refresh scopes"
onClick={refresh}
className="p-1 text-panel-text-4 hover:text-panel-text-1"
>
<RotateCw size={10} />
</button>
)}
</span>
</div>
{open && (
<div className="space-y-1.5">
<div className="flex items-center gap-2 overflow-x-auto">
{MODES.map((candidate) => (
<button
key={candidate.id}
type="button"
aria-pressed={mode === candidate.id}
onClick={() => setMode(candidate.id)}
className={`whitespace-nowrap border-b-2 py-1 text-[9px] ${
mode === candidate.id
? "border-panel-accent text-panel-text-1"
: "border-transparent text-panel-text-4 hover:text-panel-text-2"
}`}
>
{candidate.label}
</button>
))}
</div>
<canvas
ref={canvasRef}
width={320}
height={144}
role="img"
aria-label={`${MODES.find((candidate) => candidate.id === mode)?.label ?? mode} scope, ${status}`}
className="block h-auto w-full border border-panel-hairline bg-black"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,181 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
import * as frameAnalysis from "./colorGradingFrameAnalysis";
import { PropertyPanelColorSecondary } from "./propertyPanelColorSecondary";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
function normalizedSecondaries(count: number) {
const grading = normalizeHfColorGrading({
secondaries: Array.from({ length: count }, () => ({ key: {}, correction: {} })),
});
if (!grading) throw new Error("Expected normalized grading");
return grading.secondaries ?? [];
}
function renderSecondary({
secondaries = normalizedSecondaries(1),
captureFrame = vi.fn().mockResolvedValue(null),
onCommit = vi.fn(),
} = {}) {
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
act(() =>
root.render(
<PropertyPanelColorSecondary
secondaries={secondaries}
captureFrame={captureFrame}
onCommit={onCommit}
/>,
),
);
return { host, root, onCommit };
}
async function captureSecondaryFrame(host: HTMLElement) {
const capture = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Sample color from frame"),
);
await act(async () => {
capture?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
}
describe("PropertyPanelColorSecondary", () => {
it("adds a normalized secondary and enforces the contract limit", () => {
const { host, root, onCommit } = renderSecondary({ secondaries: [] });
act(() => {
host
.querySelector<HTMLButtonElement>('button[title="Add color selection"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onCommit.mock.calls[0]?.[0]).toHaveLength(1);
expect(onCommit.mock.calls[0]?.[0]?.[0]).toMatchObject({ enabled: true });
act(() => root.unmount());
const maximum = renderSecondary({ secondaries: normalizedSecondaries(4) });
expect(
maximum.host.querySelector<HTMLButtonElement>('button[title="Add secondary color selection"]')
?.disabled,
).toBe(true);
act(() => maximum.root.unmount());
});
it("bypasses a secondary without deleting its qualifier or correction", () => {
const secondaries = normalizedSecondaries(1).map((secondary) => ({
...secondary,
correction: { ...secondary.correction, luma: 0.2 },
}));
const { host, root, onCommit } = renderSecondary({ secondaries });
act(() => {
host
.querySelector<HTMLButtonElement>('[role="switch"][aria-label="Selection enabled"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onCommit.mock.calls[0]?.[0]?.[0]).toMatchObject({
enabled: false,
correction: { luma: 0.2 },
key: secondaries[0]?.key,
});
act(() => root.unmount());
});
it("keeps saturation and luma ranges strictly ordered", () => {
const { host, root, onCommit } = renderSecondary();
act(() => {
host
.querySelector<HTMLElement>('[role="slider"][aria-label="Saturation min"]')
?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
});
expect(onCommit.mock.calls.at(-1)?.[0]?.[0]?.key.saturation).toMatchObject({
min: 0.99,
max: 1,
});
act(() => {
host
.querySelector<HTMLElement>('[role="slider"][aria-label="Luma max"]')
?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
});
expect(onCommit.mock.calls.at(-1)?.[0]?.[0]?.key.luma).toMatchObject({
min: 0,
max: 0.01,
});
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")),
});
await captureSecondaryFrame(host);
expect(host.querySelector('[role="alert"]')?.textContent).toContain(
"Preview frame unavailable",
);
expect(onCommit).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("decodes a captured frame once and samples its center from the keyboard", async () => {
const pixels = new Uint8ClampedArray(4 * 4 * 4);
for (let offset = 0; offset < pixels.length; offset += 4) {
pixels[offset] = 255;
pixels[offset + 3] = 255;
}
const decode = vi.spyOn(frameAnalysis, "readColorGradingFramePixels").mockResolvedValue(pixels);
const { host, root, onCommit } = renderSecondary({
captureFrame: vi.fn().mockResolvedValue({
dataUrl: "data:image/png;base64,",
width: 4,
height: 4,
}),
});
await captureSecondaryFrame(host);
await act(async () => {
host
.querySelector<HTMLButtonElement>('[aria-label="Sample color from captured frame"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
});
expect(decode).toHaveBeenCalledOnce();
expect(onCommit.mock.calls[0]?.[0]?.[0]?.key.hue.center).toBeCloseTo(0);
const matteToggle = Array.from(
host.querySelectorAll<HTMLButtonElement>('button[aria-pressed="true"]'),
).find((button) => button.textContent?.includes("Selection matte"));
expect(matteToggle).toBeDefined();
act(() => root.unmount());
});
});
@@ -0,0 +1,448 @@
import { useEffect, useRef, useState } from "react";
import {
getHfColorGradingCapabilities,
normalizeHfColorGrading,
type NormalizedHfColorGradingSecondary,
} from "@hyperframes/core/color-grading";
import { Eyedropper, Plus, Trash } from "../../icons/SystemIcons";
import { FlatSlider } from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle";
import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
import {
buildColorGradingSecondaryMatte,
readColorGradingFramePixels,
sampleColorGradingSecondary,
} from "./colorGradingFrameAnalysis";
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: {} }],
})?.secondaries?.[0];
if (!secondary) throw new Error("Missing default color grading secondary");
return secondary;
}
const DEFAULT_SECONDARY = defaultSecondary();
function sampleCapturedFrame(
pixels: Uint8ClampedArray,
frame: ColorGradingCapturedFrame,
target: HTMLElement,
clientX?: number,
clientY?: number,
) {
const rect = target.getBoundingClientRect();
const x = (clientX === undefined ? 0.5 : (clientX - rect.left) / rect.width) * frame.width;
const y = (clientY === undefined ? 0.5 : (clientY - rect.top) / rect.height) * frame.height;
return sampleColorGradingSecondary(pixels, frame.width, frame.height, x, y);
}
function percent(value: number): string {
return `${Math.round(value * 100)}%`;
}
export function PropertyPanelColorSecondary({
secondaries,
captureFrame,
onCommit,
}: {
secondaries: Secondaries;
captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
onCommit: (secondaries: Secondaries) => void;
}) {
const [selectedIndex, setSelectedIndex] = useState(0);
const [sampleFrame, setSampleFrame] = useState<ColorGradingCapturedFrame | null>(null);
const [samplePixels, setSamplePixels] = useState<Uint8ClampedArray | null>(null);
const [showMatte, setShowMatte] = useState(false);
const [captureError, setCaptureError] = useState<string | null>(null);
const [sampling, setSampling] = useState(false);
const matteCanvasRef = useRef<HTMLCanvasElement>(null);
const activeIndex = Math.min(selectedIndex, Math.max(0, secondaries.length - 1));
const selected = secondaries[activeIndex];
useEffect(() => {
const canvas = matteCanvasRef.current;
if (!showMatte || !canvas || !sampleFrame || !samplePixels || !selected) return;
const context = canvas.getContext("2d");
if (!context) return;
const image = context.createImageData(sampleFrame.width, sampleFrame.height);
image.data.set(
buildColorGradingSecondaryMatte(
samplePixels,
sampleFrame.width,
sampleFrame.height,
selected.key,
),
);
context.putImageData(image, 0, 0);
}, [sampleFrame, samplePixels, selected, showMatte]);
const replaceSelected = (next: NormalizedHfColorGradingSecondary) => {
if (!selected) return;
onCommit(secondaries.map((secondary, index) => (index === activeIndex ? next : secondary)));
};
const addSecondary = () => {
if (secondaries.length >= SECONDARY_CAPABILITIES.max) return;
onCommit([...secondaries, DEFAULT_SECONDARY]);
setSelectedIndex(secondaries.length);
};
const removeSelected = () => {
if (!selected) return;
const next = secondaries.filter((_, index) => index !== activeIndex);
onCommit(next);
setSelectedIndex(Math.max(0, Math.min(activeIndex, next.length - 1)));
setSampleFrame(null);
setSamplePixels(null);
setCaptureError(null);
};
return (
<div className="space-y-1.5" data-flat-grade-secondary="true">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1">
{secondaries.map((_, index) => (
<button
key={index}
type="button"
aria-pressed={activeIndex === index}
onClick={() => {
setSelectedIndex(index);
setSampleFrame(null);
setSamplePixels(null);
setCaptureError(null);
}}
className={`h-6 min-w-6 border-b-2 px-1 font-mono text-[10px] ${
activeIndex === index
? "border-panel-accent text-panel-text-0"
: "border-transparent text-panel-text-4 hover:text-panel-text-2"
}`}
>
{index + 1}
</button>
))}
</span>
<span className="flex items-center gap-2">
<button
type="button"
aria-label="Add secondary color selection"
title="Add secondary color selection"
disabled={secondaries.length >= SECONDARY_CAPABILITIES.max}
onClick={addSecondary}
className="text-panel-text-3 hover:text-panel-text-1 disabled:opacity-35"
>
<Plus size={12} />
</button>
<button
type="button"
aria-label="Remove selected secondary"
title="Remove selected secondary"
disabled={!selected}
onClick={removeSelected}
className="text-panel-text-3 hover:text-panel-text-1 disabled:opacity-35"
>
<Trash size={12} />
</button>
</span>
</div>
{!selected ? (
<button
type="button"
title="Add color selection"
onClick={addSecondary}
className="w-full border border-dashed border-panel-border-input px-2 py-2 text-[10px] text-panel-text-4 hover:border-panel-accent/50 hover:text-panel-text-2"
>
Add a color selection
</button>
) : (
<>
<FlatToggle
label="Selection enabled"
checked={selected.enabled}
onChange={(enabled) => replaceSelected({ ...selected, enabled })}
/>
<button
type="button"
disabled={sampling}
onClick={async () => {
setSampling(true);
setCaptureError(null);
try {
const frame = await captureFrame();
if (!frame) throw new Error("Frame capture unavailable");
const pixels = await readColorGradingFramePixels(frame);
if (!pixels) throw new Error("Frame pixels unavailable");
setSampleFrame(frame);
setSamplePixels(pixels);
setShowMatte(false);
} catch {
setSampleFrame(null);
setSamplePixels(null);
setCaptureError("Preview frame unavailable. Try again after the media loads.");
} finally {
setSampling(false);
}
}}
className="flex min-h-7 items-center gap-1.5 text-[10px] font-medium text-panel-accent hover:text-panel-accent/80 disabled:opacity-50"
>
<Eyedropper size={12} />
{sampling ? "Capturing frame" : "Sample color from frame"}
</button>
{captureError && (
<p role="alert" className="text-[9px] leading-4 text-red-300">
{captureError}
</p>
)}
{sampleFrame && samplePixels && (
<div className="space-y-1">
<div className="flex gap-2 text-[9px]">
<button
type="button"
aria-pressed={!showMatte}
onClick={() => setShowMatte(false)}
className={!showMatte ? "text-panel-accent" : "text-panel-text-4"}
>
Source
</button>
<button
type="button"
aria-pressed={showMatte}
onClick={() => setShowMatte(true)}
className={showMatte ? "text-panel-accent" : "text-panel-text-4"}
>
Selection matte
</button>
</div>
{showMatte ? (
<canvas
ref={matteCanvasRef}
width={sampleFrame.width}
height={sampleFrame.height}
role="img"
aria-label="Selected color matte"
className="block h-auto w-full border border-panel-hairline bg-black"
/>
) : (
<button
type="button"
className="block w-full overflow-hidden border border-panel-hairline bg-black"
title="Click a color to initialize this selection"
aria-label="Sample color from captured frame"
onClick={(event) => {
const pointer: [] | [number, number] =
event.detail === 0 ? [] : [event.clientX, event.clientY];
const key = sampleCapturedFrame(
samplePixels,
sampleFrame,
event.currentTarget,
...pointer,
);
if (!key) return;
replaceSelected({ ...selected, key });
setShowMatte(true);
}}
>
<img
src={sampleFrame.dataUrl}
alt=""
draggable={false}
className="block h-auto w-full cursor-crosshair object-contain"
/>
</button>
)}
</div>
)}
<div className="text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Qualifier
</div>
<FlatSlider
label="Hue"
value={selected.key.hue.center}
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: wrapHueCenter(center) },
},
})
}
/>
<FlatSlider
label="Hue range"
value={selected.key.hue.range}
min={SECONDARY_CAPABILITIES.hue.range.min}
max={SECONDARY_CAPABILITIES.hue.range.max}
tier="explicitCustom"
displayValue={`${Math.round(selected.key.hue.range)}°`}
onCommit={(range) =>
replaceSelected({
...selected,
key: {
...selected.key,
hue: {
...selected.key.hue,
range,
softness: Math.min(
selected.key.hue.softness,
SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - range,
),
},
},
})
}
/>
<FlatSlider
label="Hue softness"
value={selected.key.hue.softness}
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) =>
replaceSelected({
...selected,
key: { ...selected.key, hue: { ...selected.key.hue, softness } },
})
}
/>
{(["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 * PERCENT_SCALE}
min={capability.min.min * PERCENT_SCALE}
max={capability.min.max * PERCENT_SCALE}
tier="explicitCustom"
displayValue={percent(range.min)}
onCommit={(value) =>
replaceSelected({
...selected,
key: {
...selected.key,
[key]: {
...range,
min: Math.max(
capability.min.min,
Math.min(value / PERCENT_SCALE, range.max - RANGE_GAP),
),
},
},
})
}
/>,
<FlatSlider
key={`${key}-max`}
label={`${label} max`}
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) =>
replaceSelected({
...selected,
key: {
...selected.key,
[key]: {
...range,
max: Math.min(
capability.max.max,
Math.max(value / PERCENT_SCALE, range.min + RANGE_GAP),
),
},
},
})
}
/>,
<FlatSlider
key={`${key}-softness`}
label={`${label} softness`}
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 / PERCENT_SCALE },
},
})
}
/>,
];
})}
<div className="pt-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Correction
</div>
{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={limit.min * scale}
max={limit.max * scale}
centerTick
tier={Math.abs(value) > 0.0001 ? "explicitCustom" : "default"}
displayValue={`${Math.round(value)}${suffix}`}
onCommit={(next) =>
replaceSelected({
...selected,
correction: { ...selected.correction, [key]: next / scale },
})
}
onReset={() =>
replaceSelected({
...selected,
correction: { ...selected.correction, [key]: 0 },
})
}
/>
);
})}
</>
)}
</div>
);
}
@@ -0,0 +1,119 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NormalizedHfColorGradingWheels } from "@hyperframes/core/color-grading";
import { ColorWheels } from "./propertyPanelColorWheels";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const NEUTRAL: NormalizedHfColorGradingWheels = {
shadows: { hue: 0, amount: 0, level: 0 },
midtones: { hue: 0, amount: 0, level: 0 },
highlights: { hue: 0, amount: 0, level: 0 },
};
afterEach(() => {
document.body.innerHTML = "";
});
function renderWheels() {
const onPreview = vi.fn();
const onCommit = vi.fn();
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
act(() => root.render(<ColorWheels value={NEUTRAL} onPreview={onPreview} onCommit={onCommit} />));
const surface = host.querySelector<HTMLDivElement>(
'[data-color-wheel="shadows"] [data-color-wheel-surface="true"]',
);
if (!surface) throw new Error("Expected the shadows wheel");
Object.defineProperty(surface, "getBoundingClientRect", {
value: () => ({ left: 0, top: 0, width: 100, height: 100, right: 100, bottom: 100 }),
});
return { root, surface, onPreview, onCommit };
}
function pointerAt(surface: HTMLElement, x: number, y: number, pointerId = 1) {
act(() => {
surface.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
pointerId,
clientX: x,
clientY: y,
}),
);
surface.dispatchEvent(
new PointerEvent("pointerup", {
bubbles: true,
pointerId,
clientX: x,
clientY: y,
}),
);
});
}
describe("ColorWheels", () => {
it("maps cardinal pointer colors to the documented counter-clockwise hue angles", () => {
const cases = [
[100, 50, 0],
[50, 0, 90],
[0, 50, 180],
[50, 100, 270],
] as const;
for (const [x, y, expectedHue] of cases) {
const { root, surface, onCommit } = renderWheels();
pointerAt(surface, x, y);
expect(onCommit.mock.calls[0]?.[0]?.shadows).toMatchObject({
hue: expectedHue,
amount: 1,
});
act(() => root.unmount());
}
});
it("renders a color sequence that follows the same hue direction as the pointer", () => {
const { root, surface } = renderWheels();
expect(surface.style.background).toContain("#f33, #f3f, #33f, #3ff, #3f3, #ff3, #f33");
act(() => root.unmount());
});
it("previews during drag and commits only once on release", () => {
const { root, surface, onPreview, onCommit } = renderWheels();
act(() => {
surface.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
pointerId: 7,
clientX: 100,
clientY: 50,
}),
);
surface.dispatchEvent(
new PointerEvent("pointermove", {
bubbles: true,
pointerId: 7,
clientX: 50,
clientY: 0,
}),
);
});
expect(onPreview.mock.calls.at(-1)?.[0]?.shadows.hue).toBe(90);
expect(onCommit).not.toHaveBeenCalled();
act(() => {
surface.dispatchEvent(
new PointerEvent("pointerup", {
bubbles: true,
pointerId: 7,
clientX: 50,
clientY: 0,
}),
);
});
expect(onCommit).toHaveBeenCalledOnce();
act(() => root.unmount());
});
});
@@ -0,0 +1,334 @@
import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
getHfColorGradingCapabilities,
type HfColorGradingWheelKey,
type NormalizedHfColorGradingWheels,
} from "@hyperframes/core/color-grading";
import { RotateCcw } from "../../icons/SystemIcons";
import { clampNumber } from "../../utils/studioHelpers";
import { GradingNumberField } from "./propertyPanelGradingNumberField";
import { useInspectorGestureDraft } from "./useInspectorGestureTransaction";
const WHEELS: ReadonlyArray<{ key: HfColorGradingWheelKey; label: string }> = [
{ key: "shadows", label: "Shadows" },
{ key: "midtones", label: "Midtones" },
{ key: "highlights", label: "Highlights" },
];
type NormalizedTonalWheel = NormalizedHfColorGradingWheels[HfColorGradingWheelKey];
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 {
const { min, maxExclusive } = WHEEL_CONTROLS.hue;
const span = maxExclusive - min;
return ((((value - min) % span) + span) % span) + min;
}
function formatNumberInput(value: number, decimals: number): string {
return String(Number(value.toFixed(decimals)));
}
const formatIntegerInput = (value: number) => formatNumberInput(value, 0);
function wheelFromPointer(
wheel: NormalizedTonalWheel,
clientX: number,
clientY: number,
rect: DOMRect,
): NormalizedTonalWheel {
const radius = Math.max(1, Math.min(rect.width, rect.height) / 2);
const x = (clientX - (rect.left + rect.width / 2)) / radius;
const y = (rect.top + rect.height / 2 - clientY) / radius;
return {
...wheel,
hue: wrapHue((Math.atan2(y, x) * 180) / Math.PI),
amount: clampNumber(Math.hypot(x, y), WHEEL_CONTROLS.amount.min, WHEEL_CONTROLS.amount.max),
};
}
function updateWheel(
value: NormalizedHfColorGradingWheels,
key: HfColorGradingWheelKey,
wheel: NormalizedTonalWheel,
): NormalizedHfColorGradingWheels {
return { ...value, [key]: wheel };
}
function wheelFromKey(
wheel: NormalizedTonalWheel,
key: string,
large: boolean,
): NormalizedTonalWheel | null {
const hueStep = large ? 10 : 1;
const amountStep = large ? 0.1 : 0.01;
switch (key) {
case "ArrowLeft":
return { ...wheel, hue: wrapHue(wheel.hue - hueStep) };
case "ArrowRight":
return { ...wheel, hue: wrapHue(wheel.hue + hueStep) };
case "ArrowDown":
return {
...wheel,
amount: clampNumber(
wheel.amount - amountStep,
WHEEL_CONTROLS.amount.min,
WHEEL_CONTROLS.amount.max,
),
};
case "ArrowUp":
return {
...wheel,
amount: clampNumber(
wheel.amount + amountStep,
WHEEL_CONTROLS.amount.min,
WHEEL_CONTROLS.amount.max,
),
};
case "Home":
return { ...wheel, amount: WHEEL_CONTROLS.amount.min };
case "End":
return { ...wheel, amount: WHEEL_CONTROLS.amount.max };
default:
return null;
}
}
function TonalWheel({
label,
wheel,
disabled,
onBegin,
onPreview,
onSettle,
onCancel,
onReset,
}: {
label: string;
wheel: NormalizedTonalWheel;
disabled?: boolean;
onBegin: () => void;
onPreview: (wheel: NormalizedTonalWheel) => void;
onSettle: () => void;
onCancel: () => void;
onReset: () => void;
}) {
const pointerIdRef = useRef<number | null>(null);
const hueRadians = (wheel.hue * Math.PI) / 180;
const thumbLeft = 50 + Math.cos(hueRadians) * wheel.amount * 46;
const thumbTop = 50 - Math.sin(hueRadians) * wheel.amount * 46;
const previewPointer = (target: HTMLDivElement, clientX: number, clientY: number) => {
onPreview(wheelFromPointer(wheel, clientX, clientY, target.getBoundingClientRect()));
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
if (event.key === "Escape") {
event.preventDefault();
onCancel();
return;
}
const next = wheelFromKey(wheel, event.key, event.shiftKey);
if (!next) return;
event.preventDefault();
onBegin();
onPreview(next);
};
return (
<div data-color-wheel={label.toLowerCase()} className="min-w-[88px] flex-1 space-y-1.5">
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[10px] font-medium text-panel-text-2">{label}</span>
<button
type="button"
aria-label={`Reset ${label}`}
title={`Reset ${label}`}
disabled={disabled}
onClick={onReset}
className="text-panel-text-4 hover:text-panel-text-1 disabled:opacity-40"
>
<RotateCcw size={10} />
</button>
</div>
<div
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`${label} color`}
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}
onPointerDown={(event) => {
if (disabled) return;
event.preventDefault();
onBegin();
pointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
previewPointer(event.currentTarget, event.clientX, event.clientY);
}}
onPointerMove={(event) => {
if (disabled || pointerIdRef.current !== event.pointerId) return;
previewPointer(event.currentTarget, event.clientX, event.clientY);
}}
onPointerUp={(event) => {
if (pointerIdRef.current !== event.pointerId) return;
previewPointer(event.currentTarget, event.clientX, event.clientY);
pointerIdRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
onSettle();
}}
onPointerCancel={(event) => {
if (pointerIdRef.current !== event.pointerId) return;
pointerIdRef.current = null;
onCancel();
}}
onKeyDown={handleKeyDown}
onKeyUp={(event) => {
if (
["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)
) {
onSettle();
}
}}
onBlur={onSettle}
className="relative mx-auto aspect-square w-full max-w-[112px] touch-none rounded-full border border-panel-border-input outline-none focus:ring-1 focus:ring-panel-accent disabled:opacity-40"
style={{
background:
"radial-gradient(circle, rgb(128 128 128) 0%, transparent 72%), conic-gradient(from 90deg, #f33, #f3f, #33f, #3ff, #3f3, #ff3, #f33)",
}}
>
<span
data-color-wheel-thumb="true"
className="pointer-events-none absolute h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-transparent shadow-[0_0_0_1px_rgba(0,0,0,0.8)]"
style={{ left: `${thumbLeft}%`, top: `${thumbTop}%` }}
/>
</div>
<input
type="range"
aria-label={`${label} level`}
min={WHEEL_CONTROLS.level.min}
max={WHEEL_CONTROLS.level.max}
step={0.01}
value={wheel.level}
disabled={disabled}
onPointerDown={onBegin}
onChange={(event) => onPreview({ ...wheel, level: Number(event.target.value) })}
onPointerUp={onSettle}
onPointerCancel={onCancel}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
} else {
onBegin();
}
}}
onKeyUp={onSettle}
onBlur={onSettle}
className="h-3 w-full accent-panel-accent"
/>
<div className="grid grid-cols-3 gap-1">
<GradingNumberField
label="Hue"
value={wheel.hue}
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: wrapHue(hue) })}
onSettle={onSettle}
onCancel={onCancel}
/>
<GradingNumberField
label="Amount"
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 / PERCENT_SCALE })}
onSettle={onSettle}
onCancel={onCancel}
/>
<GradingNumberField
label="Level"
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 / PERCENT_SCALE })}
onSettle={onSettle}
onCancel={onCancel}
/>
</div>
</div>
);
}
export function ColorWheels({
value,
disabled,
onPreview,
onCommit,
}: {
value: NormalizedHfColorGradingWheels;
disabled?: boolean;
onPreview: (value: NormalizedHfColorGradingWheels) => void;
onCommit: (value: NormalizedHfColorGradingWheels) => void;
}) {
const { draft, setDraft, transaction } = useInspectorGestureDraft({
sourceValue: value,
onPreview,
onCommit,
});
const previewWheel = (key: HfColorGradingWheelKey, wheel: NormalizedTonalWheel) =>
transaction.preview(updateWheel(draft, key, wheel));
const resetWheel = (key: HfColorGradingWheelKey) => {
transaction.cancel();
const next = updateWheel(draft, key, RESET_WHEEL);
setDraft(next);
onCommit(next);
};
return (
<div
data-color-wheels="true"
className="grid grid-cols-[repeat(auto-fit,minmax(88px,1fr))] gap-2"
>
{WHEELS.map(({ key, label }) => (
<TonalWheel
key={key}
label={label}
wheel={draft[key]}
disabled={disabled}
onBegin={transaction.begin}
onPreview={(wheel) => previewWheel(key, wheel)}
onSettle={transaction.settle}
onCancel={transaction.cancel}
onReset={() => resetWheel(key)}
/>
))}
</div>
);
}
@@ -0,0 +1,109 @@
import { useEffect, useRef } from "react";
import { isHfColorGradingActive } from "@hyperframes/core/color-grading";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { Compare, RotateCcw } from "../../icons/SystemIcons";
import type { ColorGradingControllerState } from "./useColorGradingController";
const STATUS_DOT_CLASS: Record<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
active: "bg-emerald-400",
pending: "bg-amber-300",
unavailable: "bg-red-400",
missing: "bg-panel-text-5",
inactive: "bg-panel-text-5",
};
export function FlatColorGradingAccessory({
state,
}: {
state: Pick<
ColorGradingControllerState,
"grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading"
>;
}) {
const track = useTrackDesignInput();
const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state;
const gradingActive = isHfColorGradingActive(grading);
const releaseRef = useRef<(() => void) | null>(null);
useEffect(
() => () => {
releaseRef.current?.();
releaseRef.current = null;
},
[],
);
return (
<span className="flex items-center gap-2.5">
<button
type="button"
aria-pressed={compareEnabled}
aria-label="Hold to show original"
disabled={!gradingActive}
onPointerDown={(e) => {
if (!gradingActive) return;
e.preventDefault();
e.stopPropagation();
track("button", "Compare original");
commitCompare(true);
const release = () => {
commitCompare(false);
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
window.removeEventListener("blur", release);
releaseRef.current = null;
};
releaseRef.current = release;
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
window.addEventListener("blur", release);
}}
onBlur={() => {
if (compareEnabled) commitCompare(false);
}}
onKeyDown={(e) => {
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
e.preventDefault();
if (!compareEnabled) {
track("button", "Compare original");
commitCompare(true);
}
}}
onKeyUp={(e) => {
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
e.preventDefault();
commitCompare(false);
}}
title="Hold to show original"
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
>
<Compare size={12} />
</button>
<span className="flex min-w-0 items-center gap-1" title={runtimeStatus.message}>
<span
data-flat-grade-status-dot="true"
title={runtimeStatus.message}
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
/>
<span
data-flat-grade-status-message="true"
className="max-w-[84px] truncate text-[9px] text-panel-text-4"
>
{runtimeStatus.message}
</span>
</span>
<button
type="button"
data-flat-grade-reset="true"
title="Reset color grading"
onClick={(e) => {
e.stopPropagation();
track("button", "Reset color grading");
resetGrading();
}}
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
>
<RotateCcw size={12} />
</button>
</span>
);
}
@@ -7,7 +7,10 @@ import {
FlatColorGradingAccessory,
FlatColorGradingSection,
} from "./propertyPanelFlatColorGradingSection";
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
import {
normalizeHfColorGrading,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -229,6 +232,7 @@ function neutralPropsBase() {
height: 90,
},
onRequestPresetPreviews: vi.fn(),
captureGradedFrame: vi.fn(async () => null),
};
}
@@ -241,6 +245,35 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
...base,
intensity: 0.4,
adjust: { ...base.adjust, contrast: 0.3 },
wheels: {
...base.wheels,
shadows: { hue: 210, amount: 0.12, level: 0.04 },
},
curves: {
...base.curves,
master: [
[0, 0],
[0.5, 0.58],
[1, 1],
] as const,
},
hueCurves: {
...base.hueCurves,
hueVsSaturation: [
[0, 0],
[120, 0.1],
[240, 0],
] as const,
},
secondaries:
normalizeHfColorGrading({
secondaries: [
{
key: { hue: { center: 30, range: 15 } },
correction: { saturation: 0.08 },
},
],
})?.secondaries ?? [],
details: { ...base.details, vignette: 0.2 },
effects: { ...base.effects, pixelate: 0.5 },
palette: ["#112233", "#ffffff"],
@@ -277,11 +310,109 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
palette: ["#112233", "#ffffff"],
lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
});
expect(onCommitColorGrading.mock.calls[0][0].wheels.shadows.amount).toBe(0);
expect(onCommitColorGrading.mock.calls[0][0].curves.master).toEqual([
[0, 0],
[1, 1],
]);
expect(onCommitColorGrading.mock.calls[0][0].hueCurves.hueVsSaturation).toEqual([]);
expect(onCommitColorGrading.mock.calls[0][0].secondaries).toEqual([]);
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).not.toBe(0.3);
expect(onCommitColorGrading.mock.calls[0][0].details.vignette).not.toBe(0.2);
act(() => root.unmount());
});
it("orders manual controls like the shader pipeline", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
const elements = [
host.querySelector('[data-flat-grade-adjust="true"]'),
host.querySelector('[data-color-wheels="true"]'),
host.querySelector('[data-color-curves="true"]'),
host.querySelector('[data-flat-grade-secondary="true"]'),
host.querySelector('[data-flat-grade-lut-toggle="true"]'),
];
expect(elements.every(Boolean)).toBe(true);
for (let index = 0; index < elements.length - 1; index += 1) {
const current = elements[index];
const next = elements[index + 1];
if (!current || !next) throw new Error("expected all grading sections");
expect(current.compareDocumentPosition(next) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
}
act(() => root.unmount());
});
it("samples the full-strength signal entering the secondary stage", async () => {
const base = neutralGrading();
const grading = normalizeHfColorGrading({
...base,
preset: "warm-polished",
intensity: 0.35,
adjust: { ...base.adjust, exposure: 0.2 },
wheels: {
...base.wheels,
shadows: { hue: 210, amount: 0.2, level: 0.05 },
},
curves: {
...base.curves,
master: [
[0, 0],
[0.5, 0.6],
[1, 1],
],
},
hueCurves: {
...base.hueCurves,
hueVsSaturation: [
[0, 0],
[120, 0.1],
[240, 0],
],
},
secondaries: [{ key: { hue: { center: 30, range: 15 } }, correction: {} }],
details: { ...base.details, vignette: 0.4, grain: 0.2 },
effects: { ...base.effects, pixelate: 0.5 },
lut: { src: "assets/luts/custom.cube", intensity: 0.8 },
});
if (!grading) throw new Error("expected a secondary grade");
const captureGradedFrame = vi.fn(
async (_options?: { grading?: NormalizedHfColorGrading }) => null,
);
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
grading={grading}
captureGradedFrame={captureGradedFrame}
/>,
);
await act(async () => {
const sample = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find((button) =>
button.textContent?.includes("Sample color from frame"),
);
sample?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(captureGradedFrame).toHaveBeenCalledTimes(1);
const options = captureGradedFrame.mock.calls[0]?.[0];
const sampledGrading = options?.grading;
if (!sampledGrading) throw new Error("expected the sampled grading input");
expect(sampledGrading).toMatchObject({
preset: null,
intensity: 1,
adjust: grading.adjust,
wheels: grading.wheels,
curves: grading.curves,
hueCurves: grading.hueCurves,
secondaries: [],
lut: null,
});
expect(sampledGrading.details.vignette).toBe(0);
expect(sampledGrading.details.grain).toBe(0);
expect(sampledGrading.effects.pixelate).toBe(0);
act(() => root.unmount());
});
it("keeps effect-bearing saved styles out of Grade", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
const presets = host.querySelector('[data-flat-grade-preset-group="presets"]');
@@ -294,7 +425,7 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
act(() => root.unmount());
});
it("shows exact selected-media preview images and requests a fresh batch on mount", () => {
it("shows ready selected-media preview images without requesting a duplicate batch", () => {
const onRequestPresetPreviews = vi.fn();
const { host, root } = renderInto(
<FlatColorGradingSection
@@ -302,7 +433,7 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
onRequestPresetPreviews={onRequestPresetPreviews}
/>,
);
expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1);
expect(onRequestPresetPreviews).not.toHaveBeenCalled();
expect(
host.querySelector<HTMLImageElement>('[data-flat-grade-preview="bright-pop"]')?.src,
).toContain("data:image/png;base64,bright");
@@ -310,6 +441,40 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
act(() => root.unmount());
});
it("requests an idle preview batch once", () => {
const onRequestPresetPreviews = vi.fn();
const props = neutralPropsBase();
const { root } = renderInto(
<FlatColorGradingSection
{...props}
presetPreviews={{ ...props.presetPreviews, status: "idle", images: {} }}
onRequestPresetPreviews={onRequestPresetPreviews}
/>,
);
expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("requires an explicit retry after a preview failure", () => {
const onRequestPresetPreviews = vi.fn();
const props = neutralPropsBase();
const { host, root } = renderInto(
<FlatColorGradingSection
{...props}
presetPreviews={{ ...props.presetPreviews, status: "unavailable", images: {} }}
onRequestPresetPreviews={onRequestPresetPreviews}
/>,
);
expect(onRequestPresetPreviews).not.toHaveBeenCalled();
const retry = Array.from(host.querySelectorAll("button")).find(
(button) => button.textContent === "Retry look previews",
);
if (!retry) throw new Error("expected an explicit preview retry");
act(() => retry.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("shows the Custom LUT row collapsed by default, expanding to reveal the strength slider when a LUT is set", () => {
const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.8 } };
const { host, root } = renderInto(
@@ -1,13 +1,13 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
HF_COLOR_GRADING_GRADE_PRESETS,
isHfColorGradingActive,
normalizeHfColorGrading,
serializeHfColorGrading,
type HfColorGradingAdjustKey,
type HfColorGradingDetailKey,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
import { Plus, Settings } from "../../icons/SystemIcons";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { LUT_EXT } from "../../utils/mediaTypes";
import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives";
@@ -18,138 +18,23 @@ import type {
MediaMetadata,
} from "./useColorGradingController";
import { presetPreviewHandlers } from "./propertyPanelPresetPreview";
import { ColorCurves } from "./propertyPanelColorCurves";
import {
COLOR_GRADING_ADJUST_SLIDERS,
COLOR_GRADING_DETAIL_SLIDERS,
colorGradingWithAdjust,
colorGradingWithDetail,
createColorGradingActions,
GRAIN_TUNE_SLIDERS,
normalizedColorGradingDefault,
VIGNETTE_TUNE_SLIDERS,
visibleColorGradingIntensity,
} from "./propertyPanelColorGradingControls";
import { PropertyPanelColorScopes } from "./propertyPanelColorScopes";
import { PropertyPanelColorSecondary } from "./propertyPanelColorSecondary";
import { ColorWheels } from "./propertyPanelColorWheels";
const STATUS_DOT_CLASS: Record<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
active: "bg-emerald-400",
pending: "bg-amber-300",
unavailable: "bg-red-400",
missing: "bg-panel-text-5",
inactive: "bg-panel-text-5",
};
export function FlatColorGradingAccessory({
state,
}: {
state: Pick<
ColorGradingControllerState,
"grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading"
>;
}) {
const track = useTrackDesignInput();
const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state;
const gradingActive = isHfColorGradingActive(grading);
// Tracks the active hold's cleanup so it can be torn down on unmount too —
// without this, switching selection away mid-hold (unmounting this
// accessory) leaves the pointerup/pointercancel/blur listeners registered
// on `window` forever, each holding a closure over the old commitCompare.
const releaseRef = useRef<(() => void) | null>(null);
useEffect(
() => () => {
releaseRef.current?.();
releaseRef.current = null;
},
[],
);
return (
<span className="flex items-center gap-2.5">
<button
type="button"
aria-pressed={compareEnabled}
aria-label="Hold to show original"
disabled={!gradingActive}
onPointerDown={(e) => {
if (!gradingActive) return;
e.preventDefault();
e.stopPropagation();
track("button", "Compare original");
commitCompare(true);
const release = () => {
commitCompare(false);
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
window.removeEventListener("blur", release);
releaseRef.current = null;
};
releaseRef.current = release;
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
window.addEventListener("blur", release);
}}
onBlur={() => {
if (compareEnabled) commitCompare(false);
}}
onKeyDown={(e) => {
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
e.preventDefault();
if (!compareEnabled) {
track("button", "Compare original");
commitCompare(true);
}
}}
onKeyUp={(e) => {
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
e.preventDefault();
commitCompare(false);
}}
title="Hold to show original"
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
>
<Compare size={12} />
</button>
<span className="flex min-w-0 items-center gap-1" title={runtimeStatus.message}>
<span
data-flat-grade-status-dot="true"
title={runtimeStatus.message}
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
/>
<span
data-flat-grade-status-message="true"
className="max-w-[84px] truncate text-[9px] text-panel-text-4"
>
{runtimeStatus.message}
</span>
</span>
<button
type="button"
data-flat-grade-reset="true"
title="Reset color grading"
onClick={(e) => {
e.stopPropagation();
track("button", "Reset color grading");
resetGrading();
}}
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
>
<RotateCcw size={12} />
</button>
</span>
);
}
const ADJUST_SLIDERS: Array<{
key: HfColorGradingAdjustKey;
label: string;
min: number;
max: number;
step: number;
}> = [
{ key: "exposure", label: "Exposure", min: -200, max: 200, step: 5 },
{ key: "contrast", label: "Contrast", min: -100, max: 100, step: 1 },
{ key: "highlights", label: "Highlights", min: -100, max: 100, step: 1 },
{ key: "shadows", label: "Shadows", min: -100, max: 100, step: 1 },
{ key: "whites", label: "White Point", min: -100, max: 100, step: 1 },
{ key: "blacks", label: "Black Point", min: -100, max: 100, step: 1 },
{ key: "temperature", label: "Warmth", min: -100, max: 100, step: 1 },
{ key: "tint", label: "Tint", min: -100, max: 100, step: 1 },
{ key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1 },
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 },
];
function visibleIntensity(grading: NormalizedHfColorGrading): number {
// Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
return grading.intensity === 0 ? 1 : grading.intensity;
}
export { FlatColorGradingAccessory } from "./propertyPanelFlatColorGradingAccessory";
function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
if (key === "exposure") {
@@ -159,30 +44,17 @@ function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): st
return `${Math.round(rawPercent)}%`;
}
const DETAIL_SLIDERS: Array<{
key: HfColorGradingDetailKey;
label: string;
defaultValue: number;
}> = [
{ key: "vignette", label: "Vignette", defaultValue: 0 },
{ key: "vignetteMidpoint", label: "Midpoint", defaultValue: 0.5 },
{ key: "vignetteRoundness", label: "Roundness", defaultValue: 0 },
{ key: "vignetteFeather", label: "Feather", defaultValue: 0.65 },
{ key: "grain", label: "Grain", defaultValue: 0 },
{ key: "grainSize", label: "Grain Size", defaultValue: 0.25 },
{ key: "grainRoughness", label: "Roughness", defaultValue: 0.5 },
];
const detailByKey = (key: HfColorGradingDetailKey) => {
const spec = DETAIL_SLIDERS.find((d) => d.key === key);
const spec = COLOR_GRADING_DETAIL_SLIDERS.find((candidate) => candidate.key === key);
if (!spec) throw new Error(`Unknown color grading detail key: ${key}`);
return spec;
};
const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [
"vignetteMidpoint",
"vignetteRoundness",
"vignetteFeather",
];
const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"];
function resolveColorGrading(grading: Parameters<typeof normalizeHfColorGrading>[0]) {
const resolved = normalizeHfColorGrading(grading);
if (!resolved) throw new Error("Missing resolved color grading");
return resolved;
}
function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
if (metadata?.color.dynamicRange !== "hdr") return null;
@@ -237,6 +109,7 @@ export function FlatColorGradingSection({
mediaMetadata,
presetPreviews,
onRequestPresetPreviews,
captureGradedFrame,
}: {
grading: NormalizedHfColorGrading;
assets: string[];
@@ -254,6 +127,7 @@ export function FlatColorGradingSection({
mediaMetadata: MediaMetadata | null;
presetPreviews: ColorGradingPresetPreviews;
onRequestPresetPreviews: () => void;
captureGradedFrame: ColorGradingControllerState["captureGradedFrame"];
}) {
const track = useTrackDesignInput();
const lutInputRef = useRef<HTMLInputElement>(null);
@@ -265,70 +139,65 @@ export function FlatColorGradingSection({
);
const lut = grading.lut;
const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null;
const resolvedGrading = useMemo(() => resolveColorGrading(grading), [grading]);
const secondaryInputGrading = useMemo(
() =>
resolveColorGrading({
intensity: 1,
adjust: resolvedGrading.adjust,
wheels: resolvedGrading.wheels,
curves: resolvedGrading.curves,
hueCurves: resolvedGrading.hueCurves,
colorSpace: resolvedGrading.colorSpace,
}),
[
resolvedGrading.adjust,
resolvedGrading.colorSpace,
resolvedGrading.curves,
resolvedGrading.hueCurves,
resolvedGrading.wheels,
],
);
const actions = createColorGradingActions(grading, onCommitColorGrading);
const scopesRefreshKey = useMemo(() => serializeHfColorGrading(grading), [grading]);
useEffect(() => {
if (
presetPreviews.status !== "loading" &&
HF_COLOR_GRADING_GRADE_PRESETS.some((preset) => !presetPreviews.images[preset.id])
) {
onRequestPresetPreviews();
}
}, [onRequestPresetPreviews, presetPreviews.images, presetPreviews.status]);
if (presetPreviews.status === "idle") onRequestPresetPreviews();
}, [onRequestPresetPreviews, presetPreviews.status]);
const resolvePreset = (presetId: string) => {
const resolved = normalizeHfColorGrading({ preset: presetId, lut: grading.lut });
return resolved ? { ...resolved, effects: grading.effects, palette: grading.palette } : grading;
return resolved
? {
...resolved,
effects: grading.effects,
palette: grading.palette,
}
: grading;
};
useEffect(() => () => onPreviewColorGrading(null), [onPreviewColorGrading]);
const updateIntensity = (value: number) => {
onCommitColorGrading({ ...grading, intensity: value / 100 });
};
const applyLut = (src: string | null, intensity = 1) => {
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
lut: src ? { src, intensity } : null,
});
};
const importLuts = async (files: FileList | null) => {
if (!files?.length || !onImportAssets) return;
const uploaded = await onImportAssets(files, "assets/luts");
const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
if (firstLut) {
track("button", "Import LUT");
applyLut(firstLut, 1);
}
};
const renderDetailSlider = (key: HfColorGradingDetailKey) => {
const spec = detailByKey(key);
const value = grading.details[key];
const isSet = Math.abs(value - spec.defaultValue) > 1e-4;
const defaultValue = normalizedColorGradingDefault(spec);
const isSet = Math.abs(value - defaultValue) > 1e-4;
return (
<FlatSlider
key={key}
label={spec.label}
value={Math.round(value * 100)}
min={key === "vignetteRoundness" ? -100 : 0}
max={100}
value={Math.round(value * spec.scale)}
min={spec.min}
max={spec.max}
step={spec.step}
tier={isSet ? "explicitCustom" : "default"}
displayValue={`${Math.round(value * 100)}%`}
displayValue={`${Math.round(value * spec.scale)}${spec.suffix}`}
centerTick={key === "vignetteRoundness"}
onCommit={(next) =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: { ...grading.details, [key]: next / 100 },
})
}
onReset={() =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: { ...grading.details, [key]: spec.defaultValue },
})
onCommitColorGrading(colorGradingWithDetail(grading, key, next / spec.scale))
}
onReset={() => onCommitColorGrading(colorGradingWithDetail(grading, key, defaultValue))}
/>
);
};
@@ -336,7 +205,20 @@ export function FlatColorGradingSection({
return (
<div className="space-y-1.5">
<HdrBanner metadata={mediaMetadata} />
<PropertyPanelColorScopes
captureFrame={() => captureGradedFrame()}
refreshKey={scopesRefreshKey}
/>
<div data-flat-grade-presets="true" className="space-y-1.5">
{presetPreviews.status === "unavailable" && (
<button
type="button"
onClick={onRequestPresetPreviews}
className="text-[10px] font-medium text-panel-accent hover:text-panel-accent/80"
>
Retry look previews
</button>
)}
<div data-flat-grade-preset-group="presets" className={FLAT_PREVIEW_GRID}>
{HF_COLOR_GRADING_GRADE_PRESETS.map((preset) => {
const label = preset.label;
@@ -395,10 +277,105 @@ export function FlatColorGradingSection({
max={100}
tier={grading.intensity === 1 ? "default" : "explicitCustom"}
displayValue={`${Math.round(grading.intensity * 100)}%`}
onCommit={updateIntensity}
onReset={() => updateIntensity(100)}
onCommit={actions.setIntensityPercent}
onReset={() => actions.setIntensityPercent(100)}
/>
<div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Primary
</div>
{COLOR_GRADING_ADJUST_SLIDERS.map((slider) => {
const rawPercent = grading.adjust[slider.key] * slider.scale;
const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6;
return (
<div key={slider.key} data-flat-grade-adjust="true">
<FlatSlider
label={slider.label}
value={rawPercent}
min={slider.min}
max={slider.max}
step={slider.step}
tier={isSet ? "explicitCustom" : "default"}
displayValue={formatAdjustValue(slider.key, rawPercent)}
centerTick
onCommit={(next) =>
onCommitColorGrading(
colorGradingWithAdjust(grading, slider.key, next / slider.scale),
)
}
onReset={() => onCommitColorGrading(colorGradingWithAdjust(grading, slider.key, 0))}
/>
</div>
);
})}
</div>
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Color wheels
</div>
<ColorWheels
value={resolvedGrading.wheels}
onPreview={(wheels) =>
onPreviewColorGrading({
...grading,
intensity: visibleColorGradingIntensity(grading),
wheels,
})
}
onCommit={(wheels) =>
onCommitColorGrading({
...grading,
intensity: visibleColorGradingIntensity(grading),
wheels,
})
}
/>
</div>
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Curves
</div>
<ColorCurves
value={{ curves: resolvedGrading.curves, hueCurves: resolvedGrading.hueCurves }}
onPreview={({ curves, hueCurves }) =>
onPreviewColorGrading({
...grading,
intensity: visibleColorGradingIntensity(grading),
curves,
hueCurves,
})
}
onCommit={({ curves, hueCurves }) =>
onCommitColorGrading({
...grading,
intensity: visibleColorGradingIntensity(grading),
curves,
hueCurves,
})
}
/>
</div>
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Secondary color
</div>
<PropertyPanelColorSecondary
secondaries={resolvedGrading.secondaries}
captureFrame={() => captureGradedFrame({ grading: secondaryInputGrading })}
onCommit={(secondaries) =>
onCommitColorGrading({
...grading,
intensity: visibleColorGradingIntensity(grading),
secondaries,
})
}
/>
</div>
<div className="border-t border-panel-hairline pt-1.5">
<button
type="button"
@@ -430,7 +407,7 @@ export function FlatColorGradingSection({
onChange={(e) => {
const src = e.target.value;
track("select", "Custom LUT");
applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
actions.applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
}}
className="border-b border-panel-border-input/50 bg-transparent font-mono text-[10px] text-panel-text-3 outline-none hover:border-panel-border-input"
>
@@ -456,7 +433,9 @@ export function FlatColorGradingSection({
accept=".cube"
className="hidden"
onChange={(e) => {
void importLuts(e.currentTarget.files);
void actions.importLut(e.currentTarget.files, onImportAssets, () =>
track("button", "Import LUT"),
);
e.currentTarget.value = "";
}}
/>
@@ -469,52 +448,14 @@ export function FlatColorGradingSection({
max={100}
tier={lut.intensity === 1 ? "default" : "explicitCustom"}
displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
onCommit={(v) => applyLut(lut.src, v / 100)}
onReset={() => applyLut(lut.src, 1)}
onCommit={(v) => actions.applyLut(lut.src, v / 100)}
onReset={() => actions.applyLut(lut.src, 1)}
/>
)}
</div>
)}
</div>
<div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Adjust
</div>
{ADJUST_SLIDERS.map((slider) => {
const rawPercent = grading.adjust[slider.key] * 100;
const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6;
return (
<div key={slider.key} data-flat-grade-adjust="true">
<FlatSlider
label={slider.label}
value={rawPercent}
min={slider.min}
max={slider.max}
step={slider.step}
tier={isSet ? "explicitCustom" : "default"}
displayValue={formatAdjustValue(slider.key, rawPercent)}
centerTick
onCommit={(next) =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
adjust: { ...grading.adjust, [slider.key]: next / 100 },
})
}
onReset={() =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
adjust: { ...grading.adjust, [slider.key]: 0 },
})
}
/>
</div>
);
})}
</div>
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Finish
@@ -545,8 +486,8 @@ export function FlatColorGradingSection({
</div>
{detailSettingsOpen && (
<div className="space-y-0.5 border-l-2 border-panel-border-input pl-2.5">
{(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map(
renderDetailSlider,
{(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS).map(
(slider) => renderDetailSlider(slider.key),
)}
</div>
)}
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GradingNumberField } from "./propertyPanelGradingNumberField";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function changeInput(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!setter) throw new Error("Expected the native input setter");
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function renderField() {
const callbacks = {
onBegin: vi.fn(),
onPreview: vi.fn(),
onSettle: vi.fn(),
onCancel: vi.fn(),
};
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
act(() =>
root.render(
<GradingNumberField label="Level" value={10} min={-100} max={100} {...callbacks} />,
),
);
const input = host.querySelector("input");
if (!input) throw new Error("Expected a grading field");
return { root, input, callbacks };
}
describe("GradingNumberField", () => {
it("does not begin or settle an untouched focus and blur", () => {
const { root, input, callbacks } = renderField();
act(() => input.focus());
act(() => input.blur());
expect(callbacks.onBegin).not.toHaveBeenCalled();
expect(callbacks.onPreview).not.toHaveBeenCalled();
expect(callbacks.onSettle).not.toHaveBeenCalled();
expect(callbacks.onCancel).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("cancels a real edit that returns to its baseline", () => {
const { root, input, callbacks } = renderField();
act(() => input.focus());
act(() => changeInput(input, "25"));
act(() => changeInput(input, "10"));
act(() => input.blur());
expect(callbacks.onBegin).toHaveBeenCalledOnce();
expect(callbacks.onSettle).not.toHaveBeenCalled();
expect(callbacks.onCancel).toHaveBeenCalledOnce();
act(() => root.unmount());
});
it("previews valid text and settles once on Enter", () => {
const { root, input, callbacks } = renderField();
act(() => input.focus());
act(() => changeInput(input, "-25"));
act(() => {
input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
});
expect(callbacks.onBegin).toHaveBeenCalledOnce();
expect(callbacks.onPreview).toHaveBeenLastCalledWith(-25);
expect(callbacks.onSettle).toHaveBeenCalledOnce();
act(() => root.unmount());
});
});
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from "react";
import { clampNumber } from "../../utils/studioHelpers";
export function GradingNumberField({
label,
ariaLabel = label,
value,
min,
max,
disabled,
formatValue = String,
labelClassName = "min-w-0",
labelTextClassName = "block",
inputClassName = "w-full",
onBegin,
onPreview,
onSettle,
onCancel,
}: {
label: string;
ariaLabel?: string;
value: number;
min: number;
max: number;
disabled?: boolean;
formatValue?: (value: number) => string;
labelClassName?: string;
labelTextClassName?: string;
inputClassName?: string;
onBegin: () => void;
onPreview: (value: number) => void;
onSettle: () => void;
onCancel: () => void;
}) {
const [draft, setDraft] = useState(() => formatValue(value));
const focusedRef = useRef(false);
const cancelBlurRef = useRef(false);
const baselineRef = useRef(value);
const dirtyRef = useRef(false);
useEffect(() => {
if (!focusedRef.current) setDraft(formatValue(value));
}, [formatValue, value]);
const settle = () => {
focusedRef.current = false;
if (cancelBlurRef.current) {
cancelBlurRef.current = false;
return;
}
const parsed = Number(draft);
if (draft.trim() === "" || !Number.isFinite(parsed)) {
setDraft(formatValue(value));
if (dirtyRef.current) onCancel();
dirtyRef.current = false;
return;
}
const next = clampNumber(parsed, min, max);
setDraft(formatValue(next));
if (!dirtyRef.current || Object.is(next, baselineRef.current)) {
if (dirtyRef.current) onCancel();
dirtyRef.current = false;
return;
}
onPreview(next);
onSettle();
dirtyRef.current = false;
};
return (
<label className={labelClassName}>
<span className={labelTextClassName}>{label}</span>
<input
type="text"
inputMode="decimal"
aria-label={ariaLabel}
value={draft}
disabled={disabled}
onFocus={() => {
focusedRef.current = true;
baselineRef.current = value;
dirtyRef.current = false;
setDraft(formatValue(value));
}}
onChange={(event) => {
const next = event.target.value;
setDraft(next);
if (next.trim() === "") return;
const parsed = Number(next);
if (!Number.isFinite(parsed)) return;
const clamped = clampNumber(parsed, min, max);
if (!Object.is(clamped, baselineRef.current) && !dirtyRef.current) {
dirtyRef.current = true;
onBegin();
}
if (dirtyRef.current) onPreview(clamped);
}}
onBlur={settle}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelBlurRef.current = true;
focusedRef.current = false;
setDraft(formatValue(value));
if (dirtyRef.current) onCancel();
dirtyRef.current = false;
event.currentTarget.blur();
} else if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
className={`border-b border-panel-border-input/50 bg-transparent py-0.5 text-right font-mono text-[9px] text-panel-text-2 outline-none focus:border-panel-accent disabled:opacity-40 ${inputClassName}`}
/>
</label>
);
}
@@ -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,
}),
);
});
@@ -129,6 +139,21 @@ function createPreviewFrame() {
return { contentWindow, iframe };
}
function installPreviewRenderer(contentWindow: PreviewWindow) {
const renderPreviews = vi
.fn()
.mockImplementation(async (_target: unknown, candidates: Array<{ id: string }>) => ({
width: 160,
height: 90,
images: candidates.map(({ id }) => ({
id,
dataUrl: `data:image/png;base64,${id}`,
})),
}));
contentWindow.__hf = { colorGrading: { renderPreviews } };
return renderPreviews;
}
async function flushPreviewRequest() {
act(() => vi.advanceTimersByTime(0));
await act(async () => {
@@ -161,6 +186,48 @@ describe("useColorGradingController", () => {
vi.useFakeTimers();
const devicePixelRatio = vi.spyOn(window, "devicePixelRatio", "get").mockReturnValue(2);
const { contentWindow, iframe } = createPreviewFrame();
const renderPreviews = installPreviewRenderer(contentWindow);
const initialElement = makeElement({
dataAttributes: {
"color-grading": JSON.stringify({
wheels: { shadows: { hue: 210, amount: 0.2 } },
effects: { pixelate: 0.4 },
palette: ["#112233", "#ffffff"],
}),
},
});
const { root, getState } = renderHook(vi.fn(), initialElement, { current: iframe });
act(() => getState().requestPresetPreviews());
await flushPreviewRequest();
expect(renderPreviews).toHaveBeenCalledTimes(1);
expect(renderPreviews.mock.calls[0]?.[1]).toHaveLength(18);
expect(renderPreviews.mock.calls[0]?.[1]).toContainEqual({
id: "bright-pop",
grading: expect.objectContaining({
wheels: expect.objectContaining({
shadows: expect.objectContaining({ amount: 0 }),
}),
effects: expect.objectContaining({ pixelate: 0.4 }),
palette: ["#112233", "#ffffff"],
}),
});
expect(renderPreviews.mock.calls[0]?.[2]).toEqual({ maxDimension: 320 });
expect(getState().presetPreviews).toEqual({
status: "ready",
images: expect.objectContaining({ "bright-pop": "data:image/png;base64,bright-pop" }),
width: 160,
height: 90,
});
act(() => root.unmount());
devicePixelRatio.mockRestore();
vi.useRealTimers();
});
it("retains partial preset previews and exposes retry after the batch times out", async () => {
vi.useFakeTimers();
const { contentWindow, iframe } = createPreviewFrame();
const renderPreviews = vi.fn().mockResolvedValue({
width: 160,
height: 90,
@@ -171,32 +238,81 @@ describe("useColorGradingController", () => {
act(() => getState().requestPresetPreviews());
await flushPreviewRequest();
expect(renderPreviews).toHaveBeenCalledTimes(1);
expect(renderPreviews.mock.calls[0]?.[1]).toHaveLength(18);
expect(renderPreviews.mock.calls[0]?.[2]).toEqual({ maxDimension: 320 });
expect(getState().presetPreviews).toEqual({
status: "ready",
expect(getState().presetPreviews).toMatchObject({
status: "loading",
images: { "bright-pop": "data:image/png;base64,bright" },
width: 160,
height: 90,
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1700);
});
expect(getState().presetPreviews).toMatchObject({
status: "unavailable",
images: { "bright-pop": "data:image/png;base64,bright" },
});
expect(renderPreviews.mock.calls.length).toBeGreaterThan(1);
act(() => root.unmount());
devicePixelRatio.mockRestore();
vi.useRealTimers();
});
it("persists a disabled secondary even though it does not render pixels", () => {
vi.useFakeTimers();
const onSetAttributeLive = vi.fn();
const grading = normalizeHfColorGrading({
secondaries: [
{
enabled: false,
key: { hue: { center: 215, range: 25 } },
correction: { saturation: 0.15 },
},
],
});
if (!grading) throw new Error("expected secondary grading");
const { root, getState } = renderHook(onSetAttributeLive);
act(() => getState().commitColorGrading(grading));
act(() => vi.advanceTimersByTime(400));
expect(onSetAttributeLive.mock.calls[0]?.[0]).toBe("color-grading");
expect(onSetAttributeLive.mock.calls[0]?.[1]).toContain('"enabled":false');
act(() => root.unmount());
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();
const renderPreviews = vi
.fn()
.mockImplementation(async (_target: unknown, candidates: Array<{ id: string }>) => ({
width: 160,
height: 90,
images: candidates.map(({ id }) => ({ id, dataUrl: `data:image/png;base64,${id}` })),
}));
contentWindow.__hf = { colorGrading: { renderPreviews } };
const renderPreviews = installPreviewRenderer(contentWindow);
const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
act(() => getState().requestEffectPreviews(["blur", "pixelate", "bloom"]));
@@ -490,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",
@@ -507,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"],
});
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
HF_COLOR_GRADING_ATTR,
hasHfColorGradingAuthoredValues,
isHfColorGradingActive,
normalizeHfColorGrading,
serializeHfColorGrading,
@@ -21,6 +22,7 @@ import {
} from "../../player/lib/runtimeProtocol";
import {
useColorGradingPreviews,
type ColorGradingCapturedFrame,
type ColorGradingPresetPreviews,
type ColorGradingPreviewOptions,
} from "./useColorGradingPreviews";
@@ -169,6 +171,9 @@ export interface ColorGradingControllerState {
next: NormalizedHfColorGrading | null,
options?: ColorGradingPreviewOptions,
) => void;
captureGradedFrame: (options?: {
grading?: NormalizedHfColorGrading;
}) => Promise<ColorGradingCapturedFrame | null>;
commitCompare: (enabled: boolean) => void;
setApplyScope: (scope: "source-file" | "project") => void;
applyToScope: () => Promise<void>;
@@ -472,7 +477,7 @@ export function useColorGradingController({
}
scheduleRuntimeStatusRefresh();
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
pendingPersistValueRef.current = hasHfColorGradingAuthoredValues(nextGrading)
? serializeHfColorGrading(nextGrading)
: null;
pendingPersistGradingRef.current = nextGrading;
@@ -519,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);
@@ -539,6 +546,7 @@ export function useColorGradingController({
requestEffectPreviews: previewController.requestEffectPreviews,
commitColorGrading,
previewColorGrading: previewController.previewColorGrading,
captureGradedFrame: previewController.captureGradedFrame,
commitCompare,
setApplyScope,
applyToScope,
@@ -546,6 +554,7 @@ export function useColorGradingController({
const neutral = defaultColorGrading();
commitColorGrading({
...neutral,
lut: latestGradingRef.current.lut,
effects: latestGradingRef.current.effects,
palette: latestGradingRef.current.palette,
});
@@ -16,6 +16,12 @@ export interface ColorGradingPresetPreviews {
height: number;
}
export interface ColorGradingCapturedFrame {
dataUrl: string;
width: number;
height: number;
}
type ColorGradingPreviewKind = "presets" | "effects";
export interface ColorGradingPreviewOptions {
@@ -78,12 +84,21 @@ function toPreviewColorGrading(grading: NormalizedHfColorGrading): unknown {
function previewCandidates(
request: PreviewRequest,
lut: NormalizedHfColorGrading["lut"],
grading: Pick<NormalizedHfColorGrading, "effects" | "lut" | "palette">,
): Array<{ id: string; grading: unknown }> {
if (request.kind === "presets") {
return HF_COLOR_GRADING_PRESETS.map((preset) => {
const resolved = normalizeHfColorGrading({ preset: preset.id, lut });
return { id: preset.id, grading: resolved ? toPreviewColorGrading(resolved) : null };
const resolved = normalizeHfColorGrading({ preset: preset.id, lut: grading.lut });
return {
id: preset.id,
grading: resolved
? toPreviewColorGrading({
...resolved,
effects: grading.effects,
palette: grading.palette,
})
: null,
};
});
}
return (request.effects ?? HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS).map((effect) => {
@@ -98,16 +113,19 @@ async function renderRequestedPreviews(
runtime: RuntimeColorGradingPreview,
target: HfColorGradingTarget,
request: PreviewRequest,
lut: NormalizedHfColorGrading["lut"],
grading: Pick<NormalizedHfColorGrading, "effects" | "lut" | "palette">,
) {
const batch = await runtime.renderPreviews(target, previewCandidates(request, lut), {
const candidates = previewCandidates(request, grading);
const batch = await runtime.renderPreviews(target, candidates, {
maxDimension: previewMaxDimension(),
});
if (!batch) return null;
const images = Object.fromEntries(
batch.images.flatMap((image) => (image.dataUrl ? [[image.id, image.dataUrl]] : [])),
);
return Object.keys(images).length ? { ...batch, images } : null;
return Object.keys(images).length
? { ...batch, images, complete: Object.keys(images).length === candidates.length }
: null;
}
export function useColorGradingPreviews({
@@ -154,6 +172,7 @@ export function useColorGradingPreviews({
let cancelled = false;
let complete = false;
let inFlight = false;
let timedOut = false;
const timers: number[] = [];
setState((current) => ({
...current,
@@ -169,15 +188,19 @@ export function useColorGradingPreviews({
if (!runtime) return;
inFlight = true;
try {
const result = await renderRequestedPreviews(runtime, target, request, grading.lut);
const result = await renderRequestedPreviews(runtime, target, request, {
effects: grading.effects,
lut: grading.lut,
palette: grading.palette,
});
if (cancelled || !result) return;
complete = true;
complete = result.complete;
setState((current) => ({
...current,
previews: {
...current.previews,
[kind]: {
status: "ready",
status: result.complete ? "ready" : timedOut ? "unavailable" : "loading",
images:
kind === "effects"
? { ...current.previews[kind].images, ...result.images }
@@ -206,11 +229,12 @@ export function useColorGradingPreviews({
timers.push(
window.setTimeout(() => {
if (cancelled || complete) return;
timedOut = true;
setState((current) => ({
...current,
previews: {
...current.previews,
[kind]: { status: "unavailable", images: {}, width: 16, height: 9 },
[kind]: { ...current.previews[kind], status: "unavailable" },
},
}));
}, 1600),
@@ -221,7 +245,7 @@ export function useColorGradingPreviews({
iframe.removeEventListener("load", attempt);
window.removeEventListener("message", onMessage);
};
}, [grading.lut, previewIframeRef, request, target]);
}, [grading.effects, grading.lut, grading.palette, previewIframeRef, request, target]);
const stopAnimatedPreview = useCallback(() => {
const session = animatedRef.current;
@@ -296,6 +320,24 @@ export function useColorGradingPreviews({
(effects: readonly HfColorGradingActiveEffectKey[]) => requestPreviews("effects", effects),
[requestPreviews],
);
const captureGradedFrame = useCallback(
async ({
grading: requestedGrading = gradingRef.current,
}: {
grading?: NormalizedHfColorGrading;
} = {}): Promise<ColorGradingCapturedFrame | null> => {
const runtime = readRuntime(previewIframeRef?.current);
if (!runtime) return null;
const batch = await runtime.renderPreviews(
target,
[{ id: "capture", grading: toPreviewColorGrading(requestedGrading) }],
{ maxDimension: 320, useMediaTime: true },
);
const dataUrl = batch?.images[0]?.dataUrl;
return batch && dataUrl ? { dataUrl, width: batch.width, height: batch.height } : null;
},
[gradingRef, previewIframeRef, target],
);
return {
presetPreviews: previews.presets,
@@ -303,5 +345,6 @@ export function useColorGradingPreviews({
requestPresetPreviews,
requestEffectPreviews,
previewColorGrading,
captureGradedFrame,
};
}
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
import { useColorGradingScopes } from "./useColorGradingScopes";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
vi.useRealTimers();
vi.restoreAllMocks();
});
function Harness({
open,
refreshKey,
captureFrame,
}: {
open: boolean;
refreshKey: string;
captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
}) {
const scopes = useColorGradingScopes({ open, refreshKey, captureFrame });
return (
<>
<span data-status>{scopes.status}</span>
<span data-analysis>{scopes.analysis ? "ready" : "empty"}</span>
<button type="button" onClick={scopes.refresh}>
Refresh
</button>
</>
);
}
function installPixelDecode() {
vi.spyOn(HTMLImageElement.prototype, "decode").mockResolvedValue();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn().mockReturnValue({
data: new Uint8ClampedArray([255, 255, 255, 255]),
}),
} as unknown as CanvasRenderingContext2D);
}
function renderScopeHarness({ open, refreshKey = "a" }: { open: boolean; refreshKey?: string }) {
vi.useFakeTimers();
installPixelDecode();
const captureFrame = vi.fn().mockResolvedValue({
dataUrl: "data:image/png;base64,scope",
width: 1,
height: 1,
});
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
act(() =>
root.render(<Harness open={open} refreshKey={refreshKey} captureFrame={captureFrame} />),
);
return { captureFrame, host, root };
}
describe("useColorGradingScopes", () => {
it("captures once on open rather than polling during playback", async () => {
const { captureFrame, host, root } = renderScopeHarness({ open: false });
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(captureFrame).not.toHaveBeenCalled();
act(() => root.render(<Harness open refreshKey="a" captureFrame={captureFrame} />));
await act(async () => vi.advanceTimersByTimeAsync(180));
expect(captureFrame).toHaveBeenCalledOnce();
expect(host.querySelector("[data-status]")?.textContent).toBe("ready");
await act(async () => vi.advanceTimersByTimeAsync(5000));
expect(captureFrame).toHaveBeenCalledOnce();
act(() => root.unmount());
});
it("refreshes only after a committed grading key or explicit request", async () => {
const { captureFrame, host, root } = renderScopeHarness({ open: true });
await act(async () => vi.advanceTimersByTimeAsync(180));
act(() => root.render(<Harness open refreshKey="b" captureFrame={captureFrame} />));
await act(async () => vi.advanceTimersByTimeAsync(180));
expect(captureFrame).toHaveBeenCalledTimes(2);
act(() => {
host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => vi.advanceTimersByTimeAsync(180));
expect(captureFrame).toHaveBeenCalledTimes(3);
act(() => root.unmount());
});
it("reports unavailable when capture returns no frame", async () => {
vi.useFakeTimers();
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
act(() =>
root.render(<Harness open refreshKey="a" captureFrame={vi.fn().mockResolvedValue(null)} />),
);
await act(async () => vi.advanceTimersByTimeAsync(180));
expect(host.querySelector("[data-status]")?.textContent).toBe("unavailable");
act(() => root.unmount());
});
it("clears stale analysis while a refresh is pending and when it is unavailable", async () => {
vi.useFakeTimers();
installPixelDecode();
let resolveRefresh: (frame: ColorGradingCapturedFrame | null) => void = () => {};
const pendingRefresh = new Promise<ColorGradingCapturedFrame | null>((resolve) => {
resolveRefresh = resolve;
});
const captureFrame = vi
.fn()
.mockResolvedValueOnce({
dataUrl: "data:image/png;base64,scope",
width: 1,
height: 1,
})
.mockReturnValueOnce(pendingRefresh);
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
act(() => root.render(<Harness open refreshKey="a" captureFrame={captureFrame} />));
await act(async () => vi.advanceTimersByTimeAsync(180));
expect(host.querySelector("[data-analysis]")?.textContent).toBe("ready");
act(() => {
host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
act(() => vi.advanceTimersByTime(180));
expect(host.querySelector("[data-status]")?.textContent).toBe("loading");
expect(host.querySelector("[data-analysis]")?.textContent).toBe("empty");
await act(async () => resolveRefresh(null));
expect(host.querySelector("[data-status]")?.textContent).toBe("unavailable");
expect(host.querySelector("[data-analysis]")?.textContent).toBe("empty");
act(() => root.unmount());
});
});
@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from "react";
import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
import {
analyzeColorGradingFrame,
readColorGradingFramePixels,
type ColorGradingScopeAnalysis,
} from "./colorGradingFrameAnalysis";
type ColorGradingScopeStatus = "idle" | "loading" | "ready" | "unavailable";
const CAPTURE_DEBOUNCE_MS = 180;
export function useColorGradingScopes({
open,
captureFrame,
refreshKey,
}: {
open: boolean;
captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
refreshKey: string;
}) {
const [analysis, setAnalysis] = useState<ColorGradingScopeAnalysis | null>(null);
const [status, setStatus] = useState<ColorGradingScopeStatus>("idle");
const [refreshVersion, setRefreshVersion] = useState(0);
const captureRef = useRef(captureFrame);
captureRef.current = captureFrame;
useEffect(() => {
if (!open) {
setAnalysis(null);
setStatus("idle");
return;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
setAnalysis(null);
setStatus("loading");
try {
const frame = await captureRef.current();
if (cancelled) return;
if (!frame) {
setStatus("unavailable");
return;
}
const pixels = await readColorGradingFramePixels(frame);
if (!pixels) throw new Error("Canvas 2D unavailable");
const next = analyzeColorGradingFrame(pixels, frame.width, frame.height);
if (!cancelled) {
setAnalysis(next);
setStatus("ready");
}
} catch {
if (!cancelled) setStatus("unavailable");
}
}, CAPTURE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [open, refreshKey, refreshVersion]);
return { analysis, status, refresh: () => setRefreshVersion((version) => version + 1) };
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
/** One owner for continuous inspector edits: preview freely, persist once. */
export function useInspectorGestureTransaction<T>({
@@ -58,3 +58,32 @@ export function useInspectorGestureTransaction<T>({
return { begin, preview, settle, cancel, activeRef };
}
export function useInspectorGestureDraft<T>({
sourceValue,
onPreview,
onCommit,
}: {
sourceValue: T;
onPreview: (value: T) => void;
onCommit: (value: T) => void;
}) {
const [draft, setDraft] = useState(sourceValue);
const transaction = useInspectorGestureTransaction({
sourceValue,
onPreview: (next) => {
setDraft(next);
onPreview(next);
},
onCommit: (next) => {
setDraft(next);
onCommit(next);
},
});
useEffect(() => {
if (!transaction.activeRef.current) setDraft(sourceValue);
}, [sourceValue, transaction.activeRef]);
return { draft, setDraft, transaction };
}
@@ -22,6 +22,8 @@ import {
Gear,
Scissors as PhScissors,
Link as PhLink,
Eyedropper as PhEyedropper,
Trash as PhTrash,
} from "@phosphor-icons/react";
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -71,3 +73,5 @@ export const RotateCw = makeIcon(ArrowClockwise);
export const Settings = makeIcon(Gear);
export const Scissors = makeIcon(PhScissors);
export const Link = makeIcon(PhLink);
export const Eyedropper = makeIcon(PhEyedropper);
export const Trash = makeIcon(PhTrash);