feat(studio): add FlatColorGradingSection Preset and Custom LUT rows

This commit is contained in:
Vance Ingalls
2026-07-14 15:50:51 -07:00
parent 160193d833
commit 9b6bed2824
2 changed files with 281 additions and 4 deletions
@@ -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(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const presetSelect = host.querySelector<HTMLSelectElement>(
'[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(
<FlatColorGradingSection {...neutralPropsBase()} grading={grading} />,
);
const lutToggle = host.querySelector<HTMLButtonElement>('[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(
<FlatColorGradingSection
{...neutralPropsBase()}
assets={["assets/luts/warm.cube", "assets/luts/cool.cube"]}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const lutToggle = host.querySelector<HTMLButtonElement>('[data-flat-grade-lut-toggle="true"]');
act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const lutSelect = host.querySelector<HTMLSelectElement>('[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(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
onImportAssets={onImportAssets}
/>,
);
const lutToggle = host.querySelector<HTMLButtonElement>('[data-flat-grade-lut-toggle="true"]');
act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const fileInput = host.querySelector<HTMLInputElement>('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());
});
});
@@ -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<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
active: "bg-emerald-400",
@@ -66,3 +75,159 @@ export function FlatColorGradingAccessory({
</span>
);
}
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<string[]>;
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<HTMLInputElement>(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 (
<div className="space-y-1.5">
<div data-flat-grade-preset="true" className="flex min-h-[30px] items-center justify-between">
<span className="text-[11px] text-panel-text-2">Preset</span>
<FlatSelectRow
label=""
value={grading.preset ?? "neutral"}
options={PRESET_OPTIONS}
tier={resolveValueTier(
grading.preset === "neutral" ? undefined : (grading.preset ?? undefined),
"neutral",
)}
onChange={applyPreset}
/>
</div>
<FlatSlider
label="Strength"
value={Math.round(grading.intensity * 100)}
min={0}
max={100}
tier={grading.intensity === 1 ? "default" : "explicitCustom"}
displayValue={`${Math.round(grading.intensity * 100)}%`}
onCommit={updateIntensity}
onReset={() => updateIntensity(100)}
/>
<div className="border-t border-panel-hairline pt-1.5">
<button
type="button"
data-flat-grade-lut-toggle="true"
onClick={() => setLutOpen((v) => !v)}
className="flex min-h-[30px] w-full items-center justify-between text-left"
>
<span className="text-[11px] text-panel-text-2">Custom LUT</span>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className={`flex-shrink-0 text-panel-text-5 transition-transform ${lutOpen ? "rotate-90" : ""}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
</button>
{lutOpen && (
<div className="space-y-1.5 pb-1">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-3">
{selectedLutName ?? "None"}
</span>
<select
data-flat-grade-lut-select="true"
value={lut?.src ?? ""}
onChange={(e) => {
const src = e.target.value;
applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
}}
className="bg-transparent font-mono text-[10px] text-panel-text-3 outline-none"
>
<option value="">None</option>
{lutAssets.map((asset) => (
<option key={asset} value={asset}>
{asset.split("/").pop() ?? asset}
</option>
))}
</select>
<button
type="button"
disabled={!onImportAssets}
onClick={() => lutInputRef.current?.click()}
title="Import .cube LUT"
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus size={12} />
</button>
<input
ref={lutInputRef}
type="file"
accept=".cube"
className="hidden"
onChange={(e) => {
void importLuts(e.currentTarget.files);
e.currentTarget.value = "";
}}
/>
</div>
{lut && (
<FlatSlider
label="LUT strength"
value={Math.round((lut.intensity ?? 1) * 100)}
min={0}
max={100}
tier={lut.intensity === 1 ? "default" : "explicitCustom"}
displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
onCommit={(v) => applyLut(lut.src, v / 100)}
onReset={() => applyLut(lut.src, 1)}
/>
)}
</div>
)}
</div>
</div>
);
}