refactor(studio): extract useColorGradingController from ColorGradingSection

This commit is contained in:
Vance Ingalls
2026-07-14 15:50:48 -07:00
parent 5a3abdef36
commit 22b3f07c56
3 changed files with 556 additions and 362 deletions
@@ -1,159 +1,14 @@
import {
useCallback,
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 { type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import { isHfColorGradingActive } from "@hyperframes/core/color-grading";
import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons";
import {
addStudioPendingEditFlushListener,
trackStudioPendingEdit,
} from "../../utils/studioPendingEdits";
import type { DomEditSelection } from "./domEditing";
import { ColorGradingControls } from "./propertyPanelColorGradingControls";
import { stripQueryAndHash } from "./propertyPanelHelpers";
import { Section } from "./propertyPanelPrimitives";
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>();
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" };
}
}
useColorGradingController,
type MediaMetadata,
type RuntimeColorGradingStatus,
} from "./useColorGradingController";
function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
const dotClass =
@@ -289,216 +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<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]);
const {
grading,
compareEnabled,
applyScope,
applyBusy,
runtimeStatus,
mediaMetadata,
commitColorGrading,
commitCompare,
setApplyScope,
applyToScope,
resetGrading,
} = useColorGradingController({
projectId,
element,
previewIframeRef,
onSetAttributeLive,
onApplyScope,
});
return (
<Section
@@ -516,7 +180,7 @@ export function ColorGradingSection({
type="button"
onClick={(event) => {
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"
@@ -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()),
};
}