diff --git a/packages/studio/src/components/editor/colorGradingFrameAnalysis.test.ts b/packages/studio/src/components/editor/colorGradingFrameAnalysis.test.ts index 4da94d8ef..1ea07e57e 100644 --- a/packages/studio/src/components/editor/colorGradingFrameAnalysis.test.ts +++ b/packages/studio/src/components/editor/colorGradingFrameAnalysis.test.ts @@ -65,18 +65,20 @@ describe("colorGradingFrameAnalysis", () => { expect(sampled?.saturation.max).toBeCloseTo(0.2); }); - it("matches the shader's half-softness edge convention", () => { - const key = secondaryKey({ + it("matches the runtime softness on saturation and luma feather edges", () => { + const saturationKey = secondaryKey({ hue: { center: 0, range: 180, softness: 0 }, saturation: { min: 0.5, max: 1, softness: 0.4 }, luma: { min: 0, max: 1, softness: 0 }, }); + const lumaKey = secondaryKey({ + hue: { center: 0, range: 180, softness: 0 }, + saturation: { min: 0, max: 1, softness: 0 }, + luma: { min: 0.5, max: 1, softness: 0.4 }, + }); - const belowOuterEdge = colorGradingSecondaryMask(77, 77, 77, key); - const middleOfFeather = colorGradingSecondaryMask(112, 77, 77, key); - expect(belowOuterEdge).toBe(0); - expect(middleOfFeather).toBeGreaterThan(0); - expect(middleOfFeather).toBeLessThan(1); + expect(colorGradingSecondaryMask(255, 204, 204, saturationKey)).toBeCloseTo(0.15625, 5); + expect(colorGradingSecondaryMask(51, 51, 51, lumaKey)).toBeCloseTo(0.15625, 5); }); it("builds an alpha-preserving black-and-white selection matte", () => { diff --git a/packages/studio/src/components/editor/colorGradingFrameAnalysis.ts b/packages/studio/src/components/editor/colorGradingFrameAnalysis.ts index 68aa4bb52..c429b4325 100644 --- a/packages/studio/src/components/editor/colorGradingFrameAnalysis.ts +++ b/packages/studio/src/components/editor/colorGradingFrameAnalysis.ts @@ -1,4 +1,7 @@ -import type { NormalizedHfColorGradingSecondary } from "@hyperframes/core/color-grading"; +import { + calculateHfColorGradingSecondaryMask, + type NormalizedHfColorGradingSecondary, +} from "@hyperframes/core/color-grading"; import { clampNumber } from "../../utils/studioHelpers"; const BYTE_MAX = 255; @@ -53,20 +56,6 @@ function rgbToHsv(red: number, green: number, blue: number) { }; } -function softRangeMask(value: number, min: number, max: number, softness: number): number { - if (value < min) { - if (softness <= 0 || value <= min - softness) return 0; - const t = (value - (min - softness)) / softness; - return t * t * (3 - 2 * t); - } - if (value > max) { - if (softness <= 0 || value >= max + softness) return 0; - const t = (value - max) / softness; - return 1 - t * t * (3 - 2 * t); - } - return 1; -} - export function colorGradingSecondaryMask( red: number, green: number, @@ -74,32 +63,11 @@ export function colorGradingSecondaryMask( key: NormalizedHfColorGradingSecondary["key"], ): number { const hsv = rgbToHsv(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX); - const hueDistance = Math.abs(((((hsv.hue - key.hue.center + 540) % 360) + 360) % 360) - 180); - const hueMask = - key.hue.range >= 179.999 - ? 1 - : hsv.saturation < 0.001 - ? 0 - : softRangeMask( - hueDistance, - 0, - key.hue.range, - Math.min(key.hue.softness, 180 - key.hue.range), - ); - return ( - hueMask * - softRangeMask( - hsv.saturation, - key.saturation.min, - key.saturation.max, - key.saturation.softness * 0.5, - ) * - softRangeMask( - rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX), - key.luma.min, - key.luma.max, - key.luma.softness * 0.5, - ) + return calculateHfColorGradingSecondaryMask( + hsv.hue, + hsv.saturation, + rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX), + key, ); } diff --git a/packages/studio/src/components/editor/propertyPanelColorCurves.test.tsx b/packages/studio/src/components/editor/propertyPanelColorCurves.test.tsx index b15180bff..bcbdce089 100644 --- a/packages/studio/src/components/editor/propertyPanelColorCurves.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorCurves.test.tsx @@ -131,4 +131,28 @@ describe("ColorCurves", () => { expect(onCommit).toHaveBeenCalledOnce(); act(() => root.unmount()); }); + + it("does not restore a deleted point when an overlapping key gesture settles", () => { + const { host, root, onCommit } = renderCurves(); + const graph = activate(host, "master"); + act(() => { + graph.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(onCommit.mock.calls.at(-1)?.[0]?.curves.master).toHaveLength(3); + onCommit.mockClear(); + + act(() => { + graph.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + }); + act(() => { + graph.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete", bubbles: true })); + }); + act(() => { + graph.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowDown", bubbles: true })); + }); + + expect(onCommit).toHaveBeenCalledOnce(); + expect(onCommit.mock.calls[0]?.[0]?.curves.master).toHaveLength(2); + act(() => root.unmount()); + }); }); diff --git a/packages/studio/src/components/editor/propertyPanelColorCurves.tsx b/packages/studio/src/components/editor/propertyPanelColorCurves.tsx index 2fdc3de18..43aea6388 100644 --- a/packages/studio/src/components/editor/propertyPanelColorCurves.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorCurves.tsx @@ -56,6 +56,7 @@ export function ColorCurves({ const deleteSelected = () => { if (selectedIndex === null) return; if (tab.kind === "rgb" && (selectedIndex === 0 || selectedIndex === points.length - 1)) return; + transaction.cancel(); const nextPoints = tab.kind === "hue" && points.length <= 3 ? [] diff --git a/packages/studio/src/components/editor/propertyPanelColorSecondary.test.tsx b/packages/studio/src/components/editor/propertyPanelColorSecondary.test.tsx index 47de0dd22..c863b9182 100644 --- a/packages/studio/src/components/editor/propertyPanelColorSecondary.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorSecondary.test.tsx @@ -117,6 +117,24 @@ describe("PropertyPanelColorSecondary", () => { act(() => root.unmount()); }); + it("preserves legal fractional hue centers at the wrap boundary", () => { + const grading = normalizeHfColorGrading({ + secondaries: [ + { + key: { hue: { center: 359.7, range: 20 } }, + correction: {}, + }, + ], + }); + if (!grading?.secondaries) throw new Error("Expected normalized secondaries"); + const { host, root } = renderSecondary({ secondaries: grading.secondaries }); + const hue = host.querySelector('[role="slider"][aria-label="Hue"]'); + + expect(Number(hue?.getAttribute("aria-valuenow"))).toBeCloseTo(359.7); + expect(hue?.getAttribute("aria-valuemax")).toBe("359.99"); + act(() => root.unmount()); + }); + it("shows a useful error when frame capture is unavailable", async () => { const { host, root, onCommit } = renderSecondary({ captureFrame: vi.fn().mockRejectedValue(new Error("capture unavailable")), diff --git a/packages/studio/src/components/editor/propertyPanelColorSecondary.tsx b/packages/studio/src/components/editor/propertyPanelColorSecondary.tsx index ce3f6c489..ff027492c 100644 --- a/packages/studio/src/components/editor/propertyPanelColorSecondary.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorSecondary.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import { COLOR_GRADING_MAX_SECONDARIES } from "@hyperframes/parsers/color-grading-contract"; import { + getHfColorGradingCapabilities, normalizeHfColorGrading, type NormalizedHfColorGradingSecondary, } from "@hyperframes/core/color-grading"; @@ -16,6 +16,25 @@ import { type Secondaries = readonly NormalizedHfColorGradingSecondary[]; +const SECONDARY_CAPABILITIES = getHfColorGradingCapabilities().secondaries; +const PERCENT_SCALE = 100; +const RANGE_GAP = 0.01; +const HUE_CENTER_MAX = SECONDARY_CAPABILITIES.hue.center.maxExclusive - RANGE_GAP; + +function wrapHueCenter(value: number): number { + const { min, maxExclusive } = SECONDARY_CAPABILITIES.hue.center; + const span = maxExclusive - min; + return ((((value - min) % span) + span) % span) + min; +} + +const CORRECTION_CONTROLS = [ + ["hueShift", "Hue shift", 1, "°"], + ["saturation", "Saturation", PERCENT_SCALE, "%"], + ["luma", "Luma", PERCENT_SCALE, "%"], + ["temperature", "Warmth", PERCENT_SCALE, "%"], + ["tint", "Tint", PERCENT_SCALE, "%"], +] as const; + function defaultSecondary(): NormalizedHfColorGradingSecondary { const secondary = normalizeHfColorGrading({ secondaries: [{ key: {}, correction: {} }], @@ -84,7 +103,7 @@ export function PropertyPanelColorSecondary({ onCommit(secondaries.map((secondary, index) => (index === activeIndex ? next : secondary))); }; const addSecondary = () => { - if (secondaries.length >= COLOR_GRADING_MAX_SECONDARIES) return; + if (secondaries.length >= SECONDARY_CAPABILITIES.max) return; onCommit([...secondaries, DEFAULT_SECONDARY]); setSelectedIndex(secondaries.length); }; @@ -128,7 +147,7 @@ export function PropertyPanelColorSecondary({ type="button" aria-label="Add secondary color selection" title="Add secondary color selection" - disabled={secondaries.length >= COLOR_GRADING_MAX_SECONDARIES} + disabled={secondaries.length >= SECONDARY_CAPABILITIES.max} onClick={addSecondary} className="text-panel-text-3 hover:text-panel-text-1 disabled:opacity-35" > @@ -261,22 +280,26 @@ export function PropertyPanelColorSecondary({ replaceSelected({ ...selected, - key: { ...selected.key, hue: { ...selected.key.hue, center } }, + key: { + ...selected.key, + hue: { ...selected.key.hue, center: wrapHueCenter(center) }, + }, }) } /> @@ -287,7 +310,10 @@ export function PropertyPanelColorSecondary({ hue: { ...selected.key.hue, range, - softness: Math.min(selected.key.hue.softness, 180 - range), + softness: Math.min( + selected.key.hue.softness, + SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - range, + ), }, }, }) @@ -296,8 +322,11 @@ export function PropertyPanelColorSecondary({ @@ -310,13 +339,14 @@ export function PropertyPanelColorSecondary({ {(["saturation", "luma"] as const).flatMap((key) => { const label = key === "saturation" ? "Saturation" : "Luma"; const range = selected.key[key]; + const capability = SECONDARY_CAPABILITIES[key]; return [ @@ -326,7 +356,10 @@ export function PropertyPanelColorSecondary({ ...selected.key, [key]: { ...range, - min: Math.max(0, Math.min(value / 100, range.max - 0.01)), + min: Math.max( + capability.min.min, + Math.min(value / PERCENT_SCALE, range.max - RANGE_GAP), + ), }, }, }) @@ -335,9 +368,9 @@ export function PropertyPanelColorSecondary({ @@ -347,7 +380,10 @@ export function PropertyPanelColorSecondary({ ...selected.key, [key]: { ...range, - max: Math.min(1, Math.max(value / 100, range.min + 0.01)), + max: Math.min( + capability.max.max, + Math.max(value / PERCENT_SCALE, range.min + RANGE_GAP), + ), }, }, }) @@ -356,15 +392,18 @@ export function PropertyPanelColorSecondary({ replaceSelected({ ...selected, - key: { ...selected.key, [key]: { ...range, softness: value / 100 } }, + key: { + ...selected.key, + [key]: { ...range, softness: value / PERCENT_SCALE }, + }, }) } />, @@ -374,29 +413,19 @@ export function PropertyPanelColorSecondary({
Correction
- {( - [ - ["hueShift", "Hue shift", -180, 180, "degree"], - ["saturation", "Saturation", -100, 100, "percent"], - ["luma", "Luma", -100, 100, "percent"], - ["temperature", "Warmth", -100, 100, "percent"], - ["tint", "Tint", -100, 100, "percent"], - ] as const - ).map(([key, label, min, max, format]) => { - const scale = format === "degree" ? 1 : 100; + {CORRECTION_CONTROLS.map(([key, label, scale, suffix]) => { + const limit = SECONDARY_CAPABILITIES.correction[key]; const value = selected.correction[key] * scale; return ( 0.0001 ? "explicitCustom" : "default"} - displayValue={ - format === "degree" ? `${Math.round(value)}°` : `${Math.round(value)}%` - } + displayValue={`${Math.round(value)}${suffix}`} onCommit={(next) => replaceSelected({ ...selected, diff --git a/packages/studio/src/components/editor/propertyPanelColorWheels.tsx b/packages/studio/src/components/editor/propertyPanelColorWheels.tsx index a741ceb32..9e8bb526d 100644 --- a/packages/studio/src/components/editor/propertyPanelColorWheels.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorWheels.tsx @@ -1,7 +1,8 @@ import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react"; -import type { - HfColorGradingWheelKey, - NormalizedHfColorGradingWheels, +import { + getHfColorGradingCapabilities, + type HfColorGradingWheelKey, + type NormalizedHfColorGradingWheels, } from "@hyperframes/core/color-grading"; import { RotateCcw } from "../../icons/SystemIcons"; import { clampNumber } from "../../utils/studioHelpers"; @@ -15,10 +16,19 @@ const WHEELS: ReadonlyArray<{ key: HfColorGradingWheelKey; label: string }> = [ ]; type NormalizedTonalWheel = NormalizedHfColorGradingWheels[HfColorGradingWheelKey]; -const RESET_WHEEL: NormalizedTonalWheel = { hue: 0, amount: 0, level: 0 }; +const WHEEL_CONTROLS = getHfColorGradingCapabilities().wheels.controls; +const PERCENT_SCALE = 100; +const HUE_MAX = WHEEL_CONTROLS.hue.maxExclusive - 0.01; +const RESET_WHEEL: NormalizedTonalWheel = { + hue: WHEEL_CONTROLS.hue.identity, + amount: WHEEL_CONTROLS.amount.identity, + level: WHEEL_CONTROLS.level.identity, +}; function wrapHue(value: number): number { - return ((value % 360) + 360) % 360; + const { min, maxExclusive } = WHEEL_CONTROLS.hue; + const span = maxExclusive - min; + return ((((value - min) % span) + span) % span) + min; } function formatNumberInput(value: number, decimals: number): string { @@ -39,7 +49,7 @@ function wheelFromPointer( return { ...wheel, hue: wrapHue((Math.atan2(y, x) * 180) / Math.PI), - amount: clampNumber(Math.hypot(x, y), 0, 1), + amount: clampNumber(Math.hypot(x, y), WHEEL_CONTROLS.amount.min, WHEEL_CONTROLS.amount.max), }; } @@ -64,13 +74,27 @@ function wheelFromKey( case "ArrowRight": return { ...wheel, hue: wrapHue(wheel.hue + hueStep) }; case "ArrowDown": - return { ...wheel, amount: clampNumber(wheel.amount - amountStep, 0, 1) }; + return { + ...wheel, + amount: clampNumber( + wheel.amount - amountStep, + WHEEL_CONTROLS.amount.min, + WHEEL_CONTROLS.amount.max, + ), + }; case "ArrowUp": - return { ...wheel, amount: clampNumber(wheel.amount + amountStep, 0, 1) }; + return { + ...wheel, + amount: clampNumber( + wheel.amount + amountStep, + WHEEL_CONTROLS.amount.min, + WHEEL_CONTROLS.amount.max, + ), + }; case "Home": - return { ...wheel, amount: 0 }; + return { ...wheel, amount: WHEEL_CONTROLS.amount.min }; case "End": - return { ...wheel, amount: 1 }; + return { ...wheel, amount: WHEEL_CONTROLS.amount.max }; default: return null; } @@ -136,10 +160,12 @@ function TonalWheel({ role="slider" tabIndex={disabled ? -1 : 0} aria-label={`${label} color`} - aria-valuemin={0} - aria-valuemax={100} - aria-valuenow={Math.round(wheel.amount * 100)} - aria-valuetext={`${Math.round(wheel.hue)} degrees, ${Math.round(wheel.amount * 100)} percent`} + aria-valuemin={WHEEL_CONTROLS.amount.min * PERCENT_SCALE} + aria-valuemax={WHEEL_CONTROLS.amount.max * PERCENT_SCALE} + aria-valuenow={Math.round(wheel.amount * PERCENT_SCALE)} + aria-valuetext={`${Math.round(wheel.hue)} degrees, ${Math.round( + wheel.amount * PERCENT_SCALE, + )} percent`} aria-disabled={disabled} data-color-wheel-surface="true" onDoubleClick={onReset} @@ -193,8 +219,8 @@ function TonalWheel({ onPreview({ ...wheel, hue })} + onPreview={(hue) => onPreview({ ...wheel, hue: wrapHue(hue) })} onSettle={onSettle} onCancel={onCancel} /> onPreview({ ...wheel, amount: amount / 100 })} + onPreview={(amount) => onPreview({ ...wheel, amount: amount / PERCENT_SCALE })} onSettle={onSettle} onCancel={onCancel} /> onPreview({ ...wheel, level: level / 100 })} + onPreview={(level) => onPreview({ ...wheel, level: level / PERCENT_SCALE })} onSettle={onSettle} onCancel={onCancel} /> diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx index 6abcf5720..f60b66e9d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx @@ -159,6 +159,7 @@ export function FlatColorGradingSection({ ], ); const actions = createColorGradingActions(grading, onCommitColorGrading); + const scopesRefreshKey = useMemo(() => serializeHfColorGrading(grading), [grading]); useEffect(() => { if (presetPreviews.status === "idle") onRequestPresetPreviews(); @@ -206,7 +207,7 @@ export function FlatColorGradingSection({ captureGradedFrame()} - refreshKey={serializeHfColorGrading(grading)} + refreshKey={scopesRefreshKey} />
{presetPreviews.status === "unavailable" && ( diff --git a/packages/studio/src/components/editor/useColorGradingController.test.ts b/packages/studio/src/components/editor/useColorGradingController.test.ts index ac68e8c09..e0645e10b 100644 --- a/packages/studio/src/components/editor/useColorGradingController.test.ts +++ b/packages/studio/src/components/editor/useColorGradingController.test.ts @@ -56,22 +56,30 @@ function makeElement(overrides: Partial = {}): DomEditSelectio } as DomEditSelection; } +type ApplyScope = ( + scope: "source-file" | "project", + value: string | null, +) => Promise<{ changedFiles: number; changedElements: number }>; + function HookHost({ onState, onSetAttributeLive, element, previewIframeRef, + onApplyScope, }: { onState: (state: ReturnType) => void; onSetAttributeLive: (attr: string, value: string | null) => void; element: DomEditSelection; previewIframeRef?: React.RefObject; + onApplyScope?: ApplyScope; }) { const state = useColorGradingController({ projectId: "proj", element, previewIframeRef, onSetAttributeLive, + onApplyScope, }); onState(state); return null; @@ -81,6 +89,7 @@ function renderHook( onSetAttributeLive: (attr: string, value: string | null) => void, initialElement: DomEditSelection = makeElement(), previewIframeRef?: React.RefObject, + onApplyScope?: ApplyScope, ) { const host = document.createElement("div"); document.body.append(host); @@ -94,6 +103,7 @@ function renderHook( onSetAttributeLive, element, previewIframeRef, + onApplyScope, }), ); }); @@ -269,6 +279,36 @@ describe("useColorGradingController", () => { vi.useRealTimers(); }); + it("copies a disabled authored secondary to a broader scope", async () => { + const onApplyScope = vi.fn().mockResolvedValue({ + changedFiles: 1, + changedElements: 1, + }); + const grading = { + secondaries: [ + { + enabled: false, + key: { hue: { center: 215, range: 25 } }, + correction: { saturation: 0.15 }, + }, + ], + }; + const { root, getState } = renderHook( + vi.fn(), + makeElement({ dataAttributes: { "color-grading": JSON.stringify(grading) } }), + undefined, + onApplyScope, + ); + + await act(async () => getState().applyToScope()); + + expect(onApplyScope).toHaveBeenCalledWith( + "source-file", + expect.stringContaining('"enabled":false'), + ); + act(() => root.unmount()); + }); + it("requests exact effect families and retains earlier family images", async () => { vi.useFakeTimers(); const { contentWindow, iframe } = createPreviewFrame(); @@ -566,7 +606,7 @@ describe("useColorGradingController", () => { vi.useRealTimers(); }); - it("resetGrading resets Grade fields without clearing Effects or Palette", () => { + it("resetGrading resets Grade fields without clearing LUT, Effects, or Palette", () => { const { root, getState } = renderHook(vi.fn()); const grading = normalizeHfColorGrading({ preset: "bright-pop", @@ -583,7 +623,7 @@ describe("useColorGradingController", () => { }); expect(getState().grading).toMatchObject({ preset: "neutral", - lut: null, + lut: { src: "assets/luts/custom.cube", intensity: 0.6 }, effects: { pixelate: 0.5 }, palette: ["#112233", "#ffffff"], }); diff --git a/packages/studio/src/components/editor/useColorGradingController.ts b/packages/studio/src/components/editor/useColorGradingController.ts index 7c3a6e2e9..d29d11504 100644 --- a/packages/studio/src/components/editor/useColorGradingController.ts +++ b/packages/studio/src/components/editor/useColorGradingController.ts @@ -524,7 +524,9 @@ export function useColorGradingController({ if (!onApplyScope || applyBusy) return; setApplyBusy(true); try { - const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null; + const value = hasHfColorGradingAuthoredValues(grading) + ? serializeHfColorGrading(grading) + : null; await onApplyScope(applyScope, value); } finally { setApplyBusy(false); @@ -552,6 +554,7 @@ export function useColorGradingController({ const neutral = defaultColorGrading(); commitColorGrading({ ...neutral, + lut: latestGradingRef.current.lut, effects: latestGradingRef.current.effects, palette: latestGradingRef.current.palette, });