diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index ef1393a60d..8098c4e4b7 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -427,6 +427,7 @@ export function PropertyPanelFlat({ mediaMetadata={colorGradingController.mediaMetadata} presetPreviews={colorGradingController.presetPreviews} onRequestPresetPreviews={colorGradingController.requestPresetPreviews} + captureGradedFrame={colorGradingController.captureGradedFrame} /> ), }); diff --git a/packages/studio/src/components/editor/colorGradingFrameAnalysis.test.ts b/packages/studio/src/components/editor/colorGradingFrameAnalysis.test.ts new file mode 100644 index 0000000000..1ea07e57eb --- /dev/null +++ b/packages/studio/src/components/editor/colorGradingFrameAnalysis.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeHfColorGrading, + type HfColorGradingSecondary, +} from "@hyperframes/core/color-grading"; +import { + analyzeColorGradingFrame, + buildColorGradingSecondaryMatte, + colorGradingSecondaryMask, + sampleColorGradingSecondary, +} from "./colorGradingFrameAnalysis"; + +function pixels(...rgba: number[]): Uint8ClampedArray { + return new Uint8ClampedArray(rgba); +} + +function secondaryKey(key: HfColorGradingSecondary["key"]) { + const secondary = normalizeHfColorGrading({ + secondaries: [{ key, correction: {} }], + })?.secondaries?.[0]; + if (!secondary) throw new Error("Expected a normalized secondary"); + return secondary.key; +} + +describe("colorGradingFrameAnalysis", () => { + it("places black and white at the Rec.709 scope endpoints", () => { + const result = analyzeColorGradingFrame(pixels(0, 0, 0, 255, 255, 255, 255, 255), 2, 1); + + expect(result.histogram[0]).toBe(255); + expect(result.histogram[255]).toBe(255); + expect(result.waveform[255]).toBe(255); + expect(result.waveform[256]).toBe(255); + expect(result.parade.reduce((sum, value) => sum + value, 0)).toBe(6 * 255); + }); + + it("alpha-weights all scopes and excludes transparent pixels", () => { + const result = analyzeColorGradingFrame(pixels(0, 0, 0, 0, 255, 255, 255, 64), 2, 1); + + expect(result.histogram.reduce((sum, value) => sum + value, 0)).toBe(64); + expect(result.waveform.reduce((sum, value) => sum + value, 0)).toBe(64); + expect(result.parade.reduce((sum, value) => sum + value, 0)).toBe(3 * 64); + expect(result.vectorscope.reduce((sum, value) => sum + value, 0)).toBe(64); + }); + + it("averages hue circularly when an eyedropper sample crosses red", () => { + const sampled = sampleColorGradingSecondary( + pixels(255, 0, 10, 255, 255, 10, 0, 255), + 2, + 1, + 0.5, + 0, + 1, + ); + + expect(sampled).not.toBeNull(); + if (!sampled) throw new Error("Expected a sample"); + expect(Math.min(sampled.hue.center, 360 - sampled.hue.center)).toBeLessThan(3); + expect(sampled.hue.range).toBe(18); + }); + + it("does not invent a hue qualifier for a neutral sample", () => { + const sampled = sampleColorGradingSecondary(pixels(128, 128, 128, 255), 1, 1, 0, 0); + + expect(sampled?.hue).toEqual({ center: 0, range: 180, softness: 0 }); + expect(sampled?.saturation.max).toBeCloseTo(0.2); + }); + + it("matches the runtime softness on saturation and luma feather edges", () => { + const saturationKey = secondaryKey({ + hue: { center: 0, range: 180, softness: 0 }, + saturation: { min: 0.5, max: 1, softness: 0.4 }, + luma: { min: 0, max: 1, softness: 0 }, + }); + const lumaKey = secondaryKey({ + hue: { center: 0, range: 180, softness: 0 }, + saturation: { min: 0, max: 1, softness: 0 }, + luma: { min: 0.5, max: 1, softness: 0.4 }, + }); + + expect(colorGradingSecondaryMask(255, 204, 204, saturationKey)).toBeCloseTo(0.15625, 5); + expect(colorGradingSecondaryMask(51, 51, 51, lumaKey)).toBeCloseTo(0.15625, 5); + }); + + it("builds an alpha-preserving black-and-white selection matte", () => { + const key = secondaryKey({ + hue: { center: 0, range: 15, softness: 0 }, + saturation: { min: 0.5, max: 1, softness: 0 }, + luma: { min: 0, max: 1, softness: 0 }, + }); + const matte = buildColorGradingSecondaryMatte( + pixels(255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 0, 0), + 3, + 1, + key, + ); + + expect([...matte.slice(0, 4)]).toEqual([255, 255, 255, 255]); + expect([...matte.slice(4, 8)]).toEqual([0, 0, 0, 128]); + expect([...matte.slice(8, 12)]).toEqual([0, 0, 0, 0]); + }); +}); diff --git a/packages/studio/src/components/editor/colorGradingFrameAnalysis.ts b/packages/studio/src/components/editor/colorGradingFrameAnalysis.ts new file mode 100644 index 0000000000..c429b4325f --- /dev/null +++ b/packages/studio/src/components/editor/colorGradingFrameAnalysis.ts @@ -0,0 +1,203 @@ +import { + calculateHfColorGradingSecondaryMask, + type NormalizedHfColorGradingSecondary, +} from "@hyperframes/core/color-grading"; +import { clampNumber } from "../../utils/studioHelpers"; + +const BYTE_MAX = 255; +const SCOPE_SIZE = 256; + +export type ColorGradingScopeMode = "histogram" | "waveform" | "parade" | "vectorscope"; + +export interface ColorGradingScopeAnalysis { + width: number; + histogram: Uint32Array; + waveform: Uint32Array; + parade: Uint32Array; + vectorscope: Uint32Array; +} + +export async function readColorGradingFramePixels(frame: { + dataUrl: string; + width: number; + height: number; +}): Promise { + const image = new Image(); + image.src = frame.dataUrl; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = frame.width; + canvas.height = frame.height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + context.drawImage(image, 0, 0, frame.width, frame.height); + return context.getImageData(0, 0, frame.width, frame.height).data; +} + +function rec709Luma(red: number, green: number, blue: number): number { + return red * 0.2126 + green * 0.7152 + blue * 0.0722; +} + +function rgbToHsv(red: number, green: number, blue: number) { + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const chroma = max - min; + let hue = 0; + if (chroma > 0) { + if (max === red) hue = ((green - blue) / chroma) % 6; + else if (max === green) hue = (blue - red) / chroma + 2; + else hue = (red - green) / chroma + 4; + hue = (hue * 60 + 360) % 360; + } + return { + hue, + saturation: max === 0 ? 0 : chroma / max, + value: max, + }; +} + +export function colorGradingSecondaryMask( + red: number, + green: number, + blue: number, + key: NormalizedHfColorGradingSecondary["key"], +): number { + const hsv = rgbToHsv(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX); + return calculateHfColorGradingSecondaryMask( + hsv.hue, + hsv.saturation, + rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX), + key, + ); +} + +export function buildColorGradingSecondaryMatte( + pixels: Uint8ClampedArray, + width: number, + height: number, + key: NormalizedHfColorGradingSecondary["key"], +): Uint8ClampedArray { + const matte = new Uint8ClampedArray(width * height * 4); + for (let offset = 0; offset < pixels.length; offset += 4) { + const alpha = pixels[offset + 3] ?? 0; + const mask = + alpha === 0 + ? 0 + : colorGradingSecondaryMask( + pixels[offset] ?? 0, + pixels[offset + 1] ?? 0, + pixels[offset + 2] ?? 0, + key, + ); + const value = Math.round(mask * BYTE_MAX); + matte[offset] = value; + matte[offset + 1] = value; + matte[offset + 2] = value; + matte[offset + 3] = alpha; + } + return matte; +} + +function pixelOffset(width: number, x: number, y: number): number { + return (y * width + x) * 4; +} + +function byteIndex(value: number): number { + return Math.round(clampNumber(value, 0, 1) * BYTE_MAX); +} + +export function analyzeColorGradingFrame( + pixels: Uint8ClampedArray, + width: number, + height: number, +): ColorGradingScopeAnalysis { + const histogram = new Uint32Array(SCOPE_SIZE); + const waveform = new Uint32Array(width * SCOPE_SIZE); + const parade = new Uint32Array(width * SCOPE_SIZE * 3); + const vectorscope = new Uint32Array(SCOPE_SIZE * SCOPE_SIZE); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const offset = pixelOffset(width, x, y); + const alpha = pixels[offset + 3] ?? 0; + if (alpha === 0) continue; + const red = (pixels[offset] ?? 0) / BYTE_MAX; + const green = (pixels[offset + 1] ?? 0) / BYTE_MAX; + const blue = (pixels[offset + 2] ?? 0) / BYTE_MAX; + const normalizedLuma = rec709Luma(red, green, blue); + const luma = byteIndex(normalizedLuma); + histogram[luma] += alpha; + waveform[x * SCOPE_SIZE + (BYTE_MAX - luma)] += alpha; + parade[x * SCOPE_SIZE + (BYTE_MAX - byteIndex(red))] += alpha; + parade[width * SCOPE_SIZE + x * SCOPE_SIZE + (BYTE_MAX - byteIndex(green))] += alpha; + parade[width * SCOPE_SIZE * 2 + x * SCOPE_SIZE + (BYTE_MAX - byteIndex(blue))] += alpha; + + const chromaBlue = (blue - normalizedLuma) / 1.8556; + const chromaRed = (red - normalizedLuma) / 1.5748; + const vectorX = byteIndex(chromaBlue + 0.5); + const vectorY = BYTE_MAX - byteIndex(chromaRed + 0.5); + vectorscope[vectorY * SCOPE_SIZE + vectorX] += alpha; + } + } + + return { width, histogram, waveform, parade, vectorscope }; +} + +export function sampleColorGradingSecondary( + pixels: Uint8ClampedArray, + width: number, + height: number, + x: number, + y: number, + radius = 2, +): NormalizedHfColorGradingSecondary["key"] | null { + const minX = clampNumber(Math.floor(x) - radius, 0, width - 1); + const maxX = clampNumber(Math.floor(x) + radius, 0, width - 1); + const minY = clampNumber(Math.floor(y) - radius, 0, height - 1); + const maxY = clampNumber(Math.floor(y) + radius, 0, height - 1); + let hueX = 0; + let hueY = 0; + let saturation = 0; + let luma = 0; + let weight = 0; + + for (let sampleY = minY; sampleY <= maxY; sampleY += 1) { + for (let sampleX = minX; sampleX <= maxX; sampleX += 1) { + const offset = pixelOffset(width, sampleX, sampleY); + const alpha = (pixels[offset + 3] ?? 0) / BYTE_MAX; + if (alpha === 0) continue; + const red = (pixels[offset] ?? 0) / BYTE_MAX; + const green = (pixels[offset + 1] ?? 0) / BYTE_MAX; + const blue = (pixels[offset + 2] ?? 0) / BYTE_MAX; + const hsv = rgbToHsv(red, green, blue); + const hueRadians = (hsv.hue * Math.PI) / 180; + const hueWeight = hsv.saturation * alpha; + hueX += Math.cos(hueRadians) * hueWeight; + hueY += Math.sin(hueRadians) * hueWeight; + saturation += hsv.saturation * alpha; + luma += rec709Luma(red, green, blue) * alpha; + weight += alpha; + } + } + + if (weight === 0) return null; + const averageHue = ((Math.atan2(hueY, hueX) * 180) / Math.PI + 360) % 360; + const averageSaturation = saturation / weight; + const averageLuma = luma / weight; + const neutralSample = averageSaturation < 0.02; + return { + hue: neutralSample + ? { center: 0, range: 180, softness: 0 } + : { center: averageHue, range: 18, softness: 12 }, + saturation: { + min: clampNumber(averageSaturation - 0.2, 0, 1), + max: clampNumber(averageSaturation + 0.2, 0, 1), + softness: 0.1, + }, + luma: { + min: clampNumber(averageLuma - 0.2, 0, 1), + max: clampNumber(averageLuma + 0.2, 0, 1), + softness: 0.1, + }, + }; +} diff --git a/packages/studio/src/components/editor/propertyPanelColorCurveGraph.tsx b/packages/studio/src/components/editor/propertyPanelColorCurveGraph.tsx new file mode 100644 index 0000000000..54368cca69 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorCurveGraph.tsx @@ -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) => { + 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) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + addKeyboardPoint(); + return true; + } + return false; + }; + const handleSelectionKey = (event: ReactKeyboardEvent) => { + 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) => { + 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) => { + if (handleRemovalKey(event)) return; + if (handleAddKey(event)) return; + if (handleSelectionKey(event)) return; + handleArrowKey(event); + }; + + return ( + { + 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" + > + + + + + + + + + + + + {[0.25, 0.5, 0.75].map((ratio) => ( + + + + + ))} + {tab.kind === "hue" && ( + + )} + + {points.map(([input, output], index) => { + const point = graphPoint(input, output, tab); + return ( + + ); + })} + + ); +} + +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)); +} diff --git a/packages/studio/src/components/editor/propertyPanelColorCurves.test.tsx b/packages/studio/src/components/editor/propertyPanelColorCurves.test.tsx new file mode 100644 index 0000000000..bcbdce0893 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorCurves.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ColorCurveValues } from "./propertyPanelColorCurves"; +import { ColorCurves } from "./propertyPanelColorCurves"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const IDENTITY: ColorCurveValues = { + curves: { + master: [ + [0, 0], + [1, 1], + ], + red: [ + [0, 0], + [1, 1], + ], + green: [ + [0, 0], + [1, 1], + ], + blue: [ + [0, 0], + [1, 1], + ], + }, + hueCurves: { hueVsHue: [], hueVsSaturation: [], hueVsLuma: [] }, +}; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderCurves(value = IDENTITY) { + const onPreview = vi.fn(); + const onCommit = vi.fn(); + const host = document.body.appendChild(document.createElement("div")); + const root = createRoot(host); + act(() => root.render()); + return { host, root, onPreview, onCommit }; +} + +function activate(host: HTMLElement, key: string) { + act(() => { + host + .querySelector(`[data-color-curve-tab="${key}"]`) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const graph = host.querySelector(`[data-color-curve-graph="${key}"]`); + if (!graph) throw new Error(`Expected ${key} graph`); + Object.defineProperty(graph, "getBoundingClientRect", { + value: () => ({ left: 0, top: 0, width: 160, height: 160, right: 160, bottom: 160 }), + }); + return graph; +} + +describe("ColorCurves", () => { + it("renders the four RGB and three hue-selective curve tabs", () => { + const { host, root } = renderCurves(); + expect(host.querySelectorAll("[data-color-curve-tab]")).toHaveLength(7); + expect(host.querySelector('[data-color-curve-graph="master"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("treats points across the red seam as neighbors instead of adding a duplicate", () => { + const value: ColorCurveValues = { + ...IDENTITY, + hueCurves: { + ...IDENTITY.hueCurves, + hueVsSaturation: [ + [120, 0], + [240, 0], + [359, 0.2], + ], + }, + }; + const { host, root, onCommit } = renderCurves(value); + const graph = activate(host, "hueVsSaturation"); + + act(() => { + graph.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 5, + clientX: 8, + clientY: 66, + }), + ); + graph.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 5, + clientX: 8, + clientY: 66, + }), + ); + }); + + const points = onCommit.mock.calls[0]?.[0]?.hueCurves.hueVsSaturation; + expect(points).toHaveLength(3); + expect(points.some(([hue]: readonly [number, number]) => hue < 1)).toBe(true); + act(() => root.unmount()); + }); + + it("previews pointer edits and commits once on release", () => { + const { host, root, onPreview, onCommit } = renderCurves(); + const graph = activate(host, "master"); + act(() => { + graph.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 2, + clientX: 80, + clientY: 120, + }), + ); + graph.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 2, + clientX: 80, + clientY: 120, + }), + ); + }); + expect(onPreview.mock.calls[0]?.[0]?.curves.master).toHaveLength(3); + expect(onPreview.mock.calls.at(-1)?.[0]).toBe(IDENTITY); + expect(onCommit).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); + + it("does not restore a deleted point when an overlapping key gesture settles", () => { + const { host, root, onCommit } = renderCurves(); + const graph = activate(host, "master"); + act(() => { + graph.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(onCommit.mock.calls.at(-1)?.[0]?.curves.master).toHaveLength(3); + onCommit.mockClear(); + + act(() => { + graph.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + }); + act(() => { + graph.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete", bubbles: true })); + }); + act(() => { + graph.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowDown", bubbles: true })); + }); + + expect(onCommit).toHaveBeenCalledOnce(); + expect(onCommit.mock.calls[0]?.[0]?.curves.master).toHaveLength(2); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelColorCurves.tsx b/packages/studio/src/components/editor/propertyPanelColorCurves.tsx new file mode 100644 index 0000000000..43aea6388e --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorCurves.tsx @@ -0,0 +1,185 @@ +import { useState } from "react"; +import type { HfColorCurvePoint, HfHueCurvePoint } from "@hyperframes/core/color-grading"; +import { RotateCcw } from "../../icons/SystemIcons"; +import { + CurveGraph, + formatPointValue, + movePoint, + pointsFor, + RGB_IDENTITY, + TABS, + type ColorCurveValues, + type CurveTab, + withPoints, +} from "./propertyPanelColorCurveGraph"; +import { GradingNumberField } from "./propertyPanelGradingNumberField"; +import { useInspectorGestureDraft } from "./useInspectorGestureTransaction"; + +export type { ColorCurveValues } from "./propertyPanelColorCurveGraph"; + +export function ColorCurves({ + value, + disabled, + onPreview, + onCommit, +}: { + value: ColorCurveValues; + disabled?: boolean; + onPreview: (value: ColorCurveValues) => void; + onCommit: (value: ColorCurveValues) => void; +}) { + const [activeKey, setActiveKey] = useState("master"); + const [selectedIndex, setSelectedIndex] = useState(null); + const { draft, setDraft, transaction } = useInspectorGestureDraft({ + sourceValue: value, + onPreview, + onCommit, + }); + const tab = TABS.find((candidate) => candidate.key === activeKey) ?? TABS[0]; + if (!tab) throw new Error("Color curve tabs are unavailable"); + const points = pointsFor(draft, tab); + + const previewPoints = ( + nextPoints: readonly (HfColorCurvePoint | HfHueCurvePoint)[], + nextSelectedIndex: number, + ) => { + setSelectedIndex(nextSelectedIndex); + transaction.preview(withPoints(draft, tab, nextPoints)); + }; + const resetActive = () => { + transaction.cancel(); + const next = withPoints(draft, tab, tab.kind === "rgb" ? RGB_IDENTITY : []); + setDraft(next); + setSelectedIndex(null); + onCommit(next); + }; + const deleteSelected = () => { + if (selectedIndex === null) return; + if (tab.kind === "rgb" && (selectedIndex === 0 || selectedIndex === points.length - 1)) return; + transaction.cancel(); + const nextPoints = + tab.kind === "hue" && points.length <= 3 + ? [] + : points.filter((_, index) => index !== selectedIndex); + const next = withPoints(draft, tab, nextPoints); + setDraft(next); + setSelectedIndex(null); + onCommit(next); + }; + const updateSelected = (axis: "input" | "output", rawValue: number) => { + if (selectedIndex === null || !Number.isFinite(rawValue)) return; + const point = points[selectedIndex]; + if (!point) return; + const moved = movePoint( + points, + selectedIndex, + axis === "input" ? rawValue : point[0], + axis === "output" ? rawValue : point[1], + tab, + ); + previewPoints(moved.points, moved.selected); + }; + const selectedPoint = selectedIndex === null ? null : points[selectedIndex]; + const endpointSelected = + tab.kind === "rgb" && + selectedIndex !== null && + (selectedIndex === 0 || selectedIndex === points.length - 1); + + return ( +
+
+ {TABS.map((candidate) => ( + + ))} +
+ +
+ {selectedPoint ? ( + <> + updateSelected("input", next)} + onSettle={transaction.settle} + onCancel={transaction.cancel} + /> + updateSelected("output", next)} + onSettle={transaction.settle} + onCancel={transaction.cancel} + /> + + + ) : ( + + Click the graph or press Enter to add a point + + )} + +
+
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx index 080dd3b137..0dc72f7b45 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx @@ -15,7 +15,7 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; const LUT_UPLOAD_DIR = "assets/luts"; -const ADJUST_SLIDERS: Array<{ +export const COLOR_GRADING_ADJUST_SLIDERS: Array<{ key: HfColorGradingAdjustKey; label: string; min: number; @@ -60,7 +60,7 @@ const ADJUST_SLIDERS: Array<{ { key: "saturation", label: "Saturation", min: -100, max: 100, step: 1, scale: 100, suffix: "%" }, ]; -const DETAIL_SLIDERS: Array<{ +export const COLOR_GRADING_DETAIL_SLIDERS: Array<{ key: HfColorGradingDetailKey; label: string; min: number; @@ -123,7 +123,7 @@ const DETAIL_SLIDERS: Array<{ }, ]; -type DetailSlider = (typeof DETAIL_SLIDERS)[number]; +type DetailSlider = (typeof COLOR_GRADING_DETAIL_SLIDERS)[number]; type SliderSettings = { active?: boolean; label: string; @@ -143,28 +143,111 @@ const EFFECT_SLIDERS: Array<{ { key: "pixelate", label: "Pixelate", min: 0, max: 100, step: 1, scale: 100, suffix: "%" }, ]; -const AMOUNT_DETAIL_SLIDERS = DETAIL_SLIDERS.filter( +const AMOUNT_DETAIL_SLIDERS = COLOR_GRADING_DETAIL_SLIDERS.filter( (slider) => slider.key === "vignette" || slider.key === "grain", ); -const VIGNETTE_TUNE_SLIDERS = DETAIL_SLIDERS.filter( +export const VIGNETTE_TUNE_SLIDERS = COLOR_GRADING_DETAIL_SLIDERS.filter( (slider) => slider.key === "vignetteMidpoint" || slider.key === "vignetteRoundness" || slider.key === "vignetteFeather", ); -const GRAIN_TUNE_SLIDERS = DETAIL_SLIDERS.filter( +export const GRAIN_TUNE_SLIDERS = COLOR_GRADING_DETAIL_SLIDERS.filter( (slider) => slider.key === "grainSize" || slider.key === "grainRoughness", ); -function normalizedDefaultValue(slider: { defaultValue?: number; scale: number }): number { +export function normalizedColorGradingDefault(slider: { + defaultValue?: number; + scale: number; +}): number { return (slider.defaultValue ?? 0) / slider.scale; } -function visibleIntensity(grading: NormalizedHfColorGrading): number { +export function visibleColorGradingIntensity(grading: NormalizedHfColorGrading): number { // Earlier drafts could persist 0% strength; the next manual edit should revive visible grading. return grading.intensity === 0 ? 1 : grading.intensity; } +function colorGradingWithLut( + grading: NormalizedHfColorGrading, + src: string | null, + intensity = 1, +): NormalizedHfColorGrading { + return { + ...grading, + intensity: visibleColorGradingIntensity(grading), + lut: src ? { src, intensity } : null, + }; +} + +export function colorGradingWithDetail( + grading: NormalizedHfColorGrading, + key: HfColorGradingDetailKey, + value: number, +): NormalizedHfColorGrading { + return { + ...grading, + intensity: visibleColorGradingIntensity(grading), + details: { ...grading.details, [key]: value }, + }; +} + +function colorGradingWithIntensity( + grading: NormalizedHfColorGrading, + intensity: number, +): NormalizedHfColorGrading { + return { ...grading, intensity }; +} + +export function colorGradingWithAdjust( + grading: NormalizedHfColorGrading, + key: HfColorGradingAdjustKey, + value: number, +): NormalizedHfColorGrading { + return { + ...grading, + intensity: visibleColorGradingIntensity(grading), + adjust: { ...grading.adjust, [key]: value }, + }; +} + +async function importFirstLut( + files: FileList | null, + onImportAssets?: (files: FileList, dir?: string) => Promise, +): Promise { + 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) | undefined, + onImported: () => void, + ) { + const src = await importFirstLut(files, onImportAssets); + if (!src) return; + onImported(); + applyLut(src); + }, + }; +} + export function ColorGradingControls({ grading, assets, @@ -189,11 +272,14 @@ export function ColorGradingControls({ const detailSettingsSliders = detailSettings === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS; const vignetteSettingsActive = VIGNETTE_TUNE_SLIDERS.some( - (slider) => Math.abs(grading.details[slider.key] - normalizedDefaultValue(slider)) > 0.0001, + (slider) => + Math.abs(grading.details[slider.key] - normalizedColorGradingDefault(slider)) > 0.0001, ); const grainSettingsActive = GRAIN_TUNE_SLIDERS.some( - (slider) => Math.abs(grading.details[slider.key] - normalizedDefaultValue(slider)) > 0.0001, + (slider) => + Math.abs(grading.details[slider.key] - normalizedColorGradingDefault(slider)) > 0.0001, ); + const actions = createColorGradingActions(grading, onCommitColorGrading); const applyPreset = (preset: string) => { const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut }); @@ -202,51 +288,13 @@ export function ColorGradingControls({ onCommitColorGrading(next); } }; - const updateFilterIntensity = (value: number) => { - onCommitColorGrading({ - ...grading, - intensity: value / 100, - }); - }; - const applyLut = (src: string | null, intensity = 1) => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - lut: src ? { src, intensity } : null, - }); - }; - const updateLutIntensity = (value: number) => { - if (!grading.lut) return; - applyLut(grading.lut.src, value / 100); - }; - const importLuts = async (files: FileList | null) => { - if (!files?.length || !onImportAssets) return; - const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR); - const firstLut = uploaded.find((asset) => LUT_EXT.test(asset)); - if (firstLut) { - track("button", "Import LUT"); - applyLut(firstLut, 1); - } - }; const commitDetailSlider = (slider: DetailSlider, next: number) => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - details: { - ...grading.details, - [slider.key]: next / slider.scale, - }, - }); + onCommitColorGrading(colorGradingWithDetail(grading, slider.key, next / slider.scale)); }; const resetDetailSlider = (slider: DetailSlider) => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - details: { - ...grading.details, - [slider.key]: normalizedDefaultValue(slider), - }, - }); + onCommitColorGrading( + colorGradingWithDetail(grading, slider.key, normalizedColorGradingDefault(slider)), + ); }; const renderDetailSlider = (slider: DetailSlider, settings?: SliderSettings) => { const value = Math.round(grading.details[slider.key] * slider.scale); @@ -293,8 +341,8 @@ export function ColorGradingControls({ neutral={0} suffix="%" displayValue={`${Math.round(grading.intensity * 100)}%`} - onCommit={updateFilterIntensity} - onReset={() => updateFilterIntensity(100)} + onCommit={actions.setIntensityPercent} + onReset={() => actions.setIntensityPercent(100)} />
@@ -322,7 +370,7 @@ export function ColorGradingControls({ onChange={(event) => { const nextSrc = event.target.value; track("select", "Custom LUT"); - applyLut( + actions.applyLut( nextSrc || null, nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1, ); @@ -361,7 +409,9 @@ export function ColorGradingControls({ multiple className="hidden" onChange={(event) => { - void importLuts(event.currentTarget.files); + void actions.importLut(event.currentTarget.files, onImportAssets, () => + track("button", "Import LUT"), + ); event.currentTarget.value = ""; }} /> @@ -386,8 +436,8 @@ export function ColorGradingControls({ neutral={0} suffix="%" displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`} - onCommit={updateLutIntensity} - onReset={() => updateLutIntensity(100)} + onCommit={actions.setLutIntensityPercent} + onReset={() => actions.setLutIntensityPercent(100)} />
)} @@ -398,7 +448,7 @@ export function ColorGradingControls({
Adjust
- {ADJUST_SLIDERS.map((slider) => { + {COLOR_GRADING_ADJUST_SLIDERS.map((slider) => { const value = grading.adjust[slider.key] * slider.scale; const isExposure = slider.key === "exposure"; return ( @@ -418,24 +468,12 @@ export function ColorGradingControls({ : `${Math.round(value)}%` } onCommit={(next) => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - adjust: { - ...grading.adjust, - [slider.key]: next / slider.scale, - }, - }); + onCommitColorGrading( + colorGradingWithAdjust(grading, slider.key, next / slider.scale), + ); }} onReset={() => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - adjust: { - ...grading.adjust, - [slider.key]: 0, - }, - }); + onCommitColorGrading(colorGradingWithAdjust(grading, slider.key, 0)); }} /> ); @@ -499,7 +537,7 @@ export function ColorGradingControls({ onCommit={(next) => { onCommitColorGrading({ ...grading, - intensity: visibleIntensity(grading), + intensity: visibleColorGradingIntensity(grading), effects: { ...grading.effects, [slider.key]: next / slider.scale, @@ -509,7 +547,7 @@ export function ColorGradingControls({ onReset={() => { onCommitColorGrading({ ...grading, - intensity: visibleIntensity(grading), + intensity: visibleColorGradingIntensity(grading), effects: { ...grading.effects, [slider.key]: 0, diff --git a/packages/studio/src/components/editor/propertyPanelColorScopes.tsx b/packages/studio/src/components/editor/propertyPanelColorScopes.tsx new file mode 100644 index 0000000000..7d2298a579 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorScopes.tsx @@ -0,0 +1,258 @@ +import { useEffect, useRef, useState } from "react"; +import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews"; +import { + type ColorGradingScopeAnalysis, + type ColorGradingScopeMode, +} from "./colorGradingFrameAnalysis"; +import { useColorGradingScopes } from "./useColorGradingScopes"; +import { RotateCw } from "../../icons/SystemIcons"; + +const MODES: Array<{ id: ColorGradingScopeMode; label: string }> = [ + { id: "histogram", label: "Histogram" }, + { id: "waveform", label: "Waveform" }, + { id: "parade", label: "RGB Parade" }, + { id: "vectorscope", label: "Vectorscope" }, +]; + +function densityAlpha(value: number, maximum: number): number { + if (value === 0 || maximum === 0) return 0; + return Math.min(1, Math.log2(value + 1) / Math.log2(maximum + 1)); +} + +function maximumDensity(values: Uint32Array): number { + let maximum = 0; + for (const value of values) maximum = Math.max(maximum, value); + return maximum; +} + +function drawGrid(context: CanvasRenderingContext2D, width: number, height: number) { + context.fillStyle = "#090b0e"; + context.fillRect(0, 0, width, height); + context.strokeStyle = "rgba(255,255,255,0.08)"; + context.lineWidth = 1; + for (const fraction of [0.25, 0.5, 0.75]) { + context.beginPath(); + context.moveTo(0, Math.round(height * fraction) + 0.5); + context.lineTo(width, Math.round(height * fraction) + 0.5); + context.stroke(); + } +} + +function drawHistogram( + context: CanvasRenderingContext2D, + analysis: ColorGradingScopeAnalysis, + width: number, + height: number, +) { + const maximum = maximumDensity(analysis.histogram); + context.beginPath(); + context.moveTo(0, height); + analysis.histogram.forEach((count, index) => { + context.lineTo((index / 255) * width, height - densityAlpha(count, maximum) * height); + }); + context.lineTo(width, height); + context.closePath(); + context.fillStyle = "rgba(226, 232, 240, 0.2)"; + context.fill(); + context.strokeStyle = "rgba(226, 232, 240, 0.9)"; + context.stroke(); +} + +function drawDensity( + context: CanvasRenderingContext2D, + density: Uint32Array, + columnOffset: number, + columnCount: number, + color: readonly [number, number, number], + xOffset: number, + outputWidth: number, + height: number, +) { + let maximum = 0; + const start = columnOffset * 256; + const end = (columnOffset + columnCount) * 256; + for (let index = start; index < end; index += 1) maximum = Math.max(maximum, density[index] ?? 0); + context.fillStyle = `rgb(${color.join(" ")})`; + for (let x = 0; x < columnCount; x += 1) { + for (let y = 0; y < 256; y += 1) { + const alpha = density[(columnOffset + x) * 256 + y] ?? 0; + if (!alpha) continue; + context.globalAlpha = densityAlpha(alpha, maximum); + context.fillRect( + xOffset + (x / columnCount) * outputWidth, + (y / 255) * height, + Math.max(1, outputWidth / columnCount), + Math.max(1, height / 256), + ); + } + } + context.globalAlpha = 1; +} + +function drawVectorscope( + context: CanvasRenderingContext2D, + analysis: ColorGradingScopeAnalysis, + width: number, + height: number, +) { + const maximum = maximumDensity(analysis.vectorscope); + const centerX = width / 2; + const centerY = height / 2; + context.strokeStyle = "rgba(255,255,255,0.12)"; + context.beginPath(); + context.arc(centerX, centerY, Math.min(width, height) * 0.42, 0, Math.PI * 2); + context.stroke(); + context.beginPath(); + context.moveTo(centerX, 0); + context.lineTo(centerX, height); + context.moveTo(0, centerY); + context.lineTo(width, centerY); + context.stroke(); + const skinAngle = (-123 * Math.PI) / 180; + context.strokeStyle = "rgba(251,191,36,0.3)"; + context.beginPath(); + context.moveTo(centerX, centerY); + context.lineTo( + centerX + Math.cos(skinAngle) * Math.min(width, height) * 0.45, + centerY + Math.sin(skinAngle) * Math.min(width, height) * 0.45, + ); + context.stroke(); + context.fillStyle = "rgb(110 231 183)"; + for (let y = 0; y < 256; y += 1) { + for (let x = 0; x < 256; x += 1) { + const count = analysis.vectorscope[y * 256 + x] ?? 0; + if (!count) continue; + context.globalAlpha = densityAlpha(count, maximum); + context.fillRect( + (x / 255) * width, + (y / 255) * height, + Math.max(1, width / 256), + Math.max(1, height / 256), + ); + } + } + context.globalAlpha = 1; +} + +function drawScope( + canvas: HTMLCanvasElement, + mode: ColorGradingScopeMode, + analysis: ColorGradingScopeAnalysis, +) { + const context = canvas.getContext("2d"); + if (!context) return; + const { width, height } = canvas; + drawGrid(context, width, height); + if (mode === "histogram") { + drawHistogram(context, analysis, width, height); + } else if (mode === "waveform") { + drawDensity(context, analysis.waveform, 0, analysis.width, [226, 232, 240], 0, width, height); + } else if (mode === "parade") { + const laneWidth = width / 3; + ( + [ + [0, [248, 113, 113]], + [1, [74, 222, 128]], + [2, [96, 165, 250]], + ] as const + ).forEach(([channel, color]) => { + drawDensity( + context, + analysis.parade, + channel * analysis.width, + analysis.width, + color, + channel * laneWidth, + laneWidth, + height, + ); + }); + } else { + drawVectorscope(context, analysis, width, height); + } +} + +export function PropertyPanelColorScopes({ + captureFrame, + refreshKey, +}: { + captureFrame: () => Promise; + refreshKey: string; +}) { + const [open, setOpen] = useState(false); + const [mode, setMode] = useState("waveform"); + const canvasRef = useRef(null); + const { analysis, status, refresh } = useColorGradingScopes({ + open, + captureFrame, + refreshKey, + }); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + if (analysis) { + drawScope(canvas, mode, analysis); + return; + } + const context = canvas.getContext("2d"); + if (context) drawGrid(context, canvas.width, canvas.height); + }, [analysis, mode]); + + return ( +
+
+ + + {open && ( + + )} + +
+ {open && ( +
+
+ {MODES.map((candidate) => ( + + ))} +
+ candidate.id === mode)?.label ?? mode} scope, ${status}`} + className="block h-auto w-full border border-panel-hairline bg-black" + /> +
+ )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelColorSecondary.test.tsx b/packages/studio/src/components/editor/propertyPanelColorSecondary.test.tsx new file mode 100644 index 0000000000..c863b9182c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorSecondary.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; +import * as frameAnalysis from "./colorGradingFrameAnalysis"; +import { PropertyPanelColorSecondary } from "./propertyPanelColorSecondary"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function normalizedSecondaries(count: number) { + const grading = normalizeHfColorGrading({ + secondaries: Array.from({ length: count }, () => ({ key: {}, correction: {} })), + }); + if (!grading) throw new Error("Expected normalized grading"); + return grading.secondaries ?? []; +} + +function renderSecondary({ + secondaries = normalizedSecondaries(1), + captureFrame = vi.fn().mockResolvedValue(null), + onCommit = vi.fn(), +} = {}) { + const host = document.body.appendChild(document.createElement("div")); + const root = createRoot(host); + act(() => + root.render( + , + ), + ); + 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('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('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('[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('[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('[role="slider"][aria-label="Luma max"]') + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); + }); + expect(onCommit.mock.calls.at(-1)?.[0]?.[0]?.key.luma).toMatchObject({ + min: 0, + max: 0.01, + }); + act(() => root.unmount()); + }); + + it("preserves legal fractional hue centers at the wrap boundary", () => { + const grading = normalizeHfColorGrading({ + secondaries: [ + { + key: { hue: { center: 359.7, range: 20 } }, + correction: {}, + }, + ], + }); + if (!grading?.secondaries) throw new Error("Expected normalized secondaries"); + const { host, root } = renderSecondary({ secondaries: grading.secondaries }); + const hue = host.querySelector('[role="slider"][aria-label="Hue"]'); + + expect(Number(hue?.getAttribute("aria-valuenow"))).toBeCloseTo(359.7); + expect(hue?.getAttribute("aria-valuemax")).toBe("359.99"); + act(() => root.unmount()); + }); + + it("shows a useful error when frame capture is unavailable", async () => { + const { host, root, onCommit } = renderSecondary({ + captureFrame: vi.fn().mockRejectedValue(new Error("capture unavailable")), + }); + await captureSecondaryFrame(host); + + expect(host.querySelector('[role="alert"]')?.textContent).toContain( + "Preview frame unavailable", + ); + expect(onCommit).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("decodes a captured frame once and samples its center from the keyboard", async () => { + const pixels = new Uint8ClampedArray(4 * 4 * 4); + for (let offset = 0; offset < pixels.length; offset += 4) { + pixels[offset] = 255; + pixels[offset + 3] = 255; + } + const decode = vi.spyOn(frameAnalysis, "readColorGradingFramePixels").mockResolvedValue(pixels); + const { host, root, onCommit } = renderSecondary({ + captureFrame: vi.fn().mockResolvedValue({ + dataUrl: "data:image/png;base64,", + width: 4, + height: 4, + }), + }); + await captureSecondaryFrame(host); + + await act(async () => { + host + .querySelector('[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('button[aria-pressed="true"]'), + ).find((button) => button.textContent?.includes("Selection matte")); + expect(matteToggle).toBeDefined(); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelColorSecondary.tsx b/packages/studio/src/components/editor/propertyPanelColorSecondary.tsx new file mode 100644 index 0000000000..ff027492cd --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorSecondary.tsx @@ -0,0 +1,448 @@ +import { useEffect, useRef, useState } from "react"; +import { + getHfColorGradingCapabilities, + normalizeHfColorGrading, + type NormalizedHfColorGradingSecondary, +} from "@hyperframes/core/color-grading"; +import { Eyedropper, Plus, Trash } from "../../icons/SystemIcons"; +import { FlatSlider } from "./propertyPanelFlatPrimitives"; +import { FlatToggle } from "./propertyPanelFlatToggle"; +import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews"; +import { + buildColorGradingSecondaryMatte, + readColorGradingFramePixels, + sampleColorGradingSecondary, +} from "./colorGradingFrameAnalysis"; + +type Secondaries = readonly NormalizedHfColorGradingSecondary[]; + +const SECONDARY_CAPABILITIES = getHfColorGradingCapabilities().secondaries; +const PERCENT_SCALE = 100; +const RANGE_GAP = 0.01; +const HUE_CENTER_MAX = SECONDARY_CAPABILITIES.hue.center.maxExclusive - RANGE_GAP; + +function wrapHueCenter(value: number): number { + const { min, maxExclusive } = SECONDARY_CAPABILITIES.hue.center; + const span = maxExclusive - min; + return ((((value - min) % span) + span) % span) + min; +} + +const CORRECTION_CONTROLS = [ + ["hueShift", "Hue shift", 1, "°"], + ["saturation", "Saturation", PERCENT_SCALE, "%"], + ["luma", "Luma", PERCENT_SCALE, "%"], + ["temperature", "Warmth", PERCENT_SCALE, "%"], + ["tint", "Tint", PERCENT_SCALE, "%"], +] as const; + +function defaultSecondary(): NormalizedHfColorGradingSecondary { + const secondary = normalizeHfColorGrading({ + secondaries: [{ key: {}, correction: {} }], + })?.secondaries?.[0]; + if (!secondary) throw new Error("Missing default color grading secondary"); + return secondary; +} + +const DEFAULT_SECONDARY = defaultSecondary(); + +function sampleCapturedFrame( + pixels: Uint8ClampedArray, + frame: ColorGradingCapturedFrame, + target: HTMLElement, + clientX?: number, + clientY?: number, +) { + const rect = target.getBoundingClientRect(); + const x = (clientX === undefined ? 0.5 : (clientX - rect.left) / rect.width) * frame.width; + const y = (clientY === undefined ? 0.5 : (clientY - rect.top) / rect.height) * frame.height; + return sampleColorGradingSecondary(pixels, frame.width, frame.height, x, y); +} + +function percent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +export function PropertyPanelColorSecondary({ + secondaries, + captureFrame, + onCommit, +}: { + secondaries: Secondaries; + captureFrame: () => Promise; + onCommit: (secondaries: Secondaries) => void; +}) { + const [selectedIndex, setSelectedIndex] = useState(0); + const [sampleFrame, setSampleFrame] = useState(null); + const [samplePixels, setSamplePixels] = useState(null); + const [showMatte, setShowMatte] = useState(false); + const [captureError, setCaptureError] = useState(null); + const [sampling, setSampling] = useState(false); + const matteCanvasRef = useRef(null); + const activeIndex = Math.min(selectedIndex, Math.max(0, secondaries.length - 1)); + const selected = secondaries[activeIndex]; + + useEffect(() => { + const canvas = matteCanvasRef.current; + if (!showMatte || !canvas || !sampleFrame || !samplePixels || !selected) return; + const context = canvas.getContext("2d"); + if (!context) return; + const image = context.createImageData(sampleFrame.width, sampleFrame.height); + image.data.set( + buildColorGradingSecondaryMatte( + samplePixels, + sampleFrame.width, + sampleFrame.height, + selected.key, + ), + ); + context.putImageData(image, 0, 0); + }, [sampleFrame, samplePixels, selected, showMatte]); + + const replaceSelected = (next: NormalizedHfColorGradingSecondary) => { + if (!selected) return; + onCommit(secondaries.map((secondary, index) => (index === activeIndex ? next : secondary))); + }; + const addSecondary = () => { + if (secondaries.length >= SECONDARY_CAPABILITIES.max) return; + onCommit([...secondaries, DEFAULT_SECONDARY]); + setSelectedIndex(secondaries.length); + }; + const removeSelected = () => { + if (!selected) return; + const next = secondaries.filter((_, index) => index !== activeIndex); + onCommit(next); + setSelectedIndex(Math.max(0, Math.min(activeIndex, next.length - 1))); + setSampleFrame(null); + setSamplePixels(null); + setCaptureError(null); + }; + + return ( +
+
+ + {secondaries.map((_, index) => ( + + ))} + + + + + +
+ + {!selected ? ( + + ) : ( + <> + replaceSelected({ ...selected, enabled })} + /> + + {captureError && ( +

+ {captureError} +

+ )} + {sampleFrame && samplePixels && ( +
+
+ + +
+ {showMatte ? ( + + ) : ( + + )} +
+ )} + +
+ Qualifier +
+ + replaceSelected({ + ...selected, + key: { + ...selected.key, + hue: { ...selected.key.hue, center: wrapHueCenter(center) }, + }, + }) + } + /> + + replaceSelected({ + ...selected, + key: { + ...selected.key, + hue: { + ...selected.key.hue, + range, + softness: Math.min( + selected.key.hue.softness, + SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - range, + ), + }, + }, + }) + } + /> + + replaceSelected({ + ...selected, + key: { ...selected.key, hue: { ...selected.key.hue, softness } }, + }) + } + /> + {(["saturation", "luma"] as const).flatMap((key) => { + const label = key === "saturation" ? "Saturation" : "Luma"; + const range = selected.key[key]; + const capability = SECONDARY_CAPABILITIES[key]; + return [ + + replaceSelected({ + ...selected, + key: { + ...selected.key, + [key]: { + ...range, + min: Math.max( + capability.min.min, + Math.min(value / PERCENT_SCALE, range.max - RANGE_GAP), + ), + }, + }, + }) + } + />, + + replaceSelected({ + ...selected, + key: { + ...selected.key, + [key]: { + ...range, + max: Math.min( + capability.max.max, + Math.max(value / PERCENT_SCALE, range.min + RANGE_GAP), + ), + }, + }, + }) + } + />, + + replaceSelected({ + ...selected, + key: { + ...selected.key, + [key]: { ...range, softness: value / PERCENT_SCALE }, + }, + }) + } + />, + ]; + })} + +
+ Correction +
+ {CORRECTION_CONTROLS.map(([key, label, scale, suffix]) => { + const limit = SECONDARY_CAPABILITIES.correction[key]; + const value = selected.correction[key] * scale; + return ( + 0.0001 ? "explicitCustom" : "default"} + displayValue={`${Math.round(value)}${suffix}`} + onCommit={(next) => + replaceSelected({ + ...selected, + correction: { ...selected.correction, [key]: next / scale }, + }) + } + onReset={() => + replaceSelected({ + ...selected, + correction: { ...selected.correction, [key]: 0 }, + }) + } + /> + ); + })} + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelColorWheels.test.tsx b/packages/studio/src/components/editor/propertyPanelColorWheels.test.tsx new file mode 100644 index 0000000000..eb84d51cd8 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorWheels.test.tsx @@ -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()); + const surface = host.querySelector( + '[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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelColorWheels.tsx b/packages/studio/src/components/editor/propertyPanelColorWheels.tsx new file mode 100644 index 0000000000..9e8bb526de --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorWheels.tsx @@ -0,0 +1,334 @@ +import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { + getHfColorGradingCapabilities, + type HfColorGradingWheelKey, + type NormalizedHfColorGradingWheels, +} from "@hyperframes/core/color-grading"; +import { RotateCcw } from "../../icons/SystemIcons"; +import { clampNumber } from "../../utils/studioHelpers"; +import { GradingNumberField } from "./propertyPanelGradingNumberField"; +import { useInspectorGestureDraft } from "./useInspectorGestureTransaction"; + +const WHEELS: ReadonlyArray<{ key: HfColorGradingWheelKey; label: string }> = [ + { key: "shadows", label: "Shadows" }, + { key: "midtones", label: "Midtones" }, + { key: "highlights", label: "Highlights" }, +]; +type NormalizedTonalWheel = NormalizedHfColorGradingWheels[HfColorGradingWheelKey]; + +const WHEEL_CONTROLS = getHfColorGradingCapabilities().wheels.controls; +const PERCENT_SCALE = 100; +const HUE_MAX = WHEEL_CONTROLS.hue.maxExclusive - 0.01; +const RESET_WHEEL: NormalizedTonalWheel = { + hue: WHEEL_CONTROLS.hue.identity, + amount: WHEEL_CONTROLS.amount.identity, + level: WHEEL_CONTROLS.level.identity, +}; + +function wrapHue(value: number): number { + const { min, maxExclusive } = WHEEL_CONTROLS.hue; + const span = maxExclusive - min; + return ((((value - min) % span) + span) % span) + min; +} + +function formatNumberInput(value: number, decimals: number): string { + return String(Number(value.toFixed(decimals))); +} + +const formatIntegerInput = (value: number) => formatNumberInput(value, 0); + +function wheelFromPointer( + wheel: NormalizedTonalWheel, + clientX: number, + clientY: number, + rect: DOMRect, +): NormalizedTonalWheel { + const radius = Math.max(1, Math.min(rect.width, rect.height) / 2); + const x = (clientX - (rect.left + rect.width / 2)) / radius; + const y = (rect.top + rect.height / 2 - clientY) / radius; + return { + ...wheel, + hue: wrapHue((Math.atan2(y, x) * 180) / Math.PI), + amount: clampNumber(Math.hypot(x, y), WHEEL_CONTROLS.amount.min, WHEEL_CONTROLS.amount.max), + }; +} + +function updateWheel( + value: NormalizedHfColorGradingWheels, + key: HfColorGradingWheelKey, + wheel: NormalizedTonalWheel, +): NormalizedHfColorGradingWheels { + return { ...value, [key]: wheel }; +} + +function wheelFromKey( + wheel: NormalizedTonalWheel, + key: string, + large: boolean, +): NormalizedTonalWheel | null { + const hueStep = large ? 10 : 1; + const amountStep = large ? 0.1 : 0.01; + switch (key) { + case "ArrowLeft": + return { ...wheel, hue: wrapHue(wheel.hue - hueStep) }; + case "ArrowRight": + return { ...wheel, hue: wrapHue(wheel.hue + hueStep) }; + case "ArrowDown": + return { + ...wheel, + amount: clampNumber( + wheel.amount - amountStep, + WHEEL_CONTROLS.amount.min, + WHEEL_CONTROLS.amount.max, + ), + }; + case "ArrowUp": + return { + ...wheel, + amount: clampNumber( + wheel.amount + amountStep, + WHEEL_CONTROLS.amount.min, + WHEEL_CONTROLS.amount.max, + ), + }; + case "Home": + return { ...wheel, amount: WHEEL_CONTROLS.amount.min }; + case "End": + return { ...wheel, amount: WHEEL_CONTROLS.amount.max }; + default: + return null; + } +} + +function TonalWheel({ + label, + wheel, + disabled, + onBegin, + onPreview, + onSettle, + onCancel, + onReset, +}: { + label: string; + wheel: NormalizedTonalWheel; + disabled?: boolean; + onBegin: () => void; + onPreview: (wheel: NormalizedTonalWheel) => void; + onSettle: () => void; + onCancel: () => void; + onReset: () => void; +}) { + const pointerIdRef = useRef(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) => { + 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 ( +
+
+ {label} + +
+
{ + 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)", + }} + > + +
+ 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" + /> +
+ onPreview({ ...wheel, hue: wrapHue(hue) })} + onSettle={onSettle} + onCancel={onCancel} + /> + onPreview({ ...wheel, amount: amount / PERCENT_SCALE })} + onSettle={onSettle} + onCancel={onCancel} + /> + onPreview({ ...wheel, level: level / PERCENT_SCALE })} + onSettle={onSettle} + onCancel={onCancel} + /> +
+
+ ); +} + +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 ( +
+ {WHEELS.map(({ key, label }) => ( + previewWheel(key, wheel)} + onSettle={transaction.settle} + onCancel={transaction.cancel} + onReset={() => resetWheel(key)} + /> + ))} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx new file mode 100644 index 0000000000..2a191f9fb7 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx @@ -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 = { + 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 ( + + + + + + {runtimeStatus.message} + + + + + ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx index 92e2b29c83..987bd4ee78 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx @@ -7,7 +7,10 @@ import { FlatColorGradingAccessory, FlatColorGradingSection, } from "./propertyPanelFlatColorGradingSection"; -import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; +import { + normalizeHfColorGrading, + type NormalizedHfColorGrading, +} from "@hyperframes/core/color-grading"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -229,6 +232,7 @@ function neutralPropsBase() { height: 90, }, onRequestPresetPreviews: vi.fn(), + captureGradedFrame: vi.fn(async () => null), }; } @@ -241,6 +245,35 @@ describe("FlatColorGradingSection — Preset + LUT", () => { ...base, intensity: 0.4, adjust: { ...base.adjust, contrast: 0.3 }, + wheels: { + ...base.wheels, + shadows: { hue: 210, amount: 0.12, level: 0.04 }, + }, + curves: { + ...base.curves, + master: [ + [0, 0], + [0.5, 0.58], + [1, 1], + ] as const, + }, + hueCurves: { + ...base.hueCurves, + hueVsSaturation: [ + [0, 0], + [120, 0.1], + [240, 0], + ] as const, + }, + secondaries: + normalizeHfColorGrading({ + secondaries: [ + { + key: { hue: { center: 30, range: 15 } }, + correction: { saturation: 0.08 }, + }, + ], + })?.secondaries ?? [], details: { ...base.details, vignette: 0.2 }, effects: { ...base.effects, pixelate: 0.5 }, palette: ["#112233", "#ffffff"], @@ -277,11 +310,109 @@ describe("FlatColorGradingSection — Preset + LUT", () => { palette: ["#112233", "#ffffff"], lut: { src: "assets/luts/custom.cube", intensity: 0.6 }, }); + expect(onCommitColorGrading.mock.calls[0][0].wheels.shadows.amount).toBe(0); + expect(onCommitColorGrading.mock.calls[0][0].curves.master).toEqual([ + [0, 0], + [1, 1], + ]); + expect(onCommitColorGrading.mock.calls[0][0].hueCurves.hueVsSaturation).toEqual([]); + expect(onCommitColorGrading.mock.calls[0][0].secondaries).toEqual([]); expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).not.toBe(0.3); expect(onCommitColorGrading.mock.calls[0][0].details.vignette).not.toBe(0.2); act(() => root.unmount()); }); + it("orders manual controls like the shader pipeline", () => { + const { host, root } = renderInto(); + 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( + , + ); + + await act(async () => { + const sample = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Sample color from frame"), + ); + sample?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(captureGradedFrame).toHaveBeenCalledTimes(1); + const options = captureGradedFrame.mock.calls[0]?.[0]; + const sampledGrading = options?.grading; + if (!sampledGrading) throw new Error("expected the sampled grading input"); + expect(sampledGrading).toMatchObject({ + preset: null, + intensity: 1, + adjust: grading.adjust, + wheels: grading.wheels, + curves: grading.curves, + hueCurves: grading.hueCurves, + secondaries: [], + lut: null, + }); + expect(sampledGrading.details.vignette).toBe(0); + expect(sampledGrading.details.grain).toBe(0); + expect(sampledGrading.effects.pixelate).toBe(0); + act(() => root.unmount()); + }); + it("keeps effect-bearing saved styles out of Grade", () => { const { host, root } = renderInto(); const presets = host.querySelector('[data-flat-grade-preset-group="presets"]'); @@ -294,7 +425,7 @@ describe("FlatColorGradingSection — Preset + LUT", () => { act(() => root.unmount()); }); - it("shows exact selected-media preview images and requests a fresh batch on mount", () => { + it("shows ready selected-media preview images without requesting a duplicate batch", () => { const onRequestPresetPreviews = vi.fn(); const { host, root } = renderInto( { onRequestPresetPreviews={onRequestPresetPreviews} />, ); - expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1); + expect(onRequestPresetPreviews).not.toHaveBeenCalled(); expect( host.querySelector('[data-flat-grade-preview="bright-pop"]')?.src, ).toContain("data:image/png;base64,bright"); @@ -310,6 +441,40 @@ describe("FlatColorGradingSection — Preset + LUT", () => { act(() => root.unmount()); }); + it("requests an idle preview batch once", () => { + const onRequestPresetPreviews = vi.fn(); + const props = neutralPropsBase(); + const { root } = renderInto( + , + ); + 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( + , + ); + expect(onRequestPresetPreviews).not.toHaveBeenCalled(); + const retry = Array.from(host.querySelectorAll("button")).find( + (button) => button.textContent === "Retry look previews", + ); + if (!retry) throw new Error("expected an explicit preview retry"); + act(() => retry.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + it("shows the Custom LUT row collapsed by default, expanding to reveal the strength slider when a LUT is set", () => { const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.8 } }; const { host, root } = renderInto( diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx index 7a7348cf9a..f60b66e9d1 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx @@ -1,13 +1,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { HF_COLOR_GRADING_GRADE_PRESETS, - isHfColorGradingActive, normalizeHfColorGrading, + serializeHfColorGrading, type HfColorGradingAdjustKey, type HfColorGradingDetailKey, type NormalizedHfColorGrading, } from "@hyperframes/core/color-grading"; -import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons"; +import { Plus, Settings } from "../../icons/SystemIcons"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { LUT_EXT } from "../../utils/mediaTypes"; import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives"; @@ -18,138 +18,23 @@ import type { MediaMetadata, } from "./useColorGradingController"; import { presetPreviewHandlers } from "./propertyPanelPresetPreview"; +import { ColorCurves } from "./propertyPanelColorCurves"; +import { + COLOR_GRADING_ADJUST_SLIDERS, + COLOR_GRADING_DETAIL_SLIDERS, + colorGradingWithAdjust, + colorGradingWithDetail, + createColorGradingActions, + GRAIN_TUNE_SLIDERS, + normalizedColorGradingDefault, + VIGNETTE_TUNE_SLIDERS, + visibleColorGradingIntensity, +} from "./propertyPanelColorGradingControls"; +import { PropertyPanelColorScopes } from "./propertyPanelColorScopes"; +import { PropertyPanelColorSecondary } from "./propertyPanelColorSecondary"; +import { ColorWheels } from "./propertyPanelColorWheels"; -const STATUS_DOT_CLASS: Record = { - 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 ( - - - - - - {runtimeStatus.message} - - - - - ); -} - -const ADJUST_SLIDERS: Array<{ - key: HfColorGradingAdjustKey; - label: string; - min: number; - max: number; - step: number; -}> = [ - { key: "exposure", label: "Exposure", min: -200, max: 200, step: 5 }, - { key: "contrast", label: "Contrast", min: -100, max: 100, step: 1 }, - { key: "highlights", label: "Highlights", min: -100, max: 100, step: 1 }, - { key: "shadows", label: "Shadows", min: -100, max: 100, step: 1 }, - { key: "whites", label: "White Point", min: -100, max: 100, step: 1 }, - { key: "blacks", label: "Black Point", min: -100, max: 100, step: 1 }, - { key: "temperature", label: "Warmth", min: -100, max: 100, step: 1 }, - { key: "tint", label: "Tint", min: -100, max: 100, step: 1 }, - { key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1 }, - { key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 }, -]; - -function visibleIntensity(grading: NormalizedHfColorGrading): number { - // Earlier drafts could persist 0% strength; the next manual edit should revive visible grading. - return grading.intensity === 0 ? 1 : grading.intensity; -} +export { FlatColorGradingAccessory } from "./propertyPanelFlatColorGradingAccessory"; function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string { if (key === "exposure") { @@ -159,30 +44,17 @@ function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): st return `${Math.round(rawPercent)}%`; } -const DETAIL_SLIDERS: Array<{ - key: HfColorGradingDetailKey; - label: string; - defaultValue: number; -}> = [ - { key: "vignette", label: "Vignette", defaultValue: 0 }, - { key: "vignetteMidpoint", label: "Midpoint", defaultValue: 0.5 }, - { key: "vignetteRoundness", label: "Roundness", defaultValue: 0 }, - { key: "vignetteFeather", label: "Feather", defaultValue: 0.65 }, - { key: "grain", label: "Grain", defaultValue: 0 }, - { key: "grainSize", label: "Grain Size", defaultValue: 0.25 }, - { key: "grainRoughness", label: "Roughness", defaultValue: 0.5 }, -]; const detailByKey = (key: HfColorGradingDetailKey) => { - const spec = DETAIL_SLIDERS.find((d) => d.key === key); + const spec = COLOR_GRADING_DETAIL_SLIDERS.find((candidate) => candidate.key === key); if (!spec) throw new Error(`Unknown color grading detail key: ${key}`); return spec; }; -const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [ - "vignetteMidpoint", - "vignetteRoundness", - "vignetteFeather", -]; -const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"]; + +function resolveColorGrading(grading: Parameters[0]) { + const resolved = normalizeHfColorGrading(grading); + if (!resolved) throw new Error("Missing resolved color grading"); + return resolved; +} function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) { if (metadata?.color.dynamicRange !== "hdr") return null; @@ -237,6 +109,7 @@ export function FlatColorGradingSection({ mediaMetadata, presetPreviews, onRequestPresetPreviews, + captureGradedFrame, }: { grading: NormalizedHfColorGrading; assets: string[]; @@ -254,6 +127,7 @@ export function FlatColorGradingSection({ mediaMetadata: MediaMetadata | null; presetPreviews: ColorGradingPresetPreviews; onRequestPresetPreviews: () => void; + captureGradedFrame: ColorGradingControllerState["captureGradedFrame"]; }) { const track = useTrackDesignInput(); const lutInputRef = useRef(null); @@ -265,70 +139,65 @@ export function FlatColorGradingSection({ ); const lut = grading.lut; const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null; + const resolvedGrading = useMemo(() => resolveColorGrading(grading), [grading]); + const secondaryInputGrading = useMemo( + () => + resolveColorGrading({ + intensity: 1, + adjust: resolvedGrading.adjust, + wheels: resolvedGrading.wheels, + curves: resolvedGrading.curves, + hueCurves: resolvedGrading.hueCurves, + colorSpace: resolvedGrading.colorSpace, + }), + [ + resolvedGrading.adjust, + resolvedGrading.colorSpace, + resolvedGrading.curves, + resolvedGrading.hueCurves, + resolvedGrading.wheels, + ], + ); + const actions = createColorGradingActions(grading, onCommitColorGrading); + const scopesRefreshKey = useMemo(() => serializeHfColorGrading(grading), [grading]); useEffect(() => { - if ( - presetPreviews.status !== "loading" && - HF_COLOR_GRADING_GRADE_PRESETS.some((preset) => !presetPreviews.images[preset.id]) - ) { - onRequestPresetPreviews(); - } - }, [onRequestPresetPreviews, presetPreviews.images, presetPreviews.status]); + if (presetPreviews.status === "idle") onRequestPresetPreviews(); + }, [onRequestPresetPreviews, presetPreviews.status]); const resolvePreset = (presetId: string) => { const resolved = normalizeHfColorGrading({ preset: presetId, lut: grading.lut }); - return resolved ? { ...resolved, effects: grading.effects, palette: grading.palette } : grading; + return resolved + ? { + ...resolved, + effects: grading.effects, + palette: grading.palette, + } + : grading; }; useEffect(() => () => onPreviewColorGrading(null), [onPreviewColorGrading]); - const updateIntensity = (value: number) => { - onCommitColorGrading({ ...grading, intensity: value / 100 }); - }; - const applyLut = (src: string | null, intensity = 1) => { - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - lut: src ? { src, intensity } : null, - }); - }; - const importLuts = async (files: FileList | null) => { - if (!files?.length || !onImportAssets) return; - const uploaded = await onImportAssets(files, "assets/luts"); - const firstLut = uploaded.find((asset) => LUT_EXT.test(asset)); - if (firstLut) { - track("button", "Import LUT"); - applyLut(firstLut, 1); - } - }; const renderDetailSlider = (key: HfColorGradingDetailKey) => { const spec = detailByKey(key); const value = grading.details[key]; - const isSet = Math.abs(value - spec.defaultValue) > 1e-4; + const defaultValue = normalizedColorGradingDefault(spec); + const isSet = Math.abs(value - defaultValue) > 1e-4; return ( - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - details: { ...grading.details, [key]: next / 100 }, - }) - } - onReset={() => - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - details: { ...grading.details, [key]: spec.defaultValue }, - }) + onCommitColorGrading(colorGradingWithDetail(grading, key, next / spec.scale)) } + onReset={() => onCommitColorGrading(colorGradingWithDetail(grading, key, defaultValue))} /> ); }; @@ -336,7 +205,20 @@ export function FlatColorGradingSection({ return (
+ captureGradedFrame()} + refreshKey={scopesRefreshKey} + />
+ {presetPreviews.status === "unavailable" && ( + + )}
{HF_COLOR_GRADING_GRADE_PRESETS.map((preset) => { const label = preset.label; @@ -395,10 +277,105 @@ export function FlatColorGradingSection({ max={100} tier={grading.intensity === 1 ? "default" : "explicitCustom"} displayValue={`${Math.round(grading.intensity * 100)}%`} - onCommit={updateIntensity} - onReset={() => updateIntensity(100)} + onCommit={actions.setIntensityPercent} + onReset={() => actions.setIntensityPercent(100)} /> +
+
+ Primary +
+ {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 ( +
+ + onCommitColorGrading( + colorGradingWithAdjust(grading, slider.key, next / slider.scale), + ) + } + onReset={() => onCommitColorGrading(colorGradingWithAdjust(grading, slider.key, 0))} + /> +
+ ); + })} +
+ +
+
+ Color wheels +
+ + onPreviewColorGrading({ + ...grading, + intensity: visibleColorGradingIntensity(grading), + wheels, + }) + } + onCommit={(wheels) => + onCommitColorGrading({ + ...grading, + intensity: visibleColorGradingIntensity(grading), + wheels, + }) + } + /> +
+ +
+
+ Curves +
+ + onPreviewColorGrading({ + ...grading, + intensity: visibleColorGradingIntensity(grading), + curves, + hueCurves, + }) + } + onCommit={({ curves, hueCurves }) => + onCommitColorGrading({ + ...grading, + intensity: visibleColorGradingIntensity(grading), + curves, + hueCurves, + }) + } + /> +
+ +
+
+ Secondary color +
+ captureGradedFrame({ grading: secondaryInputGrading })} + onCommit={(secondaries) => + onCommitColorGrading({ + ...grading, + intensity: visibleColorGradingIntensity(grading), + secondaries, + }) + } + /> +
+
)}
-
-
- Adjust -
- {ADJUST_SLIDERS.map((slider) => { - const rawPercent = grading.adjust[slider.key] * 100; - const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6; - return ( -
- - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - adjust: { ...grading.adjust, [slider.key]: next / 100 }, - }) - } - onReset={() => - onCommitColorGrading({ - ...grading, - intensity: visibleIntensity(grading), - adjust: { ...grading.adjust, [slider.key]: 0 }, - }) - } - /> -
- ); - })} -
-
Finish @@ -545,8 +486,8 @@ export function FlatColorGradingSection({
{detailSettingsOpen && (
- {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map( - renderDetailSlider, + {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS).map( + (slider) => renderDetailSlider(slider.key), )}
)} diff --git a/packages/studio/src/components/editor/propertyPanelGradingNumberField.test.tsx b/packages/studio/src/components/editor/propertyPanelGradingNumberField.test.tsx new file mode 100644 index 0000000000..63e81a4bcb --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelGradingNumberField.test.tsx @@ -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( + , + ), + ); + 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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelGradingNumberField.tsx b/packages/studio/src/components/editor/propertyPanelGradingNumberField.tsx new file mode 100644 index 0000000000..8d13366c15 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelGradingNumberField.tsx @@ -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 ( + + ); +} diff --git a/packages/studio/src/components/editor/useColorGradingController.test.ts b/packages/studio/src/components/editor/useColorGradingController.test.ts index d16fe5f620..e0645e10b6 100644 --- a/packages/studio/src/components/editor/useColorGradingController.test.ts +++ b/packages/studio/src/components/editor/useColorGradingController.test.ts @@ -56,22 +56,30 @@ function makeElement(overrides: Partial = {}): DomEditSelectio } as DomEditSelection; } +type ApplyScope = ( + scope: "source-file" | "project", + value: string | null, +) => Promise<{ changedFiles: number; changedElements: number }>; + function HookHost({ onState, onSetAttributeLive, element, previewIframeRef, + onApplyScope, }: { onState: (state: ReturnType) => void; onSetAttributeLive: (attr: string, value: string | null) => void; element: DomEditSelection; previewIframeRef?: React.RefObject; + onApplyScope?: ApplyScope; }) { const state = useColorGradingController({ projectId: "proj", element, previewIframeRef, onSetAttributeLive, + onApplyScope, }); onState(state); return null; @@ -81,6 +89,7 @@ function renderHook( onSetAttributeLive: (attr: string, value: string | null) => void, initialElement: DomEditSelection = makeElement(), previewIframeRef?: React.RefObject, + onApplyScope?: ApplyScope, ) { const host = document.createElement("div"); document.body.append(host); @@ -94,6 +103,7 @@ function renderHook( onSetAttributeLive, element, previewIframeRef, + onApplyScope, }), ); }); @@ -129,6 +139,21 @@ function createPreviewFrame() { return { contentWindow, iframe }; } +function installPreviewRenderer(contentWindow: PreviewWindow) { + const renderPreviews = vi + .fn() + .mockImplementation(async (_target: unknown, candidates: Array<{ id: string }>) => ({ + width: 160, + height: 90, + images: candidates.map(({ id }) => ({ + id, + dataUrl: `data:image/png;base64,${id}`, + })), + })); + contentWindow.__hf = { colorGrading: { renderPreviews } }; + return renderPreviews; +} + async function flushPreviewRequest() { act(() => vi.advanceTimersByTime(0)); await act(async () => { @@ -161,23 +186,37 @@ describe("useColorGradingController", () => { vi.useFakeTimers(); const devicePixelRatio = vi.spyOn(window, "devicePixelRatio", "get").mockReturnValue(2); const { contentWindow, iframe } = createPreviewFrame(); - const renderPreviews = vi.fn().mockResolvedValue({ - width: 160, - height: 90, - images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,bright" }], + 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"], + }), + }, }); - contentWindow.__hf = { colorGrading: { renderPreviews } }; - const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe }); + 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: { "bright-pop": "data:image/png;base64,bright" }, + images: expect.objectContaining({ "bright-pop": "data:image/png;base64,bright-pop" }), width: 160, height: 90, }); @@ -186,19 +225,96 @@ describe("useColorGradingController", () => { vi.useRealTimers(); }); - it("requests exact effect families and retains earlier family images", async () => { + it("retains partial preset previews and exposes retry after the batch times out", async () => { vi.useFakeTimers(); const { contentWindow, iframe } = createPreviewFrame(); - const renderPreviews = vi - .fn() - .mockImplementation(async (_target: unknown, candidates: Array<{ id: string }>) => ({ - width: 160, - height: 90, - images: candidates.map(({ id }) => ({ id, dataUrl: `data:image/png;base64,${id}` })), - })); + const renderPreviews = vi.fn().mockResolvedValue({ + width: 160, + height: 90, + images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,bright" }], + }); contentWindow.__hf = { colorGrading: { renderPreviews } }; const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe }); + act(() => getState().requestPresetPreviews()); + await flushPreviewRequest(); + expect(getState().presetPreviews).toMatchObject({ + status: "loading", + images: { "bright-pop": "data:image/png;base64,bright" }, + }); + + 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()); + vi.useRealTimers(); + }); + + it("copies a disabled authored secondary to a broader scope", async () => { + const onApplyScope = vi.fn().mockResolvedValue({ + changedFiles: 1, + changedElements: 1, + }); + const grading = { + secondaries: [ + { + enabled: false, + key: { hue: { center: 215, range: 25 } }, + correction: { saturation: 0.15 }, + }, + ], + }; + const { root, getState } = renderHook( + vi.fn(), + makeElement({ dataAttributes: { "color-grading": JSON.stringify(grading) } }), + undefined, + onApplyScope, + ); + + await act(async () => getState().applyToScope()); + + expect(onApplyScope).toHaveBeenCalledWith( + "source-file", + expect.stringContaining('"enabled":false'), + ); + act(() => root.unmount()); + }); + + it("requests exact effect families and retains earlier family images", async () => { + vi.useFakeTimers(); + const { contentWindow, iframe } = createPreviewFrame(); + const renderPreviews = installPreviewRenderer(contentWindow); + const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe }); + act(() => getState().requestEffectPreviews(["blur", "pixelate", "bloom"])); await flushPreviewRequest(); @@ -490,7 +606,7 @@ describe("useColorGradingController", () => { vi.useRealTimers(); }); - it("resetGrading resets Grade fields without clearing Effects or Palette", () => { + it("resetGrading resets Grade fields without clearing LUT, Effects, or Palette", () => { const { root, getState } = renderHook(vi.fn()); const grading = normalizeHfColorGrading({ preset: "bright-pop", @@ -507,7 +623,7 @@ describe("useColorGradingController", () => { }); expect(getState().grading).toMatchObject({ preset: "neutral", - lut: null, + lut: { src: "assets/luts/custom.cube", intensity: 0.6 }, effects: { pixelate: 0.5 }, palette: ["#112233", "#ffffff"], }); diff --git a/packages/studio/src/components/editor/useColorGradingController.ts b/packages/studio/src/components/editor/useColorGradingController.ts index e1994401d6..d29d115047 100644 --- a/packages/studio/src/components/editor/useColorGradingController.ts +++ b/packages/studio/src/components/editor/useColorGradingController.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { HF_COLOR_GRADING_ATTR, + hasHfColorGradingAuthoredValues, isHfColorGradingActive, normalizeHfColorGrading, serializeHfColorGrading, @@ -21,6 +22,7 @@ import { } from "../../player/lib/runtimeProtocol"; import { useColorGradingPreviews, + type ColorGradingCapturedFrame, type ColorGradingPresetPreviews, type ColorGradingPreviewOptions, } from "./useColorGradingPreviews"; @@ -169,6 +171,9 @@ export interface ColorGradingControllerState { next: NormalizedHfColorGrading | null, options?: ColorGradingPreviewOptions, ) => void; + captureGradedFrame: (options?: { + grading?: NormalizedHfColorGrading; + }) => Promise; commitCompare: (enabled: boolean) => void; setApplyScope: (scope: "source-file" | "project") => void; applyToScope: () => Promise; @@ -472,7 +477,7 @@ export function useColorGradingController({ } scheduleRuntimeStatusRefresh(); if (persistTimerRef.current) clearTimeout(persistTimerRef.current); - pendingPersistValueRef.current = isHfColorGradingActive(nextGrading) + pendingPersistValueRef.current = hasHfColorGradingAuthoredValues(nextGrading) ? serializeHfColorGrading(nextGrading) : null; pendingPersistGradingRef.current = nextGrading; @@ -519,7 +524,9 @@ export function useColorGradingController({ if (!onApplyScope || applyBusy) return; setApplyBusy(true); try { - const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null; + const value = hasHfColorGradingAuthoredValues(grading) + ? serializeHfColorGrading(grading) + : null; await onApplyScope(applyScope, value); } finally { setApplyBusy(false); @@ -539,6 +546,7 @@ export function useColorGradingController({ requestEffectPreviews: previewController.requestEffectPreviews, commitColorGrading, previewColorGrading: previewController.previewColorGrading, + captureGradedFrame: previewController.captureGradedFrame, commitCompare, setApplyScope, applyToScope, @@ -546,6 +554,7 @@ export function useColorGradingController({ const neutral = defaultColorGrading(); commitColorGrading({ ...neutral, + lut: latestGradingRef.current.lut, effects: latestGradingRef.current.effects, palette: latestGradingRef.current.palette, }); diff --git a/packages/studio/src/components/editor/useColorGradingPreviews.ts b/packages/studio/src/components/editor/useColorGradingPreviews.ts index 2c88013918..c66f4163a1 100644 --- a/packages/studio/src/components/editor/useColorGradingPreviews.ts +++ b/packages/studio/src/components/editor/useColorGradingPreviews.ts @@ -16,6 +16,12 @@ export interface ColorGradingPresetPreviews { height: number; } +export interface ColorGradingCapturedFrame { + dataUrl: string; + width: number; + height: number; +} + type ColorGradingPreviewKind = "presets" | "effects"; export interface ColorGradingPreviewOptions { @@ -78,12 +84,21 @@ function toPreviewColorGrading(grading: NormalizedHfColorGrading): unknown { function previewCandidates( request: PreviewRequest, - lut: NormalizedHfColorGrading["lut"], + grading: Pick, ): Array<{ id: string; grading: unknown }> { if (request.kind === "presets") { return HF_COLOR_GRADING_PRESETS.map((preset) => { - const resolved = normalizeHfColorGrading({ preset: preset.id, lut }); - return { id: preset.id, grading: resolved ? toPreviewColorGrading(resolved) : null }; + const resolved = normalizeHfColorGrading({ preset: preset.id, lut: grading.lut }); + return { + id: preset.id, + grading: resolved + ? toPreviewColorGrading({ + ...resolved, + effects: grading.effects, + palette: grading.palette, + }) + : null, + }; }); } return (request.effects ?? HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS).map((effect) => { @@ -98,16 +113,19 @@ async function renderRequestedPreviews( runtime: RuntimeColorGradingPreview, target: HfColorGradingTarget, request: PreviewRequest, - lut: NormalizedHfColorGrading["lut"], + grading: Pick, ) { - const batch = await runtime.renderPreviews(target, previewCandidates(request, lut), { + const candidates = previewCandidates(request, grading); + const batch = await runtime.renderPreviews(target, candidates, { maxDimension: previewMaxDimension(), }); if (!batch) return null; const images = Object.fromEntries( batch.images.flatMap((image) => (image.dataUrl ? [[image.id, image.dataUrl]] : [])), ); - return Object.keys(images).length ? { ...batch, images } : null; + return Object.keys(images).length + ? { ...batch, images, complete: Object.keys(images).length === candidates.length } + : null; } export function useColorGradingPreviews({ @@ -154,6 +172,7 @@ export function useColorGradingPreviews({ let cancelled = false; let complete = false; let inFlight = false; + let timedOut = false; const timers: number[] = []; setState((current) => ({ ...current, @@ -169,15 +188,19 @@ export function useColorGradingPreviews({ if (!runtime) return; inFlight = true; try { - const result = await renderRequestedPreviews(runtime, target, request, grading.lut); + const result = await renderRequestedPreviews(runtime, target, request, { + effects: grading.effects, + lut: grading.lut, + palette: grading.palette, + }); if (cancelled || !result) return; - complete = true; + complete = result.complete; setState((current) => ({ ...current, previews: { ...current.previews, [kind]: { - status: "ready", + status: result.complete ? "ready" : timedOut ? "unavailable" : "loading", images: kind === "effects" ? { ...current.previews[kind].images, ...result.images } @@ -206,11 +229,12 @@ export function useColorGradingPreviews({ timers.push( window.setTimeout(() => { if (cancelled || complete) return; + timedOut = true; setState((current) => ({ ...current, previews: { ...current.previews, - [kind]: { status: "unavailable", images: {}, width: 16, height: 9 }, + [kind]: { ...current.previews[kind], status: "unavailable" }, }, })); }, 1600), @@ -221,7 +245,7 @@ export function useColorGradingPreviews({ iframe.removeEventListener("load", attempt); window.removeEventListener("message", onMessage); }; - }, [grading.lut, previewIframeRef, request, target]); + }, [grading.effects, grading.lut, grading.palette, previewIframeRef, request, target]); const stopAnimatedPreview = useCallback(() => { const session = animatedRef.current; @@ -296,6 +320,24 @@ export function useColorGradingPreviews({ (effects: readonly HfColorGradingActiveEffectKey[]) => requestPreviews("effects", effects), [requestPreviews], ); + const captureGradedFrame = useCallback( + async ({ + grading: requestedGrading = gradingRef.current, + }: { + grading?: NormalizedHfColorGrading; + } = {}): Promise => { + const runtime = readRuntime(previewIframeRef?.current); + if (!runtime) return null; + const batch = await runtime.renderPreviews( + target, + [{ id: "capture", grading: toPreviewColorGrading(requestedGrading) }], + { maxDimension: 320, useMediaTime: true }, + ); + const dataUrl = batch?.images[0]?.dataUrl; + return batch && dataUrl ? { dataUrl, width: batch.width, height: batch.height } : null; + }, + [gradingRef, previewIframeRef, target], + ); return { presetPreviews: previews.presets, @@ -303,5 +345,6 @@ export function useColorGradingPreviews({ requestPresetPreviews, requestEffectPreviews, previewColorGrading, + captureGradedFrame, }; } diff --git a/packages/studio/src/components/editor/useColorGradingScopes.test.tsx b/packages/studio/src/components/editor/useColorGradingScopes.test.tsx new file mode 100644 index 0000000000..61395ad22e --- /dev/null +++ b/packages/studio/src/components/editor/useColorGradingScopes.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews"; +import { useColorGradingScopes } from "./useColorGradingScopes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function Harness({ + open, + refreshKey, + captureFrame, +}: { + open: boolean; + refreshKey: string; + captureFrame: () => Promise; +}) { + const scopes = useColorGradingScopes({ open, refreshKey, captureFrame }); + return ( + <> + {scopes.status} + {scopes.analysis ? "ready" : "empty"} + + + ); +} + +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(), + ); + 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()); + 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()); + 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(), + ); + + await act(async () => vi.advanceTimersByTimeAsync(180)); + expect(host.querySelector("[data-status]")?.textContent).toBe("unavailable"); + act(() => root.unmount()); + }); + + it("clears stale analysis while a refresh is pending and when it is unavailable", async () => { + vi.useFakeTimers(); + installPixelDecode(); + let resolveRefresh: (frame: ColorGradingCapturedFrame | null) => void = () => {}; + const pendingRefresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + const captureFrame = vi + .fn() + .mockResolvedValueOnce({ + dataUrl: "data:image/png;base64,scope", + width: 1, + height: 1, + }) + .mockReturnValueOnce(pendingRefresh); + const host = document.body.appendChild(document.createElement("div")); + const root = createRoot(host); + act(() => root.render()); + + await act(async () => vi.advanceTimersByTimeAsync(180)); + expect(host.querySelector("[data-analysis]")?.textContent).toBe("ready"); + + act(() => { + host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + act(() => vi.advanceTimersByTime(180)); + expect(host.querySelector("[data-status]")?.textContent).toBe("loading"); + expect(host.querySelector("[data-analysis]")?.textContent).toBe("empty"); + + await act(async () => resolveRefresh(null)); + expect(host.querySelector("[data-status]")?.textContent).toBe("unavailable"); + expect(host.querySelector("[data-analysis]")?.textContent).toBe("empty"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/useColorGradingScopes.ts b/packages/studio/src/components/editor/useColorGradingScopes.ts new file mode 100644 index 0000000000..dad6cd1541 --- /dev/null +++ b/packages/studio/src/components/editor/useColorGradingScopes.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from "react"; +import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews"; +import { + analyzeColorGradingFrame, + readColorGradingFramePixels, + type ColorGradingScopeAnalysis, +} from "./colorGradingFrameAnalysis"; + +type ColorGradingScopeStatus = "idle" | "loading" | "ready" | "unavailable"; +const CAPTURE_DEBOUNCE_MS = 180; + +export function useColorGradingScopes({ + open, + captureFrame, + refreshKey, +}: { + open: boolean; + captureFrame: () => Promise; + refreshKey: string; +}) { + const [analysis, setAnalysis] = useState(null); + const [status, setStatus] = useState("idle"); + const [refreshVersion, setRefreshVersion] = useState(0); + const captureRef = useRef(captureFrame); + captureRef.current = captureFrame; + + useEffect(() => { + if (!open) { + setAnalysis(null); + setStatus("idle"); + return; + } + let cancelled = false; + const timer = window.setTimeout(async () => { + setAnalysis(null); + setStatus("loading"); + try { + const frame = await captureRef.current(); + if (cancelled) return; + if (!frame) { + setStatus("unavailable"); + return; + } + const pixels = await readColorGradingFramePixels(frame); + if (!pixels) throw new Error("Canvas 2D unavailable"); + const next = analyzeColorGradingFrame(pixels, frame.width, frame.height); + if (!cancelled) { + setAnalysis(next); + setStatus("ready"); + } + } catch { + if (!cancelled) setStatus("unavailable"); + } + }, CAPTURE_DEBOUNCE_MS); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [open, refreshKey, refreshVersion]); + + return { analysis, status, refresh: () => setRefreshVersion((version) => version + 1) }; +} diff --git a/packages/studio/src/components/editor/useInspectorGestureTransaction.ts b/packages/studio/src/components/editor/useInspectorGestureTransaction.ts index 950cab5fee..df61e55867 100644 --- a/packages/studio/src/components/editor/useInspectorGestureTransaction.ts +++ b/packages/studio/src/components/editor/useInspectorGestureTransaction.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; /** One owner for continuous inspector edits: preview freely, persist once. */ export function useInspectorGestureTransaction({ @@ -58,3 +58,32 @@ export function useInspectorGestureTransaction({ return { begin, preview, settle, cancel, activeRef }; } + +export function useInspectorGestureDraft({ + 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 }; +} diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index ca3f24d95d..b2915005e9 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -22,6 +22,8 @@ import { Gear, Scissors as PhScissors, Link as PhLink, + Eyedropper as PhEyedropper, + Trash as PhTrash, } from "@phosphor-icons/react"; import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react"; @@ -71,3 +73,5 @@ export const RotateCw = makeIcon(ArrowClockwise); export const Settings = makeIcon(Gear); export const Scissors = makeIcon(PhScissors); export const Link = makeIcon(PhLink); +export const Eyedropper = makeIcon(PhEyedropper); +export const Trash = makeIcon(PhTrash);