mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(studio): add FlatColorGradingSection Preset and Custom LUT rows
This commit is contained in:
+113
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user