Merge pull request #2124 from heygen-com/studio-flat-05-grade

feat(studio): flat inspector — Grade (color grading) group
This commit is contained in:
Vance Ingalls
2026-07-14 16:03:44 -07:00
committed by GitHub
10 changed files with 1700 additions and 366 deletions
+16
View File
@@ -536,6 +536,18 @@
// live DOM snapshots beside their assertions; sharing those short fixtures // live DOM snapshots beside their assertions; sharing those short fixtures
// would obscure the exact script/identity difference under test. // would obscure the exact script/identity difference under test.
"packages/studio/src/utils/gsapUndoRestore.test.ts", "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": { "health": {
@@ -717,6 +729,10 @@
"packages/core/src/compiler/inlineSubCompositions.ts", "packages/core/src/compiler/inlineSubCompositions.ts",
"packages/core/src/compiler/htmlBundler.ts", "packages/core/src/compiler/htmlBundler.ts",
"packages/core/src/runtime/compositionLoader.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",
], ],
}, },
} }
@@ -668,6 +668,26 @@ function flatGroupTitles(host: HTMLElement): string[] {
return [...open, ...collapsed]; 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)", () => { describe("PropertyPanel — Media group (Plan 4)", () => {
it( it(
"renders the flat Media group and not the legacy MediaSection, for a video element", "renders the flat Media group and not the legacy MediaSection, for a video element",
@@ -17,6 +17,11 @@ import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections"; import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { ColorGradingSection } from "./propertyPanelColorGradingSection"; import { ColorGradingSection } from "./propertyPanelColorGradingSection";
import { useColorGradingController } from "./useColorGradingController";
import {
FlatColorGradingAccessory,
FlatColorGradingSection,
} from "./propertyPanelFlatColorGradingSection";
type EditingSections = ReturnType<typeof resolveEditingSections>; type EditingSections = ReturnType<typeof resolveEditingSections>;
@@ -225,6 +230,20 @@ export function PropertyPanelFlat({
); );
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]); const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
// 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 isTextEditable = isTextEditableSelection(element);
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
const toggleOpen = (groupId: string) => const toggleOpen = (groupId: string) =>
@@ -415,6 +434,30 @@ export function PropertyPanelFlat({
/> />
</FlatGroup> </FlatGroup>
)} )}
{sections.colorGrading && (
<FlatGroup
title="Grade"
isOpen={openGroupId === "grade" || pinnedGroupIds.includes("grade")}
isPinned={pinnedGroupIds.includes("grade")}
onToggleOpen={() => toggleOpen("grade")}
onTogglePin={() => togglePin("grade")}
accessory={<FlatColorGradingAccessory state={colorGradingController} />}
summary={`${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`}
>
<FlatColorGradingSection
grading={colorGradingController.grading}
assets={assets}
onImportAssets={onImportAssets}
onCommitColorGrading={colorGradingController.commitColorGrading}
applyScope={colorGradingController.applyScope}
applyBusy={colorGradingController.applyBusy}
onSetApplyScope={colorGradingController.setApplyScope}
onApplyToScope={() => void colorGradingController.applyToScope()}
onApplyScopeAvailable={Boolean(onApplyColorGradingScope)}
mediaMetadata={colorGradingController.mediaMetadata}
/>
</FlatGroup>
)}
{sections.colorGrading && ( {sections.colorGrading && (
<ColorGradingSection <ColorGradingSection
key={[ key={[
@@ -1,159 +1,14 @@
import { import { type PointerEvent as ReactPointerEvent, type RefObject } from "react";
useCallback, import { isHfColorGradingActive } from "@hyperframes/core/color-grading";
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject,
} from "react";
import {
HF_COLOR_GRADING_ATTR,
isHfColorGradingActive,
normalizeHfColorGrading,
serializeHfColorGrading,
type HfColorGradingTarget,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons"; import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons";
import {
addStudioPendingEditFlushListener,
trackStudioPendingEdit,
} from "../../utils/studioPendingEdits";
import type { DomEditSelection } from "./domEditing"; import type { DomEditSelection } from "./domEditing";
import { ColorGradingControls } from "./propertyPanelColorGradingControls"; import { ColorGradingControls } from "./propertyPanelColorGradingControls";
import { stripQueryAndHash } from "./propertyPanelHelpers";
import { Section } from "./propertyPanelPrimitives"; import { Section } from "./propertyPanelPrimitives";
import { import {
acceptStudioRuntimeMessage, useColorGradingController,
postRuntimeControlMessage, type MediaMetadata,
} from "../../player/lib/runtimeProtocol"; type RuntimeColorGradingStatus,
} from "./useColorGradingController";
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<string, MediaMetadata | null>();
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" };
}
}
function StatusPill({ status }: { status: RuntimeColorGradingStatus }) { function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
const dotClass = const dotClass =
@@ -289,216 +144,25 @@ export function ColorGradingSection({
value: string | null, value: string | null,
) => Promise<{ changedFiles: number; changedElements: number }>; ) => Promise<{ changedFiles: number; changedElements: number }>;
}) { }) {
const [grading, setGrading] = useState(() => readColorGradingFromElement(element)); const {
const [compareEnabled, setCompareEnabled] = useState(false); grading,
const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file"); compareEnabled,
const [applyBusy, setApplyBusy] = useState(false); applyScope,
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeColorGradingStatus>(() => ({ applyBusy,
state: "pending", runtimeStatus,
message: "Waiting for runtime", mediaMetadata,
})); commitColorGrading,
const selectedAssetPath = useMemo( commitCompare,
() => selectedMediaAssetPath(element, projectId), setApplyScope,
[element, projectId], applyToScope,
); resetGrading,
const [mediaMetadata, setMediaMetadata] = useState<MediaMetadata | null>(null); } = useColorGradingController({
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); projectId,
const pendingPersistValueRef = useRef<string | null | undefined>(undefined); element,
const statusTimersRef = useRef<number[]>([]); previewIframeRef,
const onSetAttributeLiveRef = useRef(onSetAttributeLive); onSetAttributeLive,
const latestGradingRef = useRef(grading); onApplyScope,
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) => {
postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", {
target,
grading: toBridgeColorGrading(nextGrading),
});
},
[previewIframeRef, target],
);
const postCompare = useCallback(
(enabled: boolean) => {
postRuntimeControlMessage(
previewIframeRef?.current?.contentWindow,
"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") return;
if (!acceptStudioRuntimeMessage(data)) return;
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 ( return (
<Section <Section
@@ -516,7 +180,7 @@ export function ColorGradingSection({
type="button" type="button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); 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" 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" title="Reset color grading"
@@ -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<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a slider track");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: trackWidth, top: 0, height: 2, right: trackWidth, bottom: 2 }),
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX }));
});
}
function clickSliderReset(row: Element) {
const resetButton = row.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
expect(resetButton).not.toBeNull();
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
}
describe("FlatColorGradingAccessory", () => {
it("shows a 5px status dot colored by runtime status, with the message as its title", () => {
const { host, root } = renderInto(
<FlatColorGradingAccessory
state={{
grading: neutralGrading(),
compareEnabled: false,
runtimeStatus: { state: "active", message: "Shader active" },
commitCompare: vi.fn(),
resetGrading: vi.fn(),
}}
/>,
);
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(
<FlatColorGradingAccessory
state={{
grading: neutralGrading(),
compareEnabled: false,
runtimeStatus: { state: "inactive", message: "No grading applied" },
commitCompare: vi.fn(),
resetGrading,
}}
/>,
);
const compareButton = host.querySelector<HTMLButtonElement>(
'[aria-label="Hold to show original"]',
);
expect(compareButton?.disabled).toBe(true);
const resetButton = host.querySelector<HTMLButtonElement>('[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(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const presetSelect = host.querySelector<HTMLSelectElement>(
'[data-flat-grade-preset="true"] select',
);
if (!presetSelect) throw new Error("expected a preset select");
expect(presetSelect.value).toBe("neutral");
act(() => {
presetSelect.value = "fresh-pop";
presetSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("fresh-pop");
act(() => root.unmount());
});
it("shows the Custom LUT row collapsed by default, expanding to reveal the strength slider when a LUT is set", () => {
const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.8 } };
const { host, root } = renderInto(
<FlatColorGradingSection {...neutralPropsBase()} grading={grading} />,
);
const lutToggle = host.querySelector<HTMLButtonElement>('[data-flat-grade-lut-toggle="true"]');
expect(lutToggle).not.toBeNull();
act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.textContent).toContain("warm.cube");
act(() => root.unmount());
});
it("commits the selected catalog LUT via the select control, resetting intensity to 1 when switching LUTs", () => {
const onCommitColorGrading = vi.fn();
const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.5 } };
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
assets={["assets/luts/warm.cube", "assets/luts/cool.cube"]}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const lutToggle = host.querySelector<HTMLButtonElement>('[data-flat-grade-lut-toggle="true"]');
act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const lutSelect = host.querySelector<HTMLSelectElement>('[data-flat-grade-lut-select="true"]');
if (!lutSelect) throw new Error("expected a LUT catalog select");
act(() => {
lutSelect.value = "assets/luts/cool.cube";
lutSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].lut).toEqual({
src: "assets/luts/cool.cube",
intensity: 1,
});
act(() => root.unmount());
});
it("imports a LUT via the hidden file input and commits the resolved asset", async () => {
const onCommitColorGrading = vi.fn();
const onImportAssets = vi.fn().mockResolvedValue(["assets/luts/x.cube"]);
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
onImportAssets={onImportAssets}
/>,
);
const lutToggle = host.querySelector<HTMLButtonElement>('[data-flat-grade-lut-toggle="true"]');
act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const fileInput = host.querySelector<HTMLInputElement>('input[type="file"]');
if (!fileInput) throw new Error("expected a hidden file input");
const file = new File(["cube data"], "x.cube");
Object.defineProperty(fileInput, "files", { value: [file], configurable: true });
await act(async () => {
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(onImportAssets).toHaveBeenCalledTimes(1);
expect(onImportAssets.mock.calls[0][0]).toEqual([file]);
expect(onImportAssets.mock.calls[0][1]).toBe("assets/luts");
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].lut).toEqual({
src: "assets/luts/x.cube",
intensity: 1,
});
act(() => root.unmount());
});
});
describe("FlatColorGradingSection — Adjust sliders", () => {
it("renders all 10 adjust rows with a center tick, formatting exposure distinctly from percentage sliders", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
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(
<FlatColorGradingSection
{...neutralPropsBase()}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
/>,
);
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(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
/>,
);
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(
<FlatColorGradingSection
{...neutralPropsBase()}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const exposureRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Exposure");
// min=-200, max=200, step=5, ratio=1.0 -> raw=200 -> commit(200) -> adjust.exposure = 200/100 = 2
dragSliderTrack(exposureRow, 200, 200);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].adjust.exposure).toBe(2);
expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2);
act(() => root.unmount());
});
});
describe("FlatColorGradingSection — Vignette and Grain", () => {
it("renders Vignette and Grain amount rows with a settings gear, expanding tuned sliders on click", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
const vignetteGear = host.querySelector<HTMLButtonElement>(
'[data-flat-grade-settings="vignette"]',
);
expect(vignetteGear).not.toBeNull();
act(() => vignetteGear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.textContent).toContain("Midpoint");
expect(host.textContent).toContain("Feather");
act(() => root.unmount());
});
it("shows tuned Midpoint at its 50% default with no reset until moved from default", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const midpointRow = findRowByText(host, "div", "Midpoint", "startsWith");
expect(midpointRow.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
act(() => root.unmount());
});
it("commits a dragged Roundness value on slider track pointerdown, scaled from percent back into the -1..1 detail range", () => {
const onCommitColorGrading = vi.fn();
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith");
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> details.vignetteRoundness = 50/100 = 0.5
dragSliderTrack(roundnessRow, 75, 100);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0.5);
act(() => root.unmount());
});
it("resets a non-default Roundness back to its 0 default via the tuned slider's reset button", () => {
const onCommitColorGrading = vi.fn();
const grading = {
...neutralGrading(),
details: { ...neutralGrading().details, vignetteRoundness: 0.4 },
};
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith");
clickSliderReset(roundnessRow);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0);
act(() => root.unmount());
});
});
describe("FlatColorGradingSection — Effects", () => {
it("renders Blur and Pixelate sliders under an Effects micro-label", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
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(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
/>,
);
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(
<FlatColorGradingSection
{...neutralPropsBase()}
mediaMetadata={{
kind: "video",
color: { dynamicRange: "hdr", hdrTransfer: "pq", label: "HDR10", isHdr: true },
}}
/>,
);
expect(host.textContent).toContain("SDR preview");
act(() => root.unmount());
});
it("omits the HDR banner for SDR media", () => {
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
mediaMetadata={{
kind: "video",
color: { dynamicRange: "sdr", hdrTransfer: null, label: "SDR", isHdr: false },
}}
/>,
);
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(
<FlatColorGradingSection {...neutralPropsBase()} onApplyToScope={onApplyToScope} applyBusy />,
);
const applyButton = host.querySelector<HTMLButtonElement>('[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(
<FlatColorGradingSection
{...neutralPropsBase()}
onApplyToScope={onApplyToScope}
applyBusy={false}
/>,
);
const applyButton = host.querySelector<HTMLButtonElement>('[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());
});
});
@@ -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<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
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 (
<span className="flex items-center gap-2.5">
<button
type="button"
aria-pressed={compareEnabled}
aria-label="Hold to show original"
disabled={!gradingActive}
onPointerDown={(e) => {
if (!gradingActive) return;
e.preventDefault();
e.stopPropagation();
commitCompare(true);
const release = () => {
commitCompare(false);
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
}}
title="Hold to show original"
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
>
<Compare size={12} />
</button>
<span
data-flat-grade-status-dot="true"
title={runtimeStatus.message}
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
/>
<button
type="button"
data-flat-grade-reset="true"
title="Reset color grading"
onClick={(e) => {
e.stopPropagation();
resetGrading();
}}
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
>
<RotateCcw size={12} />
</button>
</span>
);
}
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 (
<div
data-flat-grade-hdr-banner="true"
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1.5 text-[10px] leading-4 text-amber-100"
>
<div className="mb-0.5 flex items-center justify-between gap-2">
<span className="font-semibold">{metadata.color.label} source</span>
<span className="rounded bg-amber-400/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-100">
SDR preview
</span>
</div>
<p className="text-amber-100/80">
These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this
is not true HDR color grading yet.
</p>
</div>
);
}
// 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<string[]>;
onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
applyScope: "source-file" | "project";
applyBusy: boolean;
onSetApplyScope: (scope: "source-file" | "project") => void;
onApplyToScope: () => void;
onApplyScopeAvailable: boolean;
mediaMetadata: MediaMetadata | null;
}) {
const lutInputRef = useRef<HTMLInputElement>(null);
const [lutOpen, setLutOpen] = useState(false);
const [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 (
<FlatSlider
key={key}
label={spec.label}
value={Math.round(value * 100)}
min={key === "vignetteRoundness" ? -100 : 0}
max={100}
tier={isSet ? "explicitCustom" : "default"}
displayValue={`${Math.round(value * 100)}%`}
centerTick={key === "vignetteRoundness"}
onCommit={(next) =>
onCommitColorGrading({ ...grading, details: { ...grading.details, [key]: next / 100 } })
}
onReset={() =>
onCommitColorGrading({
...grading,
details: { ...grading.details, [key]: spec.defaultValue },
})
}
/>
);
};
return (
<div className="space-y-1.5">
<HdrBanner metadata={mediaMetadata} />
<div data-flat-grade-preset="true" className="flex min-h-[30px] items-center justify-between">
<span className="text-[11px] text-panel-text-2">Preset</span>
<FlatSelectRow
label=""
value={grading.preset ?? "neutral"}
options={PRESET_OPTIONS}
tier={resolveValueTier(
grading.preset === "neutral" ? undefined : (grading.preset ?? undefined),
"neutral",
)}
onChange={applyPreset}
/>
</div>
<FlatSlider
label="Strength"
value={Math.round(grading.intensity * 100)}
min={0}
max={100}
tier={grading.intensity === 1 ? "default" : "explicitCustom"}
displayValue={`${Math.round(grading.intensity * 100)}%`}
onCommit={updateIntensity}
onReset={() => updateIntensity(100)}
/>
<div className="border-t border-panel-hairline pt-1.5">
<button
type="button"
data-flat-grade-lut-toggle="true"
onClick={() => setLutOpen((v) => !v)}
className="flex min-h-[30px] w-full items-center justify-between text-left"
>
<span className="text-[11px] text-panel-text-2">Custom LUT</span>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className={`flex-shrink-0 text-panel-text-5 transition-transform ${lutOpen ? "rotate-90" : ""}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
</button>
{lutOpen && (
<div className="space-y-1.5 pb-1">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-3">
{selectedLutName ?? "None"}
</span>
<select
data-flat-grade-lut-select="true"
value={lut?.src ?? ""}
onChange={(e) => {
const src = e.target.value;
applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
}}
className="bg-transparent font-mono text-[10px] text-panel-text-3 outline-none"
>
<option value="">None</option>
{lutAssets.map((asset) => (
<option key={asset} value={asset}>
{asset.split("/").pop() ?? asset}
</option>
))}
</select>
<button
type="button"
disabled={!onImportAssets}
onClick={() => lutInputRef.current?.click()}
title="Import .cube LUT"
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus size={12} />
</button>
<input
ref={lutInputRef}
type="file"
accept=".cube"
className="hidden"
onChange={(e) => {
void importLuts(e.currentTarget.files);
e.currentTarget.value = "";
}}
/>
</div>
{lut && (
<FlatSlider
label="LUT strength"
value={Math.round((lut.intensity ?? 1) * 100)}
min={0}
max={100}
tier={lut.intensity === 1 ? "default" : "explicitCustom"}
displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
onCommit={(v) => applyLut(lut.src, v / 100)}
onReset={() => applyLut(lut.src, 1)}
/>
)}
</div>
)}
</div>
<div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Adjust
</div>
{ADJUST_SLIDERS.map((slider) => {
const rawPercent = grading.adjust[slider.key] * 100;
const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6;
return (
<div key={slider.key} data-flat-grade-adjust="true">
<FlatSlider
label={slider.label}
value={rawPercent}
min={slider.min}
max={slider.max}
step={slider.step}
tier={isSet ? "explicitCustom" : "default"}
displayValue={formatAdjustValue(slider.key, rawPercent)}
centerTick
onCommit={(next) =>
onCommitColorGrading({
...grading,
adjust: { ...grading.adjust, [slider.key]: next / 100 },
})
}
onReset={() =>
onCommitColorGrading({
...grading,
adjust: { ...grading.adjust, [slider.key]: 0 },
})
}
/>
</div>
);
})}
</div>
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Finishing
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1">{renderDetailSlider("vignette")}</div>
<button
type="button"
data-flat-grade-settings="vignette"
title="Vignette settings"
onClick={() => setDetailSettingsOpen((c) => (c === "vignette" ? null : "vignette"))}
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
>
<Settings size={12} />
</button>
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1">{renderDetailSlider("grain")}</div>
<button
type="button"
data-flat-grade-settings="grain"
title="Grain settings"
onClick={() => setDetailSettingsOpen((c) => (c === "grain" ? null : "grain"))}
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
>
<Settings size={12} />
</button>
</div>
{detailSettingsOpen && (
<div className="space-y-0.5 border-l-2 border-panel-border-input pl-2.5">
{(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map(
renderDetailSlider,
)}
</div>
)}
</div>
<div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Effects
</div>
{EFFECT_SLIDERS.map((slider) => {
const value = grading.effects[slider.key];
const isSet = value > 1e-6;
return (
<div key={slider.key} data-flat-grade-effect="true">
<FlatSlider
label={slider.label}
value={Math.round(value * 100)}
min={0}
max={100}
tier={isSet ? "explicitCustom" : "default"}
displayValue={`${Math.round(value * 100)}%`}
onCommit={(next) =>
onCommitColorGrading({
...grading,
effects: { ...grading.effects, [slider.key]: next / 100 },
})
}
onReset={() =>
onCommitColorGrading({
...grading,
effects: { ...grading.effects, [slider.key]: 0 },
})
}
/>
</div>
);
})}
</div>
{onApplyScopeAvailable && (
<div className="flex items-center justify-between gap-2 border-t border-panel-hairline pt-1.5">
<span className="flex items-center gap-1.5 text-[11px] text-panel-text-2">
Copy grade to
<select
value={applyScope}
onChange={(e) => onSetApplyScope(e.target.value as "source-file" | "project")}
disabled={applyBusy}
className="bg-transparent font-mono text-[11px] text-panel-text-0 outline-none disabled:opacity-50"
>
<option value="source-file">Current file media</option>
<option value="project">All project media</option>
</select>
</span>
<button
type="button"
data-flat-grade-apply="true"
disabled={applyBusy}
onClick={onApplyToScope}
className="text-[11px] font-medium text-panel-accent hover:text-panel-accent/80 disabled:cursor-not-allowed disabled:opacity-50"
>
{applyBusy ? "Applying" : "Apply"}
</button>
</div>
)}
</div>
);
}
@@ -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(
<FlatSlider
label="Exposure"
value={0}
min={-100}
max={100}
tier="default"
displayValue="+0.00"
centerTick
onCommit={vi.fn()}
/>,
);
expect(withTick.querySelector('[data-flat-slider-center-tick="true"]')).not.toBeNull();
act(() => rootA.unmount());
const { host: withoutTick, root: rootB } = renderInto(
<FlatSlider
label="Layer blur"
value={0}
min={0}
max={100}
tier="default"
displayValue="0px"
onCommit={vi.fn()}
/>,
);
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(
<FlatSlider
label="Contrast"
value={12}
min={-100}
max={100}
tier="explicitCustom"
displayValue="+12%"
centerTick
onReset={onReset}
onCommit={vi.fn()}
/>,
);
const slot = host.querySelector('[data-flat-slider-reset-slot="true"]');
expect(slot).not.toBeNull();
const resetButton = host.querySelector<HTMLButtonElement>('[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(
<FlatSlider
label="Contrast"
value={0}
min={-100}
max={100}
tier="default"
displayValue="0%"
centerTick
onCommit={vi.fn()}
/>,
);
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(
<FlatSlider
label="Opacity"
value={100}
min={0}
max={100}
tier="explicitCustom"
displayValue="100%"
onCommit={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-slider-reset-slot="true"]')).toBeNull();
expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
act(() => root.unmount());
});
});
describe("FlatSelectRow", () => { describe("FlatSelectRow", () => {
it("renders the default tier with no reset button", () => { it("renders the default tier with no reset button", () => {
const { host, root } = renderInto( 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(
<FlatSelectRow
label="Preset"
value="natural-lift"
options={[
{ value: "neutral", label: "Neutral" },
{ value: "natural-lift", label: "Natural Lift" },
{ value: "fresh-pop", label: "Fresh Pop" },
]}
tier="explicitCustom"
onChange={vi.fn()}
/>,
);
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(
<FlatSelectRow
label="Blend"
value="multiply"
options={["normal", "multiply", "screen"]}
tier="explicitCustom"
onChange={vi.fn()}
/>,
);
const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent);
expect(options).toEqual(["normal", "multiply", "screen"]);
act(() => root.unmount());
});
});
describe("FlatToggle", () => { describe("FlatToggle", () => {
it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => { it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => {
const onChange = vi.fn(); const onChange = vi.fn();
@@ -247,6 +247,8 @@ export function FlatSlider({
tier, tier,
displayValue, displayValue,
disabled, disabled,
centerTick,
onReset,
onCommit, onCommit,
}: { }: {
label: string; label: string;
@@ -257,6 +259,8 @@ export function FlatSlider({
tier: "default" | "explicitCustom"; tier: "default" | "explicitCustom";
displayValue: string; displayValue: string;
disabled?: boolean; disabled?: boolean;
centerTick?: boolean;
onReset?: () => void;
onCommit: (nextValue: number) => void; onCommit: (nextValue: number) => void;
}) { }) {
const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100)); 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()); commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
}} }}
> >
{centerTick && (
<div
data-flat-slider-center-tick="true"
className="absolute left-1/2 top-[-1px] h-1 w-px -translate-x-1/2 bg-panel-text-5"
/>
)}
{tier === "explicitCustom" && ( {tier === "explicitCustom" && (
<div <div
data-flat-slider-fill="true" data-flat-slider-fill="true"
@@ -308,6 +318,21 @@ export function FlatSlider({
> >
{displayValue} {displayValue}
</span> </span>
{centerTick && (
<span data-flat-slider-reset-slot="true" className="w-3.5 flex-shrink-0">
{tier === "explicitCustom" && onReset && (
<button
type="button"
data-flat-slider-reset="true"
title="Remove — fall back to default"
onClick={onReset}
className="text-panel-text-3 hover:text-panel-text-1"
>
<RotateCcw size={11} />
</button>
)}
</span>
)}
</div> </div>
); );
} }
@@ -327,12 +352,15 @@ export function FlatSelectRow({
}: { }: {
label: string; label: string;
value: string; value: string;
options: string[]; options: Array<string | { value: string; label: string }>;
tier: PropertyValueTier; tier: PropertyValueTier;
disabled?: boolean; disabled?: boolean;
onChange: (nextValue: string) => void; onChange: (nextValue: string) => void;
onReset?: () => void; onReset?: () => void;
}) { }) {
const normalizedOptions = options.map((option) =>
typeof option === "string" ? { value: option, label: option } : option,
);
return ( return (
<div className="group flex min-h-[30px] items-center justify-between"> <div className="group flex min-h-[30px] items-center justify-between">
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span> <span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
@@ -344,9 +372,9 @@ export function FlatSelectRow({
onChange={(e) => onChange(e.target.value)} 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]}`} 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) => ( {normalizedOptions.map((option) => (
<option key={option} value={option}> <option key={option.value} value={option.value}>
{option} {option.label}
</option> </option>
))} ))}
</select> </select>
@@ -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> = {}): 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<typeof useColorGradingController>) => 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<typeof useColorGradingController> | undefined;
act(() => {
root.render(
React.createElement(HookHost, {
onState: (s: ReturnType<typeof useColorGradingController>) => (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());
});
});
@@ -0,0 +1,401 @@
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";
import {
acceptStudioRuntimeMessage,
postRuntimeControlMessage,
} from "../../player/lib/runtimeProtocol";
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<string, MediaMetadata | null>();
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<void>;
resetGrading: () => void;
}
export function useColorGradingController({
projectId,
element,
previewIframeRef,
onSetAttributeLive,
onApplyScope,
}: {
projectId: string;
element: DomEditSelection;
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
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<RuntimeColorGradingStatus>(() => ({
state: "pending",
message: "Waiting for runtime",
}));
const selectedAssetPath = useMemo(
() => selectedMediaAssetPath(element, projectId),
[element, projectId],
);
const [mediaMetadata, setMediaMetadata] = useState<MediaMetadata | null>(null);
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
const statusTimersRef = useRef<number[]>([]);
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) => {
postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", {
target,
grading: toBridgeColorGrading(nextGrading),
});
},
[previewIframeRef, target],
);
const postCompare = useCallback(
(enabled: boolean) => {
postRuntimeControlMessage(
previewIframeRef?.current?.contentWindow,
"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") return;
if (!acceptStudioRuntimeMessage(data)) return;
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()),
};
}