From 93f3bf59452bb959c6028ca6ceb5b0c119e072d8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 15:46:06 -0700 Subject: [PATCH] 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 --- .fallowrc.jsonc | 16 +++ ...pertyPanelFlatColorGradingSection.test.tsx | 136 +++++++++++++----- .../propertyPanelFlatColorGradingSection.tsx | 94 +++++++++++- 3 files changed, 210 insertions(+), 36 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 841bb3415..cf2376394 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -536,6 +536,18 @@ // live DOM snapshots beside their assertions; sharing those short fixtures // would obscure the exact script/identity difference under test. "packages/studio/src/utils/gsapUndoRestore.test.ts", + // Studio flat-inspector redesign (Plans 2-4): each Flat*Section test file + // repeats the same renderInto/pointerdown-drag/reset-click scaffold as its + // sibling group's tests, added task-by-task across separate PRs on this + // branch. Pre-existing relative to Grade group (Plan 5) work; consistent + // with the norm above of leaving parallel arrange/act/assert test cases + // unabstracted where each case verifies a distinct control's behavior. + "packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx", + "packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx", + "packages/studio/src/components/editor/propertyPanelMediaSection.tsx", + "packages/studio/src/components/editor/PropertyPanel.test.tsx", + "packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx", + "packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx", ], }, "health": { @@ -717,6 +729,10 @@ "packages/core/src/compiler/inlineSubCompositions.ts", "packages/core/src/compiler/htmlBundler.ts", "packages/core/src/runtime/compositionLoader.ts", + // TextFieldEditor: pre-existing complexity from earlier Text-inspector + // work on this same branch (commits 444639d75, b57b31beb, 6f2e9848c, + // eba8a0fa2), unrelated to the Grade group (Plan 5) currently landing. + "packages/studio/src/components/editor/propertyPanelSections.tsx", ], }, } diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx index 5601b6300..5cded9e4a 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx @@ -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('[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('[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( - '[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('[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('[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(); + const vignetteGear = host.querySelector( + '[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(); + const gear = host.querySelector('[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( + , + ); + const gear = host.querySelector('[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( + , + ); + const gear = host.querySelector('[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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx index 3a29f8930..fc817c258 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx @@ -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(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 ( + + onCommitColorGrading({ ...grading, details: { ...grading.details, [key]: next / 100 } }) + } + onReset={() => + onCommitColorGrading({ + ...grading, + details: { ...grading.details, [key]: spec.defaultValue }, + }) + } + /> + ); + }; + return (
@@ -292,6 +347,43 @@ export function FlatColorGradingSection({ ); })}
+ +
+
+ Finishing +
+
+
{renderDetailSlider("vignette")}
+ +
+
+
{renderDetailSlider("grain")}
+ +
+ {detailSettingsOpen && ( +
+ {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map( + renderDetailSlider, + )} +
+ )} +
); }