mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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 React, { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { FlatColorGradingAccessory } from "./propertyPanelFlatColorGradingSection";
|
import {
|
||||||
|
FlatColorGradingAccessory,
|
||||||
|
FlatColorGradingSection,
|
||||||
|
} from "./propertyPanelFlatColorGradingSection";
|
||||||
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
||||||
|
|
||||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -71,3 +74,112 @@ describe("FlatColorGradingAccessory", () => {
|
|||||||
act(() => root.unmount());
|
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 { useMemo, useRef, useState } from "react";
|
||||||
import { Compare, RotateCcw } from "../../icons/SystemIcons";
|
import {
|
||||||
import type { ColorGradingControllerState } from "./useColorGradingController";
|
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> = {
|
const STATUS_DOT_CLASS: Record<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
|
||||||
active: "bg-emerald-400",
|
active: "bg-emerald-400",
|
||||||
@@ -66,3 +75,159 @@ export function FlatColorGradingAccessory({
|
|||||||
</span>
|
</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