From 9b6bed282474fee4ffce7ff704dc319c0863dbfe Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 15:04:29 -0700 Subject: [PATCH] feat(studio): add FlatColorGradingSection Preset and Custom LUT rows --- ...pertyPanelFlatColorGradingSection.test.tsx | 114 +++++++++++- .../propertyPanelFlatColorGradingSection.tsx | 171 +++++++++++++++++- 2 files changed, 281 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx index adfb8c40a..c9f79171f 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx @@ -3,7 +3,10 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { FlatColorGradingAccessory } from "./propertyPanelFlatColorGradingSection"; +import { + FlatColorGradingAccessory, + FlatColorGradingSection, +} from "./propertyPanelFlatColorGradingSection"; import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -71,3 +74,112 @@ describe("FlatColorGradingAccessory", () => { act(() => root.unmount()); }); }); + +function neutralPropsBase() { + return { + grading: neutralGrading(), + assets: [] as string[], + onCommitColorGrading: vi.fn(), + applyScope: "source-file" as const, + applyBusy: false, + onSetApplyScope: vi.fn(), + onApplyToScope: vi.fn(), + onApplyScopeAvailable: true, + mediaMetadata: null, + }; +} + +describe("FlatColorGradingSection — Preset + LUT", () => { + it("renders the Preset dropdown with id/label pairs and fires onCommitColorGrading on change", () => { + const onCommitColorGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + const presetSelect = host.querySelector( + '[data-flat-grade-preset="true"] select', + ); + if (!presetSelect) throw new Error("expected a preset select"); + expect(presetSelect.value).toBe("neutral"); + act(() => { + presetSelect.value = "fresh-pop"; + presetSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("fresh-pop"); + 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( + , + ); + const lutToggle = host.querySelector('[data-flat-grade-lut-toggle="true"]'); + expect(lutToggle).not.toBeNull(); + act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(host.textContent).toContain("warm.cube"); + act(() => root.unmount()); + }); + + it("commits the selected catalog LUT via the select control, resetting intensity to 1 when switching LUTs", () => { + const onCommitColorGrading = vi.fn(); + const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.5 } }; + const { host, root } = renderInto( + , + ); + const lutToggle = host.querySelector('[data-flat-grade-lut-toggle="true"]'); + act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const lutSelect = host.querySelector('[data-flat-grade-lut-select="true"]'); + if (!lutSelect) throw new Error("expected a LUT catalog select"); + act(() => { + lutSelect.value = "assets/luts/cool.cube"; + lutSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].lut).toEqual({ + src: "assets/luts/cool.cube", + intensity: 1, + }); + act(() => root.unmount()); + }); + + it("imports a LUT via the hidden file input and commits the resolved asset", async () => { + const onCommitColorGrading = vi.fn(); + const onImportAssets = vi.fn().mockResolvedValue(["assets/luts/x.cube"]); + const { host, root } = renderInto( + , + ); + const lutToggle = host.querySelector('[data-flat-grade-lut-toggle="true"]'); + act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const fileInput = host.querySelector('input[type="file"]'); + if (!fileInput) throw new Error("expected a hidden file input"); + const file = new File(["cube data"], "x.cube"); + Object.defineProperty(fileInput, "files", { value: [file], configurable: true }); + await act(async () => { + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onImportAssets).toHaveBeenCalledTimes(1); + expect(onImportAssets.mock.calls[0][0]).toEqual([file]); + expect(onImportAssets.mock.calls[0][1]).toBe("assets/luts"); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].lut).toEqual({ + src: "assets/luts/x.cube", + intensity: 1, + }); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx index 2f9c6376d..dd161efba 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx @@ -1,6 +1,15 @@ -import { isHfColorGradingActive } from "@hyperframes/core/color-grading"; -import { Compare, RotateCcw } from "../../icons/SystemIcons"; -import type { ColorGradingControllerState } from "./useColorGradingController"; +import { useMemo, useRef, useState } from "react"; +import { + HF_COLOR_GRADING_PRESETS, + isHfColorGradingActive, + normalizeHfColorGrading, + type NormalizedHfColorGrading, +} from "@hyperframes/core/color-grading"; +import { Compare, Plus, RotateCcw } from "../../icons/SystemIcons"; +import { LUT_EXT } from "../../utils/mediaTypes"; +import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives"; +import { resolveValueTier } from "./propertyPanelValueTier"; +import type { ColorGradingControllerState, MediaMetadata } from "./useColorGradingController"; const STATUS_DOT_CLASS: Record = { active: "bg-emerald-400", @@ -66,3 +75,159 @@ export function FlatColorGradingAccessory({ ); } + +const PRESET_OPTIONS = HF_COLOR_GRADING_PRESETS.map((p) => ({ value: p.id, label: p.label })); + +export function FlatColorGradingSection({ + grading, + assets, + onImportAssets, + onCommitColorGrading, + applyScope: _applyScope, + applyBusy: _applyBusy, + onSetApplyScope: _onSetApplyScope, + onApplyToScope: _onApplyToScope, + onApplyScopeAvailable: _onApplyScopeAvailable, + mediaMetadata: _mediaMetadata, +}: { + grading: NormalizedHfColorGrading; + assets: string[]; + onImportAssets?: (files: FileList, dir?: string) => Promise; + onCommitColorGrading: (next: NormalizedHfColorGrading) => void; + applyScope: "source-file" | "project"; + applyBusy: boolean; + onSetApplyScope: (scope: "source-file" | "project") => void; + onApplyToScope: () => void; + onApplyScopeAvailable: boolean; + mediaMetadata: MediaMetadata | null; +}) { + const lutInputRef = useRef(null); + const [lutOpen, setLutOpen] = useState(false); + const lutAssets = useMemo( + () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)), + [assets], + ); + const lut = grading.lut; + const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null; + + const applyPreset = (presetId: string) => { + const next = normalizeHfColorGrading({ preset: presetId, intensity: 1, lut: grading.lut }); + if (next) onCommitColorGrading(next); + }; + const updateIntensity = (value: number) => { + onCommitColorGrading({ ...grading, intensity: value / 100 }); + }; + const applyLut = (src: string | null, intensity = 1) => { + onCommitColorGrading({ ...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) applyLut(firstLut, 1); + }; + + return ( +
+
+ Preset + +
+ updateIntensity(100)} + /> + +
+ + {lutOpen && ( +
+
+ + {selectedLutName ?? "None"} + + + + { + void importLuts(e.currentTarget.files); + e.currentTarget.value = ""; + }} + /> +
+ {lut && ( + applyLut(lut.src, v / 100)} + onReset={() => applyLut(lut.src, 1)} + /> + )} +
+ )} +
+
+ ); +}