mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
feat(studio): add professional grading controls
This commit is contained in:
@@ -427,6 +427,7 @@ export function PropertyPanelFlat({
|
|||||||
mediaMetadata={colorGradingController.mediaMetadata}
|
mediaMetadata={colorGradingController.mediaMetadata}
|
||||||
presetPreviews={colorGradingController.presetPreviews}
|
presetPreviews={colorGradingController.presetPreviews}
|
||||||
onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
|
onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
|
||||||
|
captureGradedFrame={colorGradingController.captureGradedFrame}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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 shader's half-softness edge convention", () => {
|
||||||
|
const key = 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 belowOuterEdge = colorGradingSecondaryMask(77, 77, 77, key);
|
||||||
|
const middleOfFeather = colorGradingSecondaryMask(112, 77, 77, key);
|
||||||
|
expect(belowOuterEdge).toBe(0);
|
||||||
|
expect(middleOfFeather).toBeGreaterThan(0);
|
||||||
|
expect(middleOfFeather).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,235 @@
|
|||||||
|
import 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function softRangeMask(value: number, min: number, max: number, softness: number): number {
|
||||||
|
if (value < min) {
|
||||||
|
if (softness <= 0 || value <= min - softness) return 0;
|
||||||
|
const t = (value - (min - softness)) / softness;
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
if (value > max) {
|
||||||
|
if (softness <= 0 || value >= max + softness) return 0;
|
||||||
|
const t = (value - max) / softness;
|
||||||
|
return 1 - t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorGradingSecondaryMask(
|
||||||
|
red: number,
|
||||||
|
green: number,
|
||||||
|
blue: number,
|
||||||
|
key: NormalizedHfColorGradingSecondary["key"],
|
||||||
|
): number {
|
||||||
|
const hsv = rgbToHsv(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX);
|
||||||
|
const hueDistance = Math.abs(((((hsv.hue - key.hue.center + 540) % 360) + 360) % 360) - 180);
|
||||||
|
const hueMask =
|
||||||
|
key.hue.range >= 179.999
|
||||||
|
? 1
|
||||||
|
: hsv.saturation < 0.001
|
||||||
|
? 0
|
||||||
|
: softRangeMask(
|
||||||
|
hueDistance,
|
||||||
|
0,
|
||||||
|
key.hue.range,
|
||||||
|
Math.min(key.hue.softness, 180 - key.hue.range),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
hueMask *
|
||||||
|
softRangeMask(
|
||||||
|
hsv.saturation,
|
||||||
|
key.saturation.min,
|
||||||
|
key.saturation.max,
|
||||||
|
key.saturation.softness * 0.5,
|
||||||
|
) *
|
||||||
|
softRangeMask(
|
||||||
|
rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX),
|
||||||
|
key.luma.min,
|
||||||
|
key.luma.max,
|
||||||
|
key.luma.softness * 0.5,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,134 @@
|
|||||||
|
// @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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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;
|
||||||
|
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 LUT_UPLOAD_DIR = "assets/luts";
|
||||||
|
|
||||||
const ADJUST_SLIDERS: Array<{
|
export const COLOR_GRADING_ADJUST_SLIDERS: Array<{
|
||||||
key: HfColorGradingAdjustKey;
|
key: HfColorGradingAdjustKey;
|
||||||
label: string;
|
label: string;
|
||||||
min: number;
|
min: number;
|
||||||
@@ -60,7 +60,7 @@ const ADJUST_SLIDERS: Array<{
|
|||||||
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
|
{ 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;
|
key: HfColorGradingDetailKey;
|
||||||
label: string;
|
label: string;
|
||||||
min: number;
|
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 = {
|
type SliderSettings = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -143,28 +143,111 @@ const EFFECT_SLIDERS: Array<{
|
|||||||
{ key: "pixelate", label: "Pixelate", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
|
{ 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",
|
(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) =>
|
||||||
slider.key === "vignetteMidpoint" ||
|
slider.key === "vignetteMidpoint" ||
|
||||||
slider.key === "vignetteRoundness" ||
|
slider.key === "vignetteRoundness" ||
|
||||||
slider.key === "vignetteFeather",
|
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",
|
(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;
|
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.
|
// Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
|
||||||
return grading.intensity === 0 ? 1 : grading.intensity;
|
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({
|
export function ColorGradingControls({
|
||||||
grading,
|
grading,
|
||||||
assets,
|
assets,
|
||||||
@@ -189,11 +272,14 @@ export function ColorGradingControls({
|
|||||||
const detailSettingsSliders =
|
const detailSettingsSliders =
|
||||||
detailSettings === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS;
|
detailSettings === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS;
|
||||||
const vignetteSettingsActive = VIGNETTE_TUNE_SLIDERS.some(
|
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(
|
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 applyPreset = (preset: string) => {
|
||||||
const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
|
const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
|
||||||
@@ -202,51 +288,13 @@ export function ColorGradingControls({
|
|||||||
onCommitColorGrading(next);
|
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) => {
|
const commitDetailSlider = (slider: DetailSlider, next: number) => {
|
||||||
onCommitColorGrading({
|
onCommitColorGrading(colorGradingWithDetail(grading, slider.key, next / slider.scale));
|
||||||
...grading,
|
|
||||||
intensity: visibleIntensity(grading),
|
|
||||||
details: {
|
|
||||||
...grading.details,
|
|
||||||
[slider.key]: next / slider.scale,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const resetDetailSlider = (slider: DetailSlider) => {
|
const resetDetailSlider = (slider: DetailSlider) => {
|
||||||
onCommitColorGrading({
|
onCommitColorGrading(
|
||||||
...grading,
|
colorGradingWithDetail(grading, slider.key, normalizedColorGradingDefault(slider)),
|
||||||
intensity: visibleIntensity(grading),
|
);
|
||||||
details: {
|
|
||||||
...grading.details,
|
|
||||||
[slider.key]: normalizedDefaultValue(slider),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const renderDetailSlider = (slider: DetailSlider, settings?: SliderSettings) => {
|
const renderDetailSlider = (slider: DetailSlider, settings?: SliderSettings) => {
|
||||||
const value = Math.round(grading.details[slider.key] * slider.scale);
|
const value = Math.round(grading.details[slider.key] * slider.scale);
|
||||||
@@ -293,8 +341,8 @@ export function ColorGradingControls({
|
|||||||
neutral={0}
|
neutral={0}
|
||||||
suffix="%"
|
suffix="%"
|
||||||
displayValue={`${Math.round(grading.intensity * 100)}%`}
|
displayValue={`${Math.round(grading.intensity * 100)}%`}
|
||||||
onCommit={updateFilterIntensity}
|
onCommit={actions.setIntensityPercent}
|
||||||
onReset={() => updateFilterIntensity(100)}
|
onReset={() => actions.setIntensityPercent(100)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0 rounded-md border border-panel-border/70 bg-panel-input/15">
|
<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) => {
|
onChange={(event) => {
|
||||||
const nextSrc = event.target.value;
|
const nextSrc = event.target.value;
|
||||||
track("select", "Custom LUT");
|
track("select", "Custom LUT");
|
||||||
applyLut(
|
actions.applyLut(
|
||||||
nextSrc || null,
|
nextSrc || null,
|
||||||
nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
|
nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
|
||||||
);
|
);
|
||||||
@@ -361,7 +409,9 @@ export function ColorGradingControls({
|
|||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
void importLuts(event.currentTarget.files);
|
void actions.importLut(event.currentTarget.files, onImportAssets, () =>
|
||||||
|
track("button", "Import LUT"),
|
||||||
|
);
|
||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -386,8 +436,8 @@ export function ColorGradingControls({
|
|||||||
neutral={0}
|
neutral={0}
|
||||||
suffix="%"
|
suffix="%"
|
||||||
displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`}
|
displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`}
|
||||||
onCommit={updateLutIntensity}
|
onCommit={actions.setLutIntensityPercent}
|
||||||
onReset={() => updateLutIntensity(100)}
|
onReset={() => actions.setLutIntensityPercent(100)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -398,7 +448,7 @@ export function ColorGradingControls({
|
|||||||
<div className="grid min-w-0 gap-1.5">
|
<div className="grid min-w-0 gap-1.5">
|
||||||
<span className={LABEL}>Adjust</span>
|
<span className={LABEL}>Adjust</span>
|
||||||
<div className="grid min-w-0 grid-cols-2 gap-1.5">
|
<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 value = grading.adjust[slider.key] * slider.scale;
|
||||||
const isExposure = slider.key === "exposure";
|
const isExposure = slider.key === "exposure";
|
||||||
return (
|
return (
|
||||||
@@ -418,24 +468,12 @@ export function ColorGradingControls({
|
|||||||
: `${Math.round(value)}%`
|
: `${Math.round(value)}%`
|
||||||
}
|
}
|
||||||
onCommit={(next) => {
|
onCommit={(next) => {
|
||||||
onCommitColorGrading({
|
onCommitColorGrading(
|
||||||
...grading,
|
colorGradingWithAdjust(grading, slider.key, next / slider.scale),
|
||||||
intensity: visibleIntensity(grading),
|
);
|
||||||
adjust: {
|
|
||||||
...grading.adjust,
|
|
||||||
[slider.key]: next / slider.scale,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
onCommitColorGrading({
|
onCommitColorGrading(colorGradingWithAdjust(grading, slider.key, 0));
|
||||||
...grading,
|
|
||||||
intensity: visibleIntensity(grading),
|
|
||||||
adjust: {
|
|
||||||
...grading.adjust,
|
|
||||||
[slider.key]: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -499,7 +537,7 @@ export function ColorGradingControls({
|
|||||||
onCommit={(next) => {
|
onCommit={(next) => {
|
||||||
onCommitColorGrading({
|
onCommitColorGrading({
|
||||||
...grading,
|
...grading,
|
||||||
intensity: visibleIntensity(grading),
|
intensity: visibleColorGradingIntensity(grading),
|
||||||
effects: {
|
effects: {
|
||||||
...grading.effects,
|
...grading.effects,
|
||||||
[slider.key]: next / slider.scale,
|
[slider.key]: next / slider.scale,
|
||||||
@@ -509,7 +547,7 @@ export function ColorGradingControls({
|
|||||||
onReset={() => {
|
onReset={() => {
|
||||||
onCommitColorGrading({
|
onCommitColorGrading({
|
||||||
...grading,
|
...grading,
|
||||||
intensity: visibleIntensity(grading),
|
intensity: visibleColorGradingIntensity(grading),
|
||||||
effects: {
|
effects: {
|
||||||
...grading.effects,
|
...grading.effects,
|
||||||
[slider.key]: 0,
|
[slider.key]: 0,
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
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(() => {
|
||||||
|
if (analysis && canvasRef.current) drawScope(canvasRef.current, mode, analysis);
|
||||||
|
}, [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,163 @@
|
|||||||
|
// @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("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,419 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { COLOR_GRADING_MAX_SECONDARIES } from "@hyperframes/parsers/color-grading-contract";
|
||||||
|
import {
|
||||||
|
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[];
|
||||||
|
|
||||||
|
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 >= COLOR_GRADING_MAX_SECONDARIES) 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 >= COLOR_GRADING_MAX_SECONDARIES}
|
||||||
|
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={0}
|
||||||
|
max={359}
|
||||||
|
tier="explicitCustom"
|
||||||
|
displayValue={`${Math.round(selected.key.hue.center)}°`}
|
||||||
|
onCommit={(center) =>
|
||||||
|
replaceSelected({
|
||||||
|
...selected,
|
||||||
|
key: { ...selected.key, hue: { ...selected.key.hue, center } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FlatSlider
|
||||||
|
label="Hue range"
|
||||||
|
value={selected.key.hue.range}
|
||||||
|
min={0}
|
||||||
|
max={180}
|
||||||
|
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, 180 - range),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FlatSlider
|
||||||
|
label="Hue softness"
|
||||||
|
value={selected.key.hue.softness}
|
||||||
|
min={0}
|
||||||
|
max={180 - 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];
|
||||||
|
return [
|
||||||
|
<FlatSlider
|
||||||
|
key={`${key}-min`}
|
||||||
|
label={`${label} min`}
|
||||||
|
value={range.min * 100}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
tier="explicitCustom"
|
||||||
|
displayValue={percent(range.min)}
|
||||||
|
onCommit={(value) =>
|
||||||
|
replaceSelected({
|
||||||
|
...selected,
|
||||||
|
key: {
|
||||||
|
...selected.key,
|
||||||
|
[key]: {
|
||||||
|
...range,
|
||||||
|
min: Math.max(0, Math.min(value / 100, range.max - 0.01)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
<FlatSlider
|
||||||
|
key={`${key}-max`}
|
||||||
|
label={`${label} max`}
|
||||||
|
value={range.max * 100}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
tier="explicitCustom"
|
||||||
|
displayValue={percent(range.max)}
|
||||||
|
onCommit={(value) =>
|
||||||
|
replaceSelected({
|
||||||
|
...selected,
|
||||||
|
key: {
|
||||||
|
...selected.key,
|
||||||
|
[key]: {
|
||||||
|
...range,
|
||||||
|
max: Math.min(1, Math.max(value / 100, range.min + 0.01)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
<FlatSlider
|
||||||
|
key={`${key}-softness`}
|
||||||
|
label={`${label} softness`}
|
||||||
|
value={range.softness * 100}
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
tier="explicitCustom"
|
||||||
|
displayValue={percent(range.softness)}
|
||||||
|
onCommit={(value) =>
|
||||||
|
replaceSelected({
|
||||||
|
...selected,
|
||||||
|
key: { ...selected.key, [key]: { ...range, softness: value / 100 } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="pt-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||||
|
Correction
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["hueShift", "Hue shift", -180, 180, "degree"],
|
||||||
|
["saturation", "Saturation", -100, 100, "percent"],
|
||||||
|
["luma", "Luma", -100, 100, "percent"],
|
||||||
|
["temperature", "Warmth", -100, 100, "percent"],
|
||||||
|
["tint", "Tint", -100, 100, "percent"],
|
||||||
|
] as const
|
||||||
|
).map(([key, label, min, max, format]) => {
|
||||||
|
const scale = format === "degree" ? 1 : 100;
|
||||||
|
const value = selected.correction[key] * scale;
|
||||||
|
return (
|
||||||
|
<FlatSlider
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
centerTick
|
||||||
|
tier={Math.abs(value) > 0.0001 ? "explicitCustom" : "default"}
|
||||||
|
displayValue={
|
||||||
|
format === "degree" ? `${Math.round(value)}°` : `${Math.round(value)}%`
|
||||||
|
}
|
||||||
|
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,308 @@
|
|||||||
|
import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
|
import type {
|
||||||
|
HfColorGradingWheelKey,
|
||||||
|
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 RESET_WHEEL: NormalizedTonalWheel = { hue: 0, amount: 0, level: 0 };
|
||||||
|
|
||||||
|
function wrapHue(value: number): number {
|
||||||
|
return ((value % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
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), 0, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 0, 1) };
|
||||||
|
case "ArrowUp":
|
||||||
|
return { ...wheel, amount: clampNumber(wheel.amount + amountStep, 0, 1) };
|
||||||
|
case "Home":
|
||||||
|
return { ...wheel, amount: 0 };
|
||||||
|
case "End":
|
||||||
|
return { ...wheel, amount: 1 };
|
||||||
|
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={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(wheel.amount * 100)}
|
||||||
|
aria-valuetext={`${Math.round(wheel.hue)} degrees, ${Math.round(wheel.amount * 100)} percent`}
|
||||||
|
aria-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={-1}
|
||||||
|
max={1}
|
||||||
|
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={0}
|
||||||
|
max={359.99}
|
||||||
|
disabled={disabled}
|
||||||
|
formatValue={formatIntegerInput}
|
||||||
|
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
|
||||||
|
onBegin={onBegin}
|
||||||
|
onPreview={(hue) => onPreview({ ...wheel, hue })}
|
||||||
|
onSettle={onSettle}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
<GradingNumberField
|
||||||
|
label="Amount"
|
||||||
|
value={wheel.amount * 100}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
disabled={disabled}
|
||||||
|
formatValue={formatIntegerInput}
|
||||||
|
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
|
||||||
|
onBegin={onBegin}
|
||||||
|
onPreview={(amount) => onPreview({ ...wheel, amount: amount / 100 })}
|
||||||
|
onSettle={onSettle}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
<GradingNumberField
|
||||||
|
label="Level"
|
||||||
|
value={wheel.level * 100}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
disabled={disabled}
|
||||||
|
formatValue={formatIntegerInput}
|
||||||
|
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
|
||||||
|
onBegin={onBegin}
|
||||||
|
onPreview={(level) => onPreview({ ...wheel, level: level / 100 })}
|
||||||
|
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,
|
FlatColorGradingAccessory,
|
||||||
FlatColorGradingSection,
|
FlatColorGradingSection,
|
||||||
} from "./propertyPanelFlatColorGradingSection";
|
} 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;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
@@ -229,6 +232,7 @@ function neutralPropsBase() {
|
|||||||
height: 90,
|
height: 90,
|
||||||
},
|
},
|
||||||
onRequestPresetPreviews: vi.fn(),
|
onRequestPresetPreviews: vi.fn(),
|
||||||
|
captureGradedFrame: vi.fn(async () => null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +245,35 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|||||||
...base,
|
...base,
|
||||||
intensity: 0.4,
|
intensity: 0.4,
|
||||||
adjust: { ...base.adjust, contrast: 0.3 },
|
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 },
|
details: { ...base.details, vignette: 0.2 },
|
||||||
effects: { ...base.effects, pixelate: 0.5 },
|
effects: { ...base.effects, pixelate: 0.5 },
|
||||||
palette: ["#112233", "#ffffff"],
|
palette: ["#112233", "#ffffff"],
|
||||||
@@ -277,11 +310,109 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|||||||
palette: ["#112233", "#ffffff"],
|
palette: ["#112233", "#ffffff"],
|
||||||
lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
|
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].adjust.contrast).not.toBe(0.3);
|
||||||
expect(onCommitColorGrading.mock.calls[0][0].details.vignette).not.toBe(0.2);
|
expect(onCommitColorGrading.mock.calls[0][0].details.vignette).not.toBe(0.2);
|
||||||
act(() => root.unmount());
|
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", () => {
|
it("keeps effect-bearing saved styles out of Grade", () => {
|
||||||
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
||||||
const presets = host.querySelector('[data-flat-grade-preset-group="presets"]');
|
const presets = host.querySelector('[data-flat-grade-preset-group="presets"]');
|
||||||
@@ -294,7 +425,7 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|||||||
act(() => root.unmount());
|
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 onRequestPresetPreviews = vi.fn();
|
||||||
const { host, root } = renderInto(
|
const { host, root } = renderInto(
|
||||||
<FlatColorGradingSection
|
<FlatColorGradingSection
|
||||||
@@ -302,7 +433,7 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|||||||
onRequestPresetPreviews={onRequestPresetPreviews}
|
onRequestPresetPreviews={onRequestPresetPreviews}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1);
|
expect(onRequestPresetPreviews).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
host.querySelector<HTMLImageElement>('[data-flat-grade-preview="bright-pop"]')?.src,
|
host.querySelector<HTMLImageElement>('[data-flat-grade-preview="bright-pop"]')?.src,
|
||||||
).toContain("data:image/png;base64,bright");
|
).toContain("data:image/png;base64,bright");
|
||||||
@@ -310,6 +441,40 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|||||||
act(() => root.unmount());
|
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", () => {
|
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 grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.8 } };
|
||||||
const { host, root } = renderInto(
|
const { host, root } = renderInto(
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
HF_COLOR_GRADING_GRADE_PRESETS,
|
HF_COLOR_GRADING_GRADE_PRESETS,
|
||||||
isHfColorGradingActive,
|
|
||||||
normalizeHfColorGrading,
|
normalizeHfColorGrading,
|
||||||
|
serializeHfColorGrading,
|
||||||
type HfColorGradingAdjustKey,
|
type HfColorGradingAdjustKey,
|
||||||
type HfColorGradingDetailKey,
|
type HfColorGradingDetailKey,
|
||||||
type NormalizedHfColorGrading,
|
type NormalizedHfColorGrading,
|
||||||
} from "@hyperframes/core/color-grading";
|
} 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 { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||||
import { LUT_EXT } from "../../utils/mediaTypes";
|
import { LUT_EXT } from "../../utils/mediaTypes";
|
||||||
import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives";
|
import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives";
|
||||||
@@ -18,138 +18,23 @@ import type {
|
|||||||
MediaMetadata,
|
MediaMetadata,
|
||||||
} from "./useColorGradingController";
|
} from "./useColorGradingController";
|
||||||
import { presetPreviewHandlers } from "./propertyPanelPresetPreview";
|
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> = {
|
export { FlatColorGradingAccessory } from "./propertyPanelFlatColorGradingAccessory";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
|
function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
|
||||||
if (key === "exposure") {
|
if (key === "exposure") {
|
||||||
@@ -159,30 +44,17 @@ function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): st
|
|||||||
return `${Math.round(rawPercent)}%`;
|
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 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}`);
|
if (!spec) throw new Error(`Unknown color grading detail key: ${key}`);
|
||||||
return spec;
|
return spec;
|
||||||
};
|
};
|
||||||
const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [
|
|
||||||
"vignetteMidpoint",
|
function resolveColorGrading(grading: Parameters<typeof normalizeHfColorGrading>[0]) {
|
||||||
"vignetteRoundness",
|
const resolved = normalizeHfColorGrading(grading);
|
||||||
"vignetteFeather",
|
if (!resolved) throw new Error("Missing resolved color grading");
|
||||||
];
|
return resolved;
|
||||||
const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"];
|
}
|
||||||
|
|
||||||
function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
|
function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
|
||||||
if (metadata?.color.dynamicRange !== "hdr") return null;
|
if (metadata?.color.dynamicRange !== "hdr") return null;
|
||||||
@@ -237,6 +109,7 @@ export function FlatColorGradingSection({
|
|||||||
mediaMetadata,
|
mediaMetadata,
|
||||||
presetPreviews,
|
presetPreviews,
|
||||||
onRequestPresetPreviews,
|
onRequestPresetPreviews,
|
||||||
|
captureGradedFrame,
|
||||||
}: {
|
}: {
|
||||||
grading: NormalizedHfColorGrading;
|
grading: NormalizedHfColorGrading;
|
||||||
assets: string[];
|
assets: string[];
|
||||||
@@ -254,6 +127,7 @@ export function FlatColorGradingSection({
|
|||||||
mediaMetadata: MediaMetadata | null;
|
mediaMetadata: MediaMetadata | null;
|
||||||
presetPreviews: ColorGradingPresetPreviews;
|
presetPreviews: ColorGradingPresetPreviews;
|
||||||
onRequestPresetPreviews: () => void;
|
onRequestPresetPreviews: () => void;
|
||||||
|
captureGradedFrame: ColorGradingControllerState["captureGradedFrame"];
|
||||||
}) {
|
}) {
|
||||||
const track = useTrackDesignInput();
|
const track = useTrackDesignInput();
|
||||||
const lutInputRef = useRef<HTMLInputElement>(null);
|
const lutInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -265,70 +139,64 @@ export function FlatColorGradingSection({
|
|||||||
);
|
);
|
||||||
const lut = grading.lut;
|
const lut = grading.lut;
|
||||||
const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null;
|
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (presetPreviews.status === "idle") onRequestPresetPreviews();
|
||||||
presetPreviews.status !== "loading" &&
|
}, [onRequestPresetPreviews, presetPreviews.status]);
|
||||||
HF_COLOR_GRADING_GRADE_PRESETS.some((preset) => !presetPreviews.images[preset.id])
|
|
||||||
) {
|
|
||||||
onRequestPresetPreviews();
|
|
||||||
}
|
|
||||||
}, [onRequestPresetPreviews, presetPreviews.images, presetPreviews.status]);
|
|
||||||
|
|
||||||
const resolvePreset = (presetId: string) => {
|
const resolvePreset = (presetId: string) => {
|
||||||
const resolved = normalizeHfColorGrading({ preset: presetId, lut: grading.lut });
|
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]);
|
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 renderDetailSlider = (key: HfColorGradingDetailKey) => {
|
||||||
const spec = detailByKey(key);
|
const spec = detailByKey(key);
|
||||||
const value = grading.details[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 (
|
return (
|
||||||
<FlatSlider
|
<FlatSlider
|
||||||
key={key}
|
key={key}
|
||||||
label={spec.label}
|
label={spec.label}
|
||||||
value={Math.round(value * 100)}
|
value={Math.round(value * spec.scale)}
|
||||||
min={key === "vignetteRoundness" ? -100 : 0}
|
min={spec.min}
|
||||||
max={100}
|
max={spec.max}
|
||||||
|
step={spec.step}
|
||||||
tier={isSet ? "explicitCustom" : "default"}
|
tier={isSet ? "explicitCustom" : "default"}
|
||||||
displayValue={`${Math.round(value * 100)}%`}
|
displayValue={`${Math.round(value * spec.scale)}${spec.suffix}`}
|
||||||
centerTick={key === "vignetteRoundness"}
|
centerTick={key === "vignetteRoundness"}
|
||||||
onCommit={(next) =>
|
onCommit={(next) =>
|
||||||
onCommitColorGrading({
|
onCommitColorGrading(colorGradingWithDetail(grading, key, next / spec.scale))
|
||||||
...grading,
|
|
||||||
intensity: visibleIntensity(grading),
|
|
||||||
details: { ...grading.details, [key]: next / 100 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onReset={() =>
|
|
||||||
onCommitColorGrading({
|
|
||||||
...grading,
|
|
||||||
intensity: visibleIntensity(grading),
|
|
||||||
details: { ...grading.details, [key]: spec.defaultValue },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
onReset={() => onCommitColorGrading(colorGradingWithDetail(grading, key, defaultValue))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -336,7 +204,20 @@ export function FlatColorGradingSection({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<HdrBanner metadata={mediaMetadata} />
|
<HdrBanner metadata={mediaMetadata} />
|
||||||
|
<PropertyPanelColorScopes
|
||||||
|
captureFrame={() => captureGradedFrame()}
|
||||||
|
refreshKey={serializeHfColorGrading(grading)}
|
||||||
|
/>
|
||||||
<div data-flat-grade-presets="true" className="space-y-1.5">
|
<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}>
|
<div data-flat-grade-preset-group="presets" className={FLAT_PREVIEW_GRID}>
|
||||||
{HF_COLOR_GRADING_GRADE_PRESETS.map((preset) => {
|
{HF_COLOR_GRADING_GRADE_PRESETS.map((preset) => {
|
||||||
const label = preset.label;
|
const label = preset.label;
|
||||||
@@ -395,10 +276,105 @@ export function FlatColorGradingSection({
|
|||||||
max={100}
|
max={100}
|
||||||
tier={grading.intensity === 1 ? "default" : "explicitCustom"}
|
tier={grading.intensity === 1 ? "default" : "explicitCustom"}
|
||||||
displayValue={`${Math.round(grading.intensity * 100)}%`}
|
displayValue={`${Math.round(grading.intensity * 100)}%`}
|
||||||
onCommit={updateIntensity}
|
onCommit={actions.setIntensityPercent}
|
||||||
onReset={() => updateIntensity(100)}
|
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">
|
<div className="border-t border-panel-hairline pt-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -430,7 +406,7 @@ export function FlatColorGradingSection({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const src = e.target.value;
|
const src = e.target.value;
|
||||||
track("select", "Custom LUT");
|
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"
|
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 +432,9 @@ export function FlatColorGradingSection({
|
|||||||
accept=".cube"
|
accept=".cube"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
void importLuts(e.currentTarget.files);
|
void actions.importLut(e.currentTarget.files, onImportAssets, () =>
|
||||||
|
track("button", "Import LUT"),
|
||||||
|
);
|
||||||
e.currentTarget.value = "";
|
e.currentTarget.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -469,52 +447,14 @@ export function FlatColorGradingSection({
|
|||||||
max={100}
|
max={100}
|
||||||
tier={lut.intensity === 1 ? "default" : "explicitCustom"}
|
tier={lut.intensity === 1 ? "default" : "explicitCustom"}
|
||||||
displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
|
displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
|
||||||
onCommit={(v) => applyLut(lut.src, v / 100)}
|
onCommit={(v) => actions.applyLut(lut.src, v / 100)}
|
||||||
onReset={() => applyLut(lut.src, 1)}
|
onReset={() => actions.applyLut(lut.src, 1)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="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">
|
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||||
Finish
|
Finish
|
||||||
@@ -545,8 +485,8 @@ export function FlatColorGradingSection({
|
|||||||
</div>
|
</div>
|
||||||
{detailSettingsOpen && (
|
{detailSettingsOpen && (
|
||||||
<div className="space-y-0.5 border-l-2 border-panel-border-input pl-2.5">
|
<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(
|
{(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS).map(
|
||||||
renderDetailSlider,
|
(slider) => renderDetailSlider(slider.key),
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -129,6 +129,21 @@ function createPreviewFrame() {
|
|||||||
return { contentWindow, iframe };
|
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() {
|
async function flushPreviewRequest() {
|
||||||
act(() => vi.advanceTimersByTime(0));
|
act(() => vi.advanceTimersByTime(0));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -161,6 +176,48 @@ describe("useColorGradingController", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const devicePixelRatio = vi.spyOn(window, "devicePixelRatio", "get").mockReturnValue(2);
|
const devicePixelRatio = vi.spyOn(window, "devicePixelRatio", "get").mockReturnValue(2);
|
||||||
const { contentWindow, iframe } = createPreviewFrame();
|
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({
|
const renderPreviews = vi.fn().mockResolvedValue({
|
||||||
width: 160,
|
width: 160,
|
||||||
height: 90,
|
height: 90,
|
||||||
@@ -171,32 +228,51 @@ describe("useColorGradingController", () => {
|
|||||||
|
|
||||||
act(() => getState().requestPresetPreviews());
|
act(() => getState().requestPresetPreviews());
|
||||||
await flushPreviewRequest();
|
await flushPreviewRequest();
|
||||||
|
expect(getState().presetPreviews).toMatchObject({
|
||||||
expect(renderPreviews).toHaveBeenCalledTimes(1);
|
status: "loading",
|
||||||
expect(renderPreviews.mock.calls[0]?.[1]).toHaveLength(18);
|
|
||||||
expect(renderPreviews.mock.calls[0]?.[2]).toEqual({ maxDimension: 320 });
|
|
||||||
expect(getState().presetPreviews).toEqual({
|
|
||||||
status: "ready",
|
|
||||||
images: { "bright-pop": "data:image/png;base64,bright" },
|
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());
|
||||||
|
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());
|
act(() => root.unmount());
|
||||||
devicePixelRatio.mockRestore();
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requests exact effect families and retains earlier family images", async () => {
|
it("requests exact effect families and retains earlier family images", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const { contentWindow, iframe } = createPreviewFrame();
|
const { contentWindow, iframe } = createPreviewFrame();
|
||||||
const renderPreviews = vi
|
const renderPreviews = installPreviewRenderer(contentWindow);
|
||||||
.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 { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
|
const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
|
||||||
|
|
||||||
act(() => getState().requestEffectPreviews(["blur", "pixelate", "bloom"]));
|
act(() => getState().requestEffectPreviews(["blur", "pixelate", "bloom"]));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
HF_COLOR_GRADING_ATTR,
|
HF_COLOR_GRADING_ATTR,
|
||||||
|
hasHfColorGradingAuthoredValues,
|
||||||
isHfColorGradingActive,
|
isHfColorGradingActive,
|
||||||
normalizeHfColorGrading,
|
normalizeHfColorGrading,
|
||||||
serializeHfColorGrading,
|
serializeHfColorGrading,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
} from "../../player/lib/runtimeProtocol";
|
} from "../../player/lib/runtimeProtocol";
|
||||||
import {
|
import {
|
||||||
useColorGradingPreviews,
|
useColorGradingPreviews,
|
||||||
|
type ColorGradingCapturedFrame,
|
||||||
type ColorGradingPresetPreviews,
|
type ColorGradingPresetPreviews,
|
||||||
type ColorGradingPreviewOptions,
|
type ColorGradingPreviewOptions,
|
||||||
} from "./useColorGradingPreviews";
|
} from "./useColorGradingPreviews";
|
||||||
@@ -169,6 +171,9 @@ export interface ColorGradingControllerState {
|
|||||||
next: NormalizedHfColorGrading | null,
|
next: NormalizedHfColorGrading | null,
|
||||||
options?: ColorGradingPreviewOptions,
|
options?: ColorGradingPreviewOptions,
|
||||||
) => void;
|
) => void;
|
||||||
|
captureGradedFrame: (options?: {
|
||||||
|
grading?: NormalizedHfColorGrading;
|
||||||
|
}) => Promise<ColorGradingCapturedFrame | null>;
|
||||||
commitCompare: (enabled: boolean) => void;
|
commitCompare: (enabled: boolean) => void;
|
||||||
setApplyScope: (scope: "source-file" | "project") => void;
|
setApplyScope: (scope: "source-file" | "project") => void;
|
||||||
applyToScope: () => Promise<void>;
|
applyToScope: () => Promise<void>;
|
||||||
@@ -472,7 +477,7 @@ export function useColorGradingController({
|
|||||||
}
|
}
|
||||||
scheduleRuntimeStatusRefresh();
|
scheduleRuntimeStatusRefresh();
|
||||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
||||||
pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
|
pendingPersistValueRef.current = hasHfColorGradingAuthoredValues(nextGrading)
|
||||||
? serializeHfColorGrading(nextGrading)
|
? serializeHfColorGrading(nextGrading)
|
||||||
: null;
|
: null;
|
||||||
pendingPersistGradingRef.current = nextGrading;
|
pendingPersistGradingRef.current = nextGrading;
|
||||||
@@ -539,6 +544,7 @@ export function useColorGradingController({
|
|||||||
requestEffectPreviews: previewController.requestEffectPreviews,
|
requestEffectPreviews: previewController.requestEffectPreviews,
|
||||||
commitColorGrading,
|
commitColorGrading,
|
||||||
previewColorGrading: previewController.previewColorGrading,
|
previewColorGrading: previewController.previewColorGrading,
|
||||||
|
captureGradedFrame: previewController.captureGradedFrame,
|
||||||
commitCompare,
|
commitCompare,
|
||||||
setApplyScope,
|
setApplyScope,
|
||||||
applyToScope,
|
applyToScope,
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export interface ColorGradingPresetPreviews {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ColorGradingCapturedFrame {
|
||||||
|
dataUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
type ColorGradingPreviewKind = "presets" | "effects";
|
type ColorGradingPreviewKind = "presets" | "effects";
|
||||||
|
|
||||||
export interface ColorGradingPreviewOptions {
|
export interface ColorGradingPreviewOptions {
|
||||||
@@ -78,12 +84,21 @@ function toPreviewColorGrading(grading: NormalizedHfColorGrading): unknown {
|
|||||||
|
|
||||||
function previewCandidates(
|
function previewCandidates(
|
||||||
request: PreviewRequest,
|
request: PreviewRequest,
|
||||||
lut: NormalizedHfColorGrading["lut"],
|
grading: Pick<NormalizedHfColorGrading, "effects" | "lut" | "palette">,
|
||||||
): Array<{ id: string; grading: unknown }> {
|
): Array<{ id: string; grading: unknown }> {
|
||||||
if (request.kind === "presets") {
|
if (request.kind === "presets") {
|
||||||
return HF_COLOR_GRADING_PRESETS.map((preset) => {
|
return HF_COLOR_GRADING_PRESETS.map((preset) => {
|
||||||
const resolved = normalizeHfColorGrading({ preset: preset.id, lut });
|
const resolved = normalizeHfColorGrading({ preset: preset.id, lut: grading.lut });
|
||||||
return { id: preset.id, grading: resolved ? toPreviewColorGrading(resolved) : null };
|
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) => {
|
return (request.effects ?? HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS).map((effect) => {
|
||||||
@@ -98,16 +113,19 @@ async function renderRequestedPreviews(
|
|||||||
runtime: RuntimeColorGradingPreview,
|
runtime: RuntimeColorGradingPreview,
|
||||||
target: HfColorGradingTarget,
|
target: HfColorGradingTarget,
|
||||||
request: PreviewRequest,
|
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(),
|
maxDimension: previewMaxDimension(),
|
||||||
});
|
});
|
||||||
if (!batch) return null;
|
if (!batch) return null;
|
||||||
const images = Object.fromEntries(
|
const images = Object.fromEntries(
|
||||||
batch.images.flatMap((image) => (image.dataUrl ? [[image.id, image.dataUrl]] : [])),
|
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({
|
export function useColorGradingPreviews({
|
||||||
@@ -154,6 +172,7 @@ export function useColorGradingPreviews({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let complete = false;
|
let complete = false;
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
|
let timedOut = false;
|
||||||
const timers: number[] = [];
|
const timers: number[] = [];
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -169,15 +188,19 @@ export function useColorGradingPreviews({
|
|||||||
if (!runtime) return;
|
if (!runtime) return;
|
||||||
inFlight = true;
|
inFlight = true;
|
||||||
try {
|
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;
|
if (cancelled || !result) return;
|
||||||
complete = true;
|
complete = result.complete;
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
previews: {
|
previews: {
|
||||||
...current.previews,
|
...current.previews,
|
||||||
[kind]: {
|
[kind]: {
|
||||||
status: "ready",
|
status: result.complete ? "ready" : timedOut ? "unavailable" : "loading",
|
||||||
images:
|
images:
|
||||||
kind === "effects"
|
kind === "effects"
|
||||||
? { ...current.previews[kind].images, ...result.images }
|
? { ...current.previews[kind].images, ...result.images }
|
||||||
@@ -206,11 +229,12 @@ export function useColorGradingPreviews({
|
|||||||
timers.push(
|
timers.push(
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (cancelled || complete) return;
|
if (cancelled || complete) return;
|
||||||
|
timedOut = true;
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
previews: {
|
previews: {
|
||||||
...current.previews,
|
...current.previews,
|
||||||
[kind]: { status: "unavailable", images: {}, width: 16, height: 9 },
|
[kind]: { ...current.previews[kind], status: "unavailable" },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, 1600),
|
}, 1600),
|
||||||
@@ -221,7 +245,7 @@ export function useColorGradingPreviews({
|
|||||||
iframe.removeEventListener("load", attempt);
|
iframe.removeEventListener("load", attempt);
|
||||||
window.removeEventListener("message", onMessage);
|
window.removeEventListener("message", onMessage);
|
||||||
};
|
};
|
||||||
}, [grading.lut, previewIframeRef, request, target]);
|
}, [grading.effects, grading.lut, grading.palette, previewIframeRef, request, target]);
|
||||||
|
|
||||||
const stopAnimatedPreview = useCallback(() => {
|
const stopAnimatedPreview = useCallback(() => {
|
||||||
const session = animatedRef.current;
|
const session = animatedRef.current;
|
||||||
@@ -296,6 +320,24 @@ export function useColorGradingPreviews({
|
|||||||
(effects: readonly HfColorGradingActiveEffectKey[]) => requestPreviews("effects", effects),
|
(effects: readonly HfColorGradingActiveEffectKey[]) => requestPreviews("effects", effects),
|
||||||
[requestPreviews],
|
[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 {
|
return {
|
||||||
presetPreviews: previews.presets,
|
presetPreviews: previews.presets,
|
||||||
@@ -303,5 +345,6 @@ export function useColorGradingPreviews({
|
|||||||
requestPresetPreviews,
|
requestPresetPreviews,
|
||||||
requestEffectPreviews,
|
requestEffectPreviews,
|
||||||
previewColorGrading,
|
previewColorGrading,
|
||||||
|
captureGradedFrame,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// @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>
|
||||||
|
<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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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) {
|
||||||
|
setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = window.setTimeout(async () => {
|
||||||
|
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. */
|
/** One owner for continuous inspector edits: preview freely, persist once. */
|
||||||
export function useInspectorGestureTransaction<T>({
|
export function useInspectorGestureTransaction<T>({
|
||||||
@@ -58,3 +58,32 @@ export function useInspectorGestureTransaction<T>({
|
|||||||
|
|
||||||
return { begin, preview, settle, cancel, activeRef };
|
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,
|
Gear,
|
||||||
Scissors as PhScissors,
|
Scissors as PhScissors,
|
||||||
Link as PhLink,
|
Link as PhLink,
|
||||||
|
Eyedropper as PhEyedropper,
|
||||||
|
Trash as PhTrash,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } 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 Settings = makeIcon(Gear);
|
||||||
export const Scissors = makeIcon(PhScissors);
|
export const Scissors = makeIcon(PhScissors);
|
||||||
export const Link = makeIcon(PhLink);
|
export const Link = makeIcon(PhLink);
|
||||||
|
export const Eyedropper = makeIcon(PhEyedropper);
|
||||||
|
export const Trash = makeIcon(PhTrash);
|
||||||
|
|||||||
Reference in New Issue
Block a user