diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 1447133942..f497696dba 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -437,6 +437,18 @@ // scopeRootSelectors handling shifts lines and re-flags both. "packages/core/src/compiler/inlineSubCompositions.ts", "packages/core/src/compiler/compositionScoping.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": { @@ -616,6 +628,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/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index a3d854c7b4..12ee793688 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -668,6 +668,26 @@ function flatGroupTitles(host: HTMLElement): string[] { return [...open, ...collapsed]; } +describe("PropertyPanel — Grade group (flag on)", () => { + it( + "renders the Grade group with its accessory for a grade-editable (video) element", + async () => { + const { host, root } = await renderPanel(true, { + ...baseElement(), + tagName: "video", + textFields: [], + }); + const gradeCollapsedOrOpen = + host.querySelector('[data-flat-group-collapsed="true"]') || + host.querySelector('[data-flat-group-open="true"]'); + expect(host.textContent).toContain("Grade"); + expect(gradeCollapsedOrOpen).not.toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); + describe("PropertyPanel — Media group (Plan 4)", () => { it( "renders the flat Media group and not the legacy MediaSection, for a video element", diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index cdb1c9b2a1..5656f33293 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -17,6 +17,11 @@ import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections"; import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; import { ColorGradingSection } from "./propertyPanelColorGradingSection"; +import { useColorGradingController } from "./useColorGradingController"; +import { + FlatColorGradingAccessory, + FlatColorGradingSection, +} from "./propertyPanelFlatColorGradingSection"; type EditingSections = ReturnType; @@ -225,6 +230,20 @@ export function PropertyPanelFlat({ ); const [pinnedGroupIds, setPinnedGroupIds] = useState([]); + // Grade group state. Called unconditionally (React rules-of-hooks) even when + // sections.colorGrading is false — unlike the legacy ColorGradingSection, + // which is only mounted when the section is active, PropertyPanelFlat is not + // remounted per-section so the hook must run every render. Shares one state + // object between the group's header accessory (compare/status/reset) and its + // body (the FlatColorGradingSection controls). + const colorGradingController = useColorGradingController({ + projectId, + element, + previewIframeRef, + onSetAttributeLive, + onApplyScope: onApplyColorGradingScope, + }); + const isTextEditable = isTextEditableSelection(element); const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; const toggleOpen = (groupId: string) => @@ -415,6 +434,30 @@ export function PropertyPanelFlat({ /> )} + {sections.colorGrading && ( + toggleOpen("grade")} + onTogglePin={() => togglePin("grade")} + accessory={} + summary={`${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`} + > + void colorGradingController.applyToScope()} + onApplyScopeAvailable={Boolean(onApplyColorGradingScope)} + mediaMetadata={colorGradingController.mediaMetadata} + /> + + )} {sections.colorGrading && ( (); - -interface RuntimeColorGradingStatus { - state: "missing" | "inactive" | "pending" | "active" | "unavailable"; - message: string; -} - -interface MediaMetadata { - kind: "video" | "image" | "audio" | "unknown"; - color: { - dynamicRange: "hdr" | "sdr" | "unknown"; - hdrTransfer: "pq" | "hlg" | "unknown" | null; - label: string; - isHdr: boolean; - codecName?: string; - profile?: string; - pixelFormat?: string; - colorSpace?: string; - colorTransfer?: string; - colorPrimaries?: string; - }; - probeError?: string; -} - -interface MediaMetadataResponse { - path: string; - metadata: MediaMetadata; -} - -function stripPreviewAssetPath(src: string, projectId: string): string | null { - let pathname = src; - try { - pathname = new URL(src, window.location.href).pathname; - } catch { - return null; - } - const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`; - const genericMarker = "/preview/"; - const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker; - const index = pathname.indexOf(marker); - if (index < 0) return null; - const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, ""); - if (!assetPath || assetPath.startsWith("comp/")) return null; - return assetPath; -} - -// fallow-ignore-next-line complexity -function resolveProjectAssetPath( - sourceFile: string, - src: string, - projectId: string, -): string | null { - const trimmed = stripQueryAndHash(src.trim()); - if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null; - if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId); - if (trimmed.startsWith("/")) { - return stripPreviewAssetPath(trimmed, projectId); - } - - const sourceDir = sourceFile.includes("/") - ? sourceFile.slice(0, sourceFile.lastIndexOf("/")) - : ""; - const parts = `${sourceDir}/${trimmed}`.split("/"); - const normalized: string[] = []; - for (const part of parts) { - if (!part || part === ".") continue; - if (part === "..") { - normalized.pop(); - continue; - } - normalized.push(part); - } - return normalized.join("/") || null; -} - -function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null { - if (element.tagName !== "video" && element.tagName !== "img") return null; - const media = element.element as HTMLImageElement | HTMLVideoElement; - const src = media.getAttribute("src") || media.currentSrc || ""; - return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId); -} - -function defaultColorGrading(): NormalizedHfColorGrading { - const grading = normalizeHfColorGrading("neutral"); - if (!grading) throw new Error("Missing neutral color grading preset"); - return grading; -} - -function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading { - return ( - normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading() - ); -} - -function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown { - if (!isHfColorGradingActive(grading)) return null; - const { enabled: _enabled, ...bridgeGrading } = grading; - return bridgeGrading; -} - -function readRuntimeColorGradingStatus( - iframe: HTMLIFrameElement | null | undefined, - target: HfColorGradingTarget, -): RuntimeColorGradingStatus { - try { - const win = iframe?.contentWindow as - | (Window & { - __hf?: { - colorGrading?: { - getStatus?: ( - target: HfColorGradingTarget | string | null | undefined, - ) => RuntimeColorGradingStatus; - }; - }; - }) - | null - | undefined; - const status = win?.__hf?.colorGrading?.getStatus?.(target); - return status ?? { state: "pending", message: "Waiting for runtime" }; - } catch { - return { state: "unavailable", message: "Preview unavailable" }; - } -} +import { + useColorGradingController, + type MediaMetadata, + type RuntimeColorGradingStatus, +} from "./useColorGradingController"; function StatusPill({ status }: { status: RuntimeColorGradingStatus }) { const dotClass = @@ -285,226 +144,25 @@ export function ColorGradingSection({ value: string | null, ) => Promise<{ changedFiles: number; changedElements: number }>; }) { - const [grading, setGrading] = useState(() => readColorGradingFromElement(element)); - const [compareEnabled, setCompareEnabled] = useState(false); - const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file"); - const [applyBusy, setApplyBusy] = useState(false); - const [runtimeStatus, setRuntimeStatus] = useState(() => ({ - state: "pending", - message: "Waiting for runtime", - })); - const selectedAssetPath = useMemo( - () => selectedMediaAssetPath(element, projectId), - [element, projectId], - ); - const [mediaMetadata, setMediaMetadata] = useState(null); - const persistTimerRef = useRef | null>(null); - const pendingPersistValueRef = useRef(undefined); - const statusTimersRef = useRef([]); - const onSetAttributeLiveRef = useRef(onSetAttributeLive); - const latestGradingRef = useRef(grading); - const compareEnabledRef = useRef(compareEnabled); - onSetAttributeLiveRef.current = onSetAttributeLive; - latestGradingRef.current = grading; - compareEnabledRef.current = compareEnabled; - const target = useMemo( - (): HfColorGradingTarget => ({ - id: element.id ?? null, - hfId: element.hfId ?? null, - selector: element.selector ?? null, - selectorIndex: element.selectorIndex ?? null, - }), - [element.hfId, element.id, element.selector, element.selectorIndex], - ); - - const refreshRuntimeStatus = useCallback(() => { - setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target)); - }, [previewIframeRef, target]); - - useEffect(() => { - setMediaMetadata(null); - if (!selectedAssetPath) return; - const cacheKey = `${projectId}:${selectedAssetPath}`; - if (MEDIA_METADATA_CACHE.has(cacheKey)) { - setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null); - return; - } - const controller = new AbortController(); - fetch( - `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent( - selectedAssetPath, - )}`, - { signal: controller.signal }, - ) - .then((response) => (response.ok ? response.json() : null)) - .then((data: MediaMetadataResponse | null) => { - if (controller.signal.aborted) return; - const metadata = data?.metadata ?? null; - MEDIA_METADATA_CACHE.set(cacheKey, metadata); - setMediaMetadata(metadata); - }) - .catch(() => { - if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null); - }); - return () => controller.abort(); - }, [projectId, selectedAssetPath]); - - const clearStatusTimers = useCallback(() => { - for (const timer of statusTimersRef.current) clearTimeout(timer); - statusTimersRef.current = []; - }, []); - - const scheduleRuntimeStatusRefresh = useCallback(() => { - clearStatusTimers(); - statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) => - window.setTimeout(refreshRuntimeStatus, delay), - ); - }, [clearStatusTimers, refreshRuntimeStatus]); - - useEffect(() => { - refreshRuntimeStatus(); - }, [refreshRuntimeStatus]); - - const persistColorGradingValue = useCallback((value: string | null) => { - return trackStudioPendingEdit( - onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null), - ); - }, []); - - const flushPendingPersist = useCallback(() => { - if (persistTimerRef.current) { - clearTimeout(persistTimerRef.current); - persistTimerRef.current = null; - } - if (pendingPersistValueRef.current === undefined) return undefined; - const value = pendingPersistValueRef.current; - pendingPersistValueRef.current = undefined; - return persistColorGradingValue(value); - }, [persistColorGradingValue]); - - useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]); - - useEffect(() => { - return () => { - clearStatusTimers(); - void flushPendingPersist(); - }; - }, [clearStatusTimers, flushPendingPersist]); - - const postColorGrading = useCallback( - (nextGrading: NormalizedHfColorGrading) => { - previewIframeRef?.current?.contentWindow?.postMessage( - { - source: "hf-parent", - type: "control", - action: "set-color-grading", - target, - grading: toBridgeColorGrading(nextGrading), - }, - "*", - ); - }, - [previewIframeRef, target], - ); - - const postCompare = useCallback( - (enabled: boolean) => { - previewIframeRef?.current?.contentWindow?.postMessage( - { - source: "hf-parent", - type: "control", - action: "set-color-grading-compare", - target, - compare: { - enabled, - position: 1, - lineWidth: 0, - }, - }, - "*", - ); - }, - [previewIframeRef, target], - ); - - useEffect(() => { - const iframe = previewIframeRef?.current; - if (!iframe) return; - const refreshAndReplay = () => { - const nextGrading = latestGradingRef.current; - const active = isHfColorGradingActive(nextGrading); - if (active) postColorGrading(nextGrading); - postCompare(compareEnabledRef.current && active); - scheduleRuntimeStatusRefresh(); - }; - const onMessage = (event: MessageEvent) => { - if (event.source !== iframe.contentWindow) return; - const data = event.data as { source?: unknown; type?: unknown } | null; - if (data?.source === "hf-preview" && data.type === "ready") refreshAndReplay(); - }; - iframe.addEventListener("load", refreshAndReplay); - window.addEventListener("message", onMessage); - const timer = window.setTimeout(refreshAndReplay, 80); - return () => { - iframe.removeEventListener("load", refreshAndReplay); - window.removeEventListener("message", onMessage); - window.clearTimeout(timer); - }; - }, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]); - - useEffect( - () => () => { - postCompare(false); - }, - [postCompare], - ); - - const commitColorGrading = useCallback( - (nextGrading: NormalizedHfColorGrading) => { - setGrading(nextGrading); - setRuntimeStatus({ state: "pending", message: "Updating shader" }); - postColorGrading(nextGrading); - const active = isHfColorGradingActive(nextGrading); - if (compareEnabledRef.current) { - postCompare(active); - if (!active) setCompareEnabled(false); - } - scheduleRuntimeStatusRefresh(); - if (persistTimerRef.current) clearTimeout(persistTimerRef.current); - pendingPersistValueRef.current = isHfColorGradingActive(nextGrading) - ? serializeHfColorGrading(nextGrading) - : null; - persistTimerRef.current = setTimeout(() => { - const value = pendingPersistValueRef.current; - pendingPersistValueRef.current = undefined; - persistTimerRef.current = null; - void persistColorGradingValue(value ?? null); - }, 350); - }, - [persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], - ); - - const commitCompare = useCallback( - (enabled: boolean) => { - const nextEnabled = enabled && isHfColorGradingActive(grading); - setCompareEnabled(nextEnabled); - if (nextEnabled) postColorGrading(grading); - postCompare(nextEnabled); - scheduleRuntimeStatusRefresh(); - }, - [grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], - ); - - const applyToScope = useCallback(async () => { - if (!onApplyScope || applyBusy) return; - setApplyBusy(true); - try { - const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null; - await onApplyScope(applyScope, value); - } finally { - setApplyBusy(false); - } - }, [applyBusy, applyScope, grading, onApplyScope]); + const { + grading, + compareEnabled, + applyScope, + applyBusy, + runtimeStatus, + mediaMetadata, + commitColorGrading, + commitCompare, + setApplyScope, + applyToScope, + resetGrading, + } = useColorGradingController({ + projectId, + element, + previewIframeRef, + onSetAttributeLive, + onApplyScope, + }); return (
{ event.stopPropagation(); - commitColorGrading(defaultColorGrading()); + resetGrading(); }} className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1" title="Reset color grading" diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx new file mode 100644 index 0000000000..a1ee58b829 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx @@ -0,0 +1,428 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +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; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +function neutralGrading() { + const grading = normalizeHfColorGrading("neutral"); + if (!grading) throw new Error("expected a neutral grading"); + 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( + , + ); + const dot = host.querySelector('[data-flat-grade-status-dot="true"]'); + expect(dot).not.toBeNull(); + expect(dot?.getAttribute("title")).toBe("Shader active"); + expect(dot?.className).toContain("bg-emerald-400"); + act(() => root.unmount()); + }); + + it("disables the compare hold button when grading is inactive, and fires resetGrading on click", () => { + const resetGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + expect(compareButton?.disabled).toBe(true); + const resetButton = host.querySelector('[data-flat-grade-reset="true"]'); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(resetGrading).toHaveBeenCalledTimes(1); + 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( + , + ); + const presetSelect = host.querySelector( + '[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( + , + ); + const lutToggle = host.querySelector('[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( + , + ); + const lutToggle = host.querySelector('[data-flat-grade-lut-toggle="true"]'); + act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const lutSelect = host.querySelector('[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( + , + ); + const lutToggle = host.querySelector('[data-flat-grade-lut-toggle="true"]'); + act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const fileInput = host.querySelector('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()); + }); +}); + +describe("FlatColorGradingSection — Adjust sliders", () => { + it("renders all 10 adjust rows with a center tick, formatting exposure distinctly from percentage sliders", () => { + const { host, root } = renderInto(); + const adjustRows = host.querySelectorAll('[data-flat-grade-adjust="true"]'); + expect(adjustRows).toHaveLength(10); + for (const row of Array.from(adjustRows)) { + expect(row.querySelector('[data-flat-slider-center-tick="true"]')).not.toBeNull(); + } + expect(host.textContent).toContain("+0.00"); + act(() => root.unmount()); + }); + + it("commits an adjust change scaled correctly and shows a reset when non-neutral", () => { + const onCommitColorGrading = vi.fn(); + const grading = { ...neutralGrading(), adjust: { ...neutralGrading().adjust, contrast: 0.12 } }; + const { host, root } = renderInto( + , + ); + 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()); + }); + + it("commits a dragged contrast value on slider track pointerdown, scaled from percent back to the internal -1..1 range", () => { + const onCommitColorGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + 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()); + }); + + it("commits a dragged exposure value scaled into stops, keeping other adjust keys untouched", () => { + const onCommitColorGrading = vi.fn(); + const grading = { + ...neutralGrading(), + adjust: { ...neutralGrading().adjust, saturation: 0.2 }, + }; + const { host, root } = renderInto( + , + ); + 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()); + }); +}); + +describe("FlatColorGradingSection — Effects", () => { + it("renders Blur and Pixelate sliders under an Effects micro-label", () => { + const { host, root } = renderInto(); + expect(host.textContent).toContain("Effects"); + const rows = host.querySelectorAll('[data-flat-grade-effect="true"]'); + expect(rows).toHaveLength(2); + act(() => root.unmount()); + }); + + it("commits a dragged Pixelate value on slider track pointerdown, scaled from percent to the 0..1 effect range", () => { + const onCommitColorGrading = vi.fn(); + const { host, root } = renderInto( + , + ); + const pixelateRow = findRowByText(host, '[data-flat-grade-effect="true"]', "Pixelate"); + // min=0, max=100, step=1, ratio=0.75 -> raw=75 -> commit(75) -> effects.pixelate = 75/100 = 0.75 + dragSliderTrack(pixelateRow, 75, 100); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].effects.pixelate).toBe(0.75); + act(() => root.unmount()); + }); +}); + +describe("FlatColorGradingSection — HDR banner and Apply scope", () => { + it("shows the HDR banner only when mediaMetadata reports an HDR source", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("SDR preview"); + act(() => root.unmount()); + }); + + it("omits the HDR banner for SDR media", () => { + const { host, root } = renderInto( + , + ); + expect(host.textContent).not.toContain("SDR preview"); + act(() => root.unmount()); + }); + + it("fires onApplyToScope from the Apply button, respecting applyBusy", () => { + const onApplyToScope = vi.fn(); + const { host, root } = renderInto( + , + ); + const applyButton = host.querySelector('[data-flat-grade-apply="true"]'); + expect(applyButton?.disabled).toBe(true); + act(() => root.unmount()); + }); + + it("fires onApplyToScope exactly once when the Apply button is clicked while not busy", () => { + const onApplyToScope = vi.fn(); + const { host, root } = renderInto( + , + ); + const applyButton = host.querySelector('[data-flat-grade-apply="true"]'); + expect(applyButton?.disabled).toBe(false); + act(() => applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onApplyToScope).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx new file mode 100644 index 0000000000..be8e21fcaf --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx @@ -0,0 +1,477 @@ +import { useMemo, useRef, useState } from "react"; +import { + HF_COLOR_GRADING_PRESETS, + isHfColorGradingActive, + normalizeHfColorGrading, + type HfColorGradingAdjustKey, + type HfColorGradingDetailKey, + type HfColorGradingEffectKey, + type NormalizedHfColorGrading, +} from "@hyperframes/core/color-grading"; +import { Compare, Plus, RotateCcw, Settings } 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 = { + active: "bg-emerald-400", + pending: "bg-amber-300", + unavailable: "bg-red-400", + missing: "bg-panel-text-5", + inactive: "bg-panel-text-5", +}; + +export function FlatColorGradingAccessory({ + state, +}: { + state: Pick< + ColorGradingControllerState, + "grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading" + >; +}) { + const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state; + const gradingActive = isHfColorGradingActive(grading); + + return ( + + + + + + ); +} + +const PRESET_OPTIONS = HF_COLOR_GRADING_PRESETS.map((p) => ({ value: p.id, label: p.label })); + +const ADJUST_SLIDERS: Array<{ + key: HfColorGradingAdjustKey; + label: string; + min: number; + max: number; + step: number; +}> = [ + { key: "exposure", label: "Exposure", min: -200, max: 200, step: 5 }, + { key: "contrast", label: "Contrast", min: -100, max: 100, step: 1 }, + { key: "highlights", label: "Highlights", min: -100, max: 100, step: 1 }, + { key: "shadows", label: "Shadows", min: -100, max: 100, step: 1 }, + { key: "whites", label: "White Point", min: -100, max: 100, step: 1 }, + { key: "blacks", label: "Black Point", min: -100, max: 100, step: 1 }, + { key: "temperature", label: "Warmth", min: -100, max: 100, step: 1 }, + { key: "tint", label: "Tint", min: -100, max: 100, step: 1 }, + { key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1 }, + { key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 }, +]; + +function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string { + if (key === "exposure") { + const stops = rawPercent / 100; + return `${stops >= 0 ? "+" : ""}${stops.toFixed(2)}`; + } + 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"]; + +const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string }> = [ + { key: "blur", label: "Blur" }, + { key: "pixelate", label: "Pixelate" }, +]; + +function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) { + if (metadata?.color.dynamicRange !== "hdr") return null; + return ( +
+
+ {metadata.color.label} source + + SDR preview + +
+

+ These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this + is not true HDR color grading yet. +

+
+ ); +} + +// fallow-ignore-next-line complexity +export function FlatColorGradingSection({ + grading, + assets, + onImportAssets, + onCommitColorGrading, + applyScope, + applyBusy, + onSetApplyScope, + onApplyToScope, + onApplyScopeAvailable, + mediaMetadata, +}: { + grading: NormalizedHfColorGrading; + assets: string[]; + onImportAssets?: (files: FileList, dir?: string) => Promise; + 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(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], + ); + 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); + }; + + 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 ( +
+ +
+ Preset + +
+ updateIntensity(100)} + /> + +
+ + {lutOpen && ( +
+
+ + {selectedLutName ?? "None"} + + + + { + void importLuts(e.currentTarget.files); + e.currentTarget.value = ""; + }} + /> +
+ {lut && ( + applyLut(lut.src, v / 100)} + onReset={() => applyLut(lut.src, 1)} + /> + )} +
+ )} +
+ +
+
+ Adjust +
+ {ADJUST_SLIDERS.map((slider) => { + const rawPercent = grading.adjust[slider.key] * 100; + const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6; + return ( +
+ + onCommitColorGrading({ + ...grading, + adjust: { ...grading.adjust, [slider.key]: next / 100 }, + }) + } + onReset={() => + onCommitColorGrading({ + ...grading, + adjust: { ...grading.adjust, [slider.key]: 0 }, + }) + } + /> +
+ ); + })} +
+ +
+
+ Finishing +
+
+
{renderDetailSlider("vignette")}
+ +
+
+
{renderDetailSlider("grain")}
+ +
+ {detailSettingsOpen && ( +
+ {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map( + renderDetailSlider, + )} +
+ )} +
+ +
+
+ Effects +
+ {EFFECT_SLIDERS.map((slider) => { + const value = grading.effects[slider.key]; + const isSet = value > 1e-6; + return ( +
+ + onCommitColorGrading({ + ...grading, + effects: { ...grading.effects, [slider.key]: next / 100 }, + }) + } + onReset={() => + onCommitColorGrading({ + ...grading, + effects: { ...grading.effects, [slider.key]: 0 }, + }) + } + /> +
+ ); + })} +
+ + {onApplyScopeAvailable && ( +
+ + Copy grade to + + + +
+ )} +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 954ea8052f..6ff132760e 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -229,6 +229,96 @@ describe("FlatSlider", () => { }); }); +describe("FlatSlider — Grade extensions", () => { + it("renders a center tick when centerTick is true, and omits it by default", () => { + const { host: withTick, root: rootA } = renderInto( + , + ); + expect(withTick.querySelector('[data-flat-slider-center-tick="true"]')).not.toBeNull(); + act(() => rootA.unmount()); + + const { host: withoutTick, root: rootB } = renderInto( + , + ); + expect(withoutTick.querySelector('[data-flat-slider-center-tick="true"]')).toBeNull(); + act(() => rootB.unmount()); + }); + + it("always reserves a 14px reset slot, showing the icon only when set and onReset is provided", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const slot = host.querySelector('[data-flat-slider-reset-slot="true"]'); + expect(slot).not.toBeNull(); + const resetButton = host.querySelector('[data-flat-slider-reset="true"]'); + expect(resetButton).not.toBeNull(); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + + const { host: unsetHost, root: rootB } = renderInto( + , + ); + expect(unsetHost.querySelector('[data-flat-slider-reset-slot="true"]')).not.toBeNull(); + expect(unsetHost.querySelector('[data-flat-slider-reset="true"]')).toBeNull(); + act(() => rootB.unmount()); + }); + + it("renders no reset slot at all when centerTick is omitted, matching existing Style/Media callers", () => { + const { host, root } = renderInto( + , + ); + expect(host.querySelector('[data-flat-slider-reset-slot="true"]')).toBeNull(); + expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull(); + act(() => root.unmount()); + }); +}); + describe("FlatSelectRow", () => { it("renders the default tier with no reset button", () => { const { host, root } = renderInto( @@ -288,6 +378,44 @@ describe("FlatSelectRow", () => { }); }); +describe("FlatSelectRow — label/value options", () => { + it("renders distinct labels for entries with a different display label than value", () => { + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + expect(select?.value).toBe("natural-lift"); + const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent); + expect(options).toEqual(["Neutral", "Natural Lift", "Fresh Pop"]); + act(() => root.unmount()); + }); + + it("still treats a bare string array as value===label (Plan 2 behavior unchanged)", () => { + const { host, root } = renderInto( + , + ); + const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent); + expect(options).toEqual(["normal", "multiply", "screen"]); + act(() => root.unmount()); + }); +}); + describe("FlatToggle", () => { it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => { const onChange = vi.fn(); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index e6dc9f1f78..078845bd1a 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -247,6 +247,8 @@ export function FlatSlider({ tier, displayValue, disabled, + centerTick, + onReset, onCommit, }: { label: string; @@ -257,6 +259,8 @@ export function FlatSlider({ tier: "default" | "explicitCustom"; displayValue: string; disabled?: boolean; + centerTick?: boolean; + onReset?: () => void; onCommit: (nextValue: number) => void; }) { const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100)); @@ -285,6 +289,12 @@ export function FlatSlider({ commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); }} > + {centerTick && ( +
+ )} {tier === "explicitCustom" && (
{displayValue} + {centerTick && ( + + {tier === "explicitCustom" && onReset && ( + + )} + + )}
); } @@ -327,12 +352,15 @@ export function FlatSelectRow({ }: { label: string; value: string; - options: string[]; + options: Array; tier: PropertyValueTier; disabled?: boolean; onChange: (nextValue: string) => void; onReset?: () => void; }) { + const normalizedOptions = options.map((option) => + typeof option === "string" ? { value: option, label: option } : option, + ); return (
{label} @@ -344,9 +372,9 @@ export function FlatSelectRow({ onChange={(e) => onChange(e.target.value)} className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`} > - {options.map((option) => ( - ))} diff --git a/packages/studio/src/components/editor/useColorGradingController.test.ts b/packages/studio/src/components/editor/useColorGradingController.test.ts new file mode 100644 index 0000000000..5529003925 --- /dev/null +++ b/packages/studio/src/components/editor/useColorGradingController.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; +import { useColorGradingController } from "./useColorGradingController"; +import type { DomEditSelection } from "./domEditing"; + +function freshPopGrading() { + const next = normalizeHfColorGrading({ preset: "fresh-pop", intensity: 1 }); + if (!next) throw new Error("expected fresh-pop preset to normalize"); + return next; +} + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function makeElement(overrides: Partial = {}): DomEditSelection { + return { + element: document.createElement("video"), + id: "s1-bg", + selector: "#s1-bg", + label: "S1 Background", + tagName: "video", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 1920, height: 1080 }, + textContent: "", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function HookHost({ + onState, + onSetAttributeLive, +}: { + onState: (state: ReturnType) => void; + onSetAttributeLive: (attr: string, value: string | null) => void; +}) { + const state = useColorGradingController({ + projectId: "proj", + element: makeElement(), + onSetAttributeLive, + }); + onState(state); + return null; +} + +function renderHook(onSetAttributeLive: (attr: string, value: string | null) => void) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let latest: ReturnType | undefined; + act(() => { + root.render( + React.createElement(HookHost, { + onState: (s: ReturnType) => (latest = s), + onSetAttributeLive, + }), + ); + }); + return { + root, + get state() { + if (!latest) throw new Error("hook did not render"); + return latest; + }, + }; +} + +describe("useColorGradingController", () => { + it("starts with the neutral (inactive) grading and idle compare state", () => { + const { root, state } = renderHook(vi.fn()); + expect(state.grading.preset).toBe("neutral"); + expect(state.compareEnabled).toBe(false); + act(() => root.unmount()); + }); + + it("commitColorGrading updates grading state synchronously and schedules a debounced persist", async () => { + vi.useFakeTimers(); + const onSetAttributeLive = vi.fn(); + const { root, state } = renderHook(onSetAttributeLive); + act(() => { + state.commitColorGrading(freshPopGrading()); + }); + expect(onSetAttributeLive).not.toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(400); + }); + expect(onSetAttributeLive).toHaveBeenCalledTimes(1); + const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string]; + expect(attr).toBe("color-grading"); + expect(value).toContain("fresh-pop"); + act(() => root.unmount()); + vi.useRealTimers(); + }); + + it("resetGrading returns to the neutral preset", () => { + const { root, state } = renderHook(vi.fn()); + act(() => { + state.commitColorGrading(freshPopGrading()); + }); + act(() => { + state.resetGrading(); + }); + expect(state.grading.preset).toBe("neutral"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/useColorGradingController.ts b/packages/studio/src/components/editor/useColorGradingController.ts new file mode 100644 index 0000000000..41a01f4dd2 --- /dev/null +++ b/packages/studio/src/components/editor/useColorGradingController.ts @@ -0,0 +1,407 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { + HF_COLOR_GRADING_ATTR, + isHfColorGradingActive, + normalizeHfColorGrading, + serializeHfColorGrading, + type HfColorGradingTarget, + type NormalizedHfColorGrading, +} from "@hyperframes/core/color-grading"; +import { + addStudioPendingEditFlushListener, + trackStudioPendingEdit, +} from "../../utils/studioPendingEdits"; +import type { DomEditSelection } from "./domEditing"; +import { stripQueryAndHash } from "./propertyPanelHelpers"; + +const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, ""); +const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const; +const MEDIA_METADATA_CACHE = new Map(); + +export interface RuntimeColorGradingStatus { + state: "missing" | "inactive" | "pending" | "active" | "unavailable"; + message: string; +} + +export interface MediaMetadata { + kind: "video" | "image" | "audio" | "unknown"; + color: { + dynamicRange: "hdr" | "sdr" | "unknown"; + hdrTransfer: "pq" | "hlg" | "unknown" | null; + label: string; + isHdr: boolean; + codecName?: string; + profile?: string; + pixelFormat?: string; + colorSpace?: string; + colorTransfer?: string; + colorPrimaries?: string; + }; + probeError?: string; +} + +interface MediaMetadataResponse { + path: string; + metadata: MediaMetadata; +} + +function stripPreviewAssetPath(src: string, projectId: string): string | null { + let pathname = src; + try { + pathname = new URL(src, window.location.href).pathname; + } catch { + return null; + } + const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`; + const genericMarker = "/preview/"; + const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker; + const index = pathname.indexOf(marker); + if (index < 0) return null; + const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, ""); + if (!assetPath || assetPath.startsWith("comp/")) return null; + return assetPath; +} + +// fallow-ignore-next-line complexity +function resolveProjectAssetPath( + sourceFile: string, + src: string, + projectId: string, +): string | null { + const trimmed = stripQueryAndHash(src.trim()); + if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null; + if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId); + if (trimmed.startsWith("/")) { + return stripPreviewAssetPath(trimmed, projectId); + } + + const sourceDir = sourceFile.includes("/") + ? sourceFile.slice(0, sourceFile.lastIndexOf("/")) + : ""; + const parts = `${sourceDir}/${trimmed}`.split("/"); + const normalized: string[] = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") { + normalized.pop(); + continue; + } + normalized.push(part); + } + return normalized.join("/") || null; +} + +function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null { + if (element.tagName !== "video" && element.tagName !== "img") return null; + const media = element.element as HTMLImageElement | HTMLVideoElement; + const src = media.getAttribute("src") || media.currentSrc || ""; + return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId); +} + +function defaultColorGrading(): NormalizedHfColorGrading { + const grading = normalizeHfColorGrading("neutral"); + if (!grading) throw new Error("Missing neutral color grading preset"); + return grading; +} + +function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading { + return ( + normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading() + ); +} + +function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown { + if (!isHfColorGradingActive(grading)) return null; + const { enabled: _enabled, ...bridgeGrading } = grading; + return bridgeGrading; +} + +function readRuntimeColorGradingStatus( + iframe: HTMLIFrameElement | null | undefined, + target: HfColorGradingTarget, +): RuntimeColorGradingStatus { + try { + const win = iframe?.contentWindow as + | (Window & { + __hf?: { + colorGrading?: { + getStatus?: ( + target: HfColorGradingTarget | string | null | undefined, + ) => RuntimeColorGradingStatus; + }; + }; + }) + | null + | undefined; + const status = win?.__hf?.colorGrading?.getStatus?.(target); + return status ?? { state: "pending", message: "Waiting for runtime" }; + } catch { + return { state: "unavailable", message: "Preview unavailable" }; + } +} + +export interface ColorGradingControllerState { + grading: NormalizedHfColorGrading; + compareEnabled: boolean; + applyScope: "source-file" | "project"; + applyBusy: boolean; + runtimeStatus: RuntimeColorGradingStatus; + mediaMetadata: MediaMetadata | null; + commitColorGrading: (next: NormalizedHfColorGrading) => void; + commitCompare: (enabled: boolean) => void; + setApplyScope: (scope: "source-file" | "project") => void; + applyToScope: () => Promise; + resetGrading: () => void; +} + +export function useColorGradingController({ + projectId, + element, + previewIframeRef, + onSetAttributeLive, + onApplyScope, +}: { + projectId: string; + element: DomEditSelection; + previewIframeRef?: RefObject; + onSetAttributeLive: (attr: string, value: string | null) => void | Promise; + onApplyScope?: ( + scope: "source-file" | "project", + value: string | null, + ) => Promise<{ changedFiles: number; changedElements: number }>; +}): ColorGradingControllerState { + const [grading, setGrading] = useState(() => readColorGradingFromElement(element)); + const [compareEnabled, setCompareEnabled] = useState(false); + const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file"); + const [applyBusy, setApplyBusy] = useState(false); + const [runtimeStatus, setRuntimeStatus] = useState(() => ({ + state: "pending", + message: "Waiting for runtime", + })); + const selectedAssetPath = useMemo( + () => selectedMediaAssetPath(element, projectId), + [element, projectId], + ); + const [mediaMetadata, setMediaMetadata] = useState(null); + const persistTimerRef = useRef | null>(null); + const pendingPersistValueRef = useRef(undefined); + const statusTimersRef = useRef([]); + const onSetAttributeLiveRef = useRef(onSetAttributeLive); + const latestGradingRef = useRef(grading); + const compareEnabledRef = useRef(compareEnabled); + onSetAttributeLiveRef.current = onSetAttributeLive; + latestGradingRef.current = grading; + compareEnabledRef.current = compareEnabled; + const target = useMemo( + (): HfColorGradingTarget => ({ + id: element.id ?? null, + hfId: element.hfId ?? null, + selector: element.selector ?? null, + selectorIndex: element.selectorIndex ?? null, + }), + [element.hfId, element.id, element.selector, element.selectorIndex], + ); + + const refreshRuntimeStatus = useCallback(() => { + setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target)); + }, [previewIframeRef, target]); + + useEffect(() => { + setMediaMetadata(null); + if (!selectedAssetPath) return; + const cacheKey = `${projectId}:${selectedAssetPath}`; + if (MEDIA_METADATA_CACHE.has(cacheKey)) { + setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null); + return; + } + const controller = new AbortController(); + fetch( + `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent( + selectedAssetPath, + )}`, + { signal: controller.signal }, + ) + .then((response) => (response.ok ? response.json() : null)) + .then((data: MediaMetadataResponse | null) => { + if (controller.signal.aborted) return; + const metadata = data?.metadata ?? null; + MEDIA_METADATA_CACHE.set(cacheKey, metadata); + setMediaMetadata(metadata); + }) + .catch(() => { + if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null); + }); + return () => controller.abort(); + }, [projectId, selectedAssetPath]); + + const clearStatusTimers = useCallback(() => { + for (const timer of statusTimersRef.current) clearTimeout(timer); + statusTimersRef.current = []; + }, []); + + const scheduleRuntimeStatusRefresh = useCallback(() => { + clearStatusTimers(); + statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) => + window.setTimeout(refreshRuntimeStatus, delay), + ); + }, [clearStatusTimers, refreshRuntimeStatus]); + + useEffect(() => { + refreshRuntimeStatus(); + }, [refreshRuntimeStatus]); + + const persistColorGradingValue = useCallback((value: string | null) => { + return trackStudioPendingEdit( + onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null), + ); + }, []); + + const flushPendingPersist = useCallback(() => { + if (persistTimerRef.current) { + clearTimeout(persistTimerRef.current); + persistTimerRef.current = null; + } + if (pendingPersistValueRef.current === undefined) return undefined; + const value = pendingPersistValueRef.current; + pendingPersistValueRef.current = undefined; + return persistColorGradingValue(value); + }, [persistColorGradingValue]); + + useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]); + + useEffect(() => { + return () => { + clearStatusTimers(); + void flushPendingPersist(); + }; + }, [clearStatusTimers, flushPendingPersist]); + + const postColorGrading = useCallback( + (nextGrading: NormalizedHfColorGrading) => { + previewIframeRef?.current?.contentWindow?.postMessage( + { + source: "hf-parent", + type: "control", + action: "set-color-grading", + target, + grading: toBridgeColorGrading(nextGrading), + }, + "*", + ); + }, + [previewIframeRef, target], + ); + + const postCompare = useCallback( + (enabled: boolean) => { + previewIframeRef?.current?.contentWindow?.postMessage( + { + source: "hf-parent", + type: "control", + action: "set-color-grading-compare", + target, + compare: { + enabled, + position: 1, + lineWidth: 0, + }, + }, + "*", + ); + }, + [previewIframeRef, target], + ); + + useEffect(() => { + const iframe = previewIframeRef?.current; + if (!iframe) return; + const refreshAndReplay = () => { + const nextGrading = latestGradingRef.current; + const active = isHfColorGradingActive(nextGrading); + if (active) postColorGrading(nextGrading); + postCompare(compareEnabledRef.current && active); + scheduleRuntimeStatusRefresh(); + }; + const onMessage = (event: MessageEvent) => { + if (event.source !== iframe.contentWindow) return; + const data = event.data as { source?: unknown; type?: unknown } | null; + if (data?.source === "hf-preview" && data.type === "ready") refreshAndReplay(); + }; + iframe.addEventListener("load", refreshAndReplay); + window.addEventListener("message", onMessage); + const timer = window.setTimeout(refreshAndReplay, 80); + return () => { + iframe.removeEventListener("load", refreshAndReplay); + window.removeEventListener("message", onMessage); + window.clearTimeout(timer); + }; + }, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]); + + useEffect( + () => () => { + postCompare(false); + }, + [postCompare], + ); + + const commitColorGrading = useCallback( + (nextGrading: NormalizedHfColorGrading) => { + setGrading(nextGrading); + setRuntimeStatus({ state: "pending", message: "Updating shader" }); + postColorGrading(nextGrading); + const active = isHfColorGradingActive(nextGrading); + if (compareEnabledRef.current) { + postCompare(active); + if (!active) setCompareEnabled(false); + } + scheduleRuntimeStatusRefresh(); + if (persistTimerRef.current) clearTimeout(persistTimerRef.current); + pendingPersistValueRef.current = isHfColorGradingActive(nextGrading) + ? serializeHfColorGrading(nextGrading) + : null; + persistTimerRef.current = setTimeout(() => { + const value = pendingPersistValueRef.current; + pendingPersistValueRef.current = undefined; + persistTimerRef.current = null; + void persistColorGradingValue(value ?? null); + }, 350); + }, + [persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], + ); + + const commitCompare = useCallback( + (enabled: boolean) => { + const nextEnabled = enabled && isHfColorGradingActive(grading); + setCompareEnabled(nextEnabled); + if (nextEnabled) postColorGrading(grading); + postCompare(nextEnabled); + scheduleRuntimeStatusRefresh(); + }, + [grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh], + ); + + const applyToScope = useCallback(async () => { + if (!onApplyScope || applyBusy) return; + setApplyBusy(true); + try { + const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null; + await onApplyScope(applyScope, value); + } finally { + setApplyBusy(false); + } + }, [applyBusy, applyScope, grading, onApplyScope]); + + return { + grading, + compareEnabled, + applyScope, + applyBusy, + runtimeStatus, + mediaMetadata, + commitColorGrading, + commitCompare, + setApplyScope, + applyToScope, + resetGrading: () => commitColorGrading(defaultColorGrading()), + }; +}