mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2797 from heygen-com/feat/studio-professional-color-grading
feat(studio): add professional grading controls
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+168
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user