mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add Vignette and Grain rows with tuned-settings sub-panel
Also hoists the pointerdown-drag/reset-click test scaffold shared by the new Roundness tests and the existing Contrast/Exposure tests into helpers (findRowByText/dragSliderTrack/clickSliderReset), and exempts pre-existing, branch-inherited fallow findings unrelated to this task (TextFieldEditor complexity from earlier Text-inspector commits; test-scaffold duplication across four flat-inspector-series test files from Plans 2-4) via .fallowrc.jsonc, per this repo's established convention for line-shift/ inherited findings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
82cb7b8979
commit
93f3bf5945
+101
-35
@@ -31,6 +31,36 @@ function neutralGrading() {
|
||||
return grading;
|
||||
}
|
||||
|
||||
function findRowByText(
|
||||
host: HTMLElement,
|
||||
selector: string,
|
||||
text: string,
|
||||
match: "includes" | "startsWith" = "includes",
|
||||
) {
|
||||
const row = Array.from(host.querySelectorAll(selector)).find((el) =>
|
||||
el.textContent?.[match](text),
|
||||
);
|
||||
if (!row) throw new Error(`expected a ${text} row`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function dragSliderTrack(row: Element, clientX: number, trackWidth: number) {
|
||||
const track = row.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
|
||||
if (!track) throw new Error("expected a slider track");
|
||||
Object.defineProperty(track, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: trackWidth, top: 0, height: 2, right: trackWidth, bottom: 2 }),
|
||||
});
|
||||
act(() => {
|
||||
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX }));
|
||||
});
|
||||
}
|
||||
|
||||
function clickSliderReset(row: Element) {
|
||||
const resetButton = row.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
|
||||
expect(resetButton).not.toBeNull();
|
||||
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
}
|
||||
|
||||
describe("FlatColorGradingAccessory", () => {
|
||||
it("shows a 5px status dot colored by runtime status, with the message as its title", () => {
|
||||
const { host, root } = renderInto(
|
||||
@@ -206,15 +236,8 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
|
||||
onCommitColorGrading={onCommitColorGrading}
|
||||
/>,
|
||||
);
|
||||
const contrastRow = Array.from(host.querySelectorAll('[data-flat-grade-adjust="true"]')).find(
|
||||
(row) => row.textContent?.includes("Contrast"),
|
||||
);
|
||||
if (!contrastRow) throw new Error("expected a Contrast row");
|
||||
const resetButton = contrastRow.querySelector<HTMLButtonElement>(
|
||||
'[data-flat-slider-reset="true"]',
|
||||
);
|
||||
expect(resetButton).not.toBeNull();
|
||||
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast");
|
||||
clickSliderReset(contrastRow);
|
||||
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0);
|
||||
act(() => root.unmount());
|
||||
@@ -228,19 +251,9 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
|
||||
onCommitColorGrading={onCommitColorGrading}
|
||||
/>,
|
||||
);
|
||||
const contrastRow = Array.from(host.querySelectorAll('[data-flat-grade-adjust="true"]')).find(
|
||||
(row) => row.textContent?.includes("Contrast"),
|
||||
);
|
||||
if (!contrastRow) throw new Error("expected a Contrast row");
|
||||
const track = contrastRow.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
|
||||
if (!track) throw new Error("expected a slider track");
|
||||
Object.defineProperty(track, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
||||
});
|
||||
act(() => {
|
||||
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 50/100 = 0.5
|
||||
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 75 }));
|
||||
});
|
||||
const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast");
|
||||
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 50/100 = 0.5
|
||||
dragSliderTrack(contrastRow, 75, 100);
|
||||
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0.5);
|
||||
act(() => root.unmount());
|
||||
@@ -259,22 +272,75 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
|
||||
onCommitColorGrading={onCommitColorGrading}
|
||||
/>,
|
||||
);
|
||||
const exposureRow = Array.from(host.querySelectorAll('[data-flat-grade-adjust="true"]')).find(
|
||||
(row) => row.textContent?.includes("Exposure"),
|
||||
);
|
||||
if (!exposureRow) throw new Error("expected an Exposure row");
|
||||
const track = exposureRow.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
|
||||
if (!track) throw new Error("expected a slider track");
|
||||
Object.defineProperty(track, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 200, top: 0, height: 2, right: 200, bottom: 2 }),
|
||||
});
|
||||
act(() => {
|
||||
// min=-200, max=200, step=5, ratio=1.0 -> raw=200 -> commit(200) -> adjust.exposure = 200/100 = 2
|
||||
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 200 }));
|
||||
});
|
||||
const exposureRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Exposure");
|
||||
// min=-200, max=200, step=5, ratio=1.0 -> raw=200 -> commit(200) -> adjust.exposure = 200/100 = 2
|
||||
dragSliderTrack(exposureRow, 200, 200);
|
||||
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||
expect(onCommitColorGrading.mock.calls[0][0].adjust.exposure).toBe(2);
|
||||
expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatColorGradingSection — Vignette and Grain", () => {
|
||||
it("renders Vignette and Grain amount rows with a settings gear, expanding tuned sliders on click", () => {
|
||||
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
||||
const vignetteGear = host.querySelector<HTMLButtonElement>(
|
||||
'[data-flat-grade-settings="vignette"]',
|
||||
);
|
||||
expect(vignetteGear).not.toBeNull();
|
||||
act(() => vignetteGear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(host.textContent).toContain("Midpoint");
|
||||
expect(host.textContent).toContain("Feather");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows tuned Midpoint at its 50% default with no reset until moved from default", () => {
|
||||
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
||||
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
|
||||
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const midpointRow = findRowByText(host, "div", "Midpoint", "startsWith");
|
||||
expect(midpointRow.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a dragged Roundness value on slider track pointerdown, scaled from percent back into the -1..1 detail range", () => {
|
||||
const onCommitColorGrading = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<FlatColorGradingSection
|
||||
{...neutralPropsBase()}
|
||||
onCommitColorGrading={onCommitColorGrading}
|
||||
/>,
|
||||
);
|
||||
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
|
||||
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith");
|
||||
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> details.vignetteRoundness = 50/100 = 0.5
|
||||
dragSliderTrack(roundnessRow, 75, 100);
|
||||
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||
expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0.5);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("resets a non-default Roundness back to its 0 default via the tuned slider's reset button", () => {
|
||||
const onCommitColorGrading = vi.fn();
|
||||
const grading = {
|
||||
...neutralGrading(),
|
||||
details: { ...neutralGrading().details, vignetteRoundness: 0.4 },
|
||||
};
|
||||
const { host, root } = renderInto(
|
||||
<FlatColorGradingSection
|
||||
{...neutralPropsBase()}
|
||||
grading={grading}
|
||||
onCommitColorGrading={onCommitColorGrading}
|
||||
/>,
|
||||
);
|
||||
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
|
||||
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith");
|
||||
clickSliderReset(roundnessRow);
|
||||
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||
expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
isHfColorGradingActive,
|
||||
normalizeHfColorGrading,
|
||||
type HfColorGradingAdjustKey,
|
||||
type HfColorGradingDetailKey,
|
||||
type NormalizedHfColorGrading,
|
||||
} from "@hyperframes/core/color-grading";
|
||||
import { Compare, Plus, RotateCcw } from "../../icons/SystemIcons";
|
||||
import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
|
||||
import { LUT_EXT } from "../../utils/mediaTypes";
|
||||
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
|
||||
import { resolveValueTier } from "./propertyPanelValueTier";
|
||||
@@ -106,6 +107,32 @@ 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);
|
||||
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"];
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function FlatColorGradingSection({
|
||||
grading,
|
||||
assets,
|
||||
@@ -131,6 +158,7 @@ export function FlatColorGradingSection({
|
||||
}) {
|
||||
const lutInputRef = useRef<HTMLInputElement>(null);
|
||||
const [lutOpen, setLutOpen] = useState(false);
|
||||
const [detailSettingsOpen, setDetailSettingsOpen] = useState<"vignette" | "grain" | null>(null);
|
||||
const lutAssets = useMemo(
|
||||
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
|
||||
[assets],
|
||||
@@ -155,6 +183,33 @@ export function FlatColorGradingSection({
|
||||
if (firstLut) 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;
|
||||
return (
|
||||
<FlatSlider
|
||||
key={key}
|
||||
label={spec.label}
|
||||
value={Math.round(value * 100)}
|
||||
min={key === "vignetteRoundness" ? -100 : 0}
|
||||
max={100}
|
||||
tier={isSet ? "explicitCustom" : "default"}
|
||||
displayValue={`${Math.round(value * 100)}%`}
|
||||
centerTick={key === "vignetteRoundness"}
|
||||
onCommit={(next) =>
|
||||
onCommitColorGrading({ ...grading, details: { ...grading.details, [key]: next / 100 } })
|
||||
}
|
||||
onReset={() =>
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
details: { ...grading.details, [key]: spec.defaultValue },
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div data-flat-grade-preset="true" className="flex min-h-[30px] items-center justify-between">
|
||||
@@ -292,6 +347,43 @@ export function FlatColorGradingSection({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
|
||||
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||
Finishing
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1">{renderDetailSlider("vignette")}</div>
|
||||
<button
|
||||
type="button"
|
||||
data-flat-grade-settings="vignette"
|
||||
title="Vignette settings"
|
||||
onClick={() => setDetailSettingsOpen((c) => (c === "vignette" ? null : "vignette"))}
|
||||
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
|
||||
>
|
||||
<Settings size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1">{renderDetailSlider("grain")}</div>
|
||||
<button
|
||||
type="button"
|
||||
data-flat-grade-settings="grain"
|
||||
title="Grain settings"
|
||||
onClick={() => setDetailSettingsOpen((c) => (c === "grain" ? null : "grain"))}
|
||||
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
|
||||
>
|
||||
<Settings size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{detailSettingsOpen && (
|
||||
<div className="space-y-0.5 border-l-2 border-panel-border-input pl-2.5">
|
||||
{(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map(
|
||||
renderDetailSlider,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user