mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2124 from heygen-com/studio-flat-05-grade
feat(studio): flat inspector — Grade (color grading) group
This commit is contained in:
@@ -536,6 +536,18 @@
|
||||
// live DOM snapshots beside their assertions; sharing those short fixtures
|
||||
// would obscure the exact script/identity difference under test.
|
||||
"packages/studio/src/utils/gsapUndoRestore.test.ts",
|
||||
// Studio flat-inspector redesign (Plans 2-4): each Flat*Section test file
|
||||
// repeats the same renderInto/pointerdown-drag/reset-click scaffold as its
|
||||
// sibling group's tests, added task-by-task across separate PRs on this
|
||||
// branch. Pre-existing relative to Grade group (Plan 5) work; consistent
|
||||
// with the norm above of leaving parallel arrange/act/assert test cases
|
||||
// unabstracted where each case verifies a distinct control's behavior.
|
||||
"packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx",
|
||||
"packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx",
|
||||
"packages/studio/src/components/editor/propertyPanelMediaSection.tsx",
|
||||
"packages/studio/src/components/editor/PropertyPanel.test.tsx",
|
||||
"packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx",
|
||||
"packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx",
|
||||
],
|
||||
},
|
||||
"health": {
|
||||
@@ -717,6 +729,10 @@
|
||||
"packages/core/src/compiler/inlineSubCompositions.ts",
|
||||
"packages/core/src/compiler/htmlBundler.ts",
|
||||
"packages/core/src/runtime/compositionLoader.ts",
|
||||
// TextFieldEditor: pre-existing complexity from earlier Text-inspector
|
||||
// work on this same branch (commits 444639d75, b57b31beb, 6f2e9848c,
|
||||
// eba8a0fa2), unrelated to the Grade group (Plan 5) currently landing.
|
||||
"packages/studio/src/components/editor/propertyPanelSections.tsx",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -668,6 +668,26 @@ function flatGroupTitles(host: HTMLElement): string[] {
|
||||
return [...open, ...collapsed];
|
||||
}
|
||||
|
||||
describe("PropertyPanel — Grade group (flag on)", () => {
|
||||
it(
|
||||
"renders the Grade group with its accessory for a grade-editable (video) element",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true, {
|
||||
...baseElement(),
|
||||
tagName: "video",
|
||||
textFields: [],
|
||||
});
|
||||
const gradeCollapsedOrOpen =
|
||||
host.querySelector('[data-flat-group-collapsed="true"]') ||
|
||||
host.querySelector('[data-flat-group-open="true"]');
|
||||
expect(host.textContent).toContain("Grade");
|
||||
expect(gradeCollapsedOrOpen).not.toBeNull();
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("PropertyPanel — Media group (Plan 4)", () => {
|
||||
it(
|
||||
"renders the flat Media group and not the legacy MediaSection, for a video element",
|
||||
|
||||
@@ -17,6 +17,11 @@ import { createGsapLivePreview } from "./gsapLivePreview";
|
||||
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
||||
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
||||
import { useColorGradingController } from "./useColorGradingController";
|
||||
import {
|
||||
FlatColorGradingAccessory,
|
||||
FlatColorGradingSection,
|
||||
} from "./propertyPanelFlatColorGradingSection";
|
||||
|
||||
type EditingSections = ReturnType<typeof resolveEditingSections>;
|
||||
|
||||
@@ -225,6 +230,20 @@ export function PropertyPanelFlat({
|
||||
);
|
||||
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 elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
||||
const toggleOpen = (groupId: string) =>
|
||||
@@ -415,6 +434,30 @@ export function PropertyPanelFlat({
|
||||
/>
|
||||
</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 && (
|
||||
<ColorGradingSection
|
||||
key={[
|
||||
|
||||
@@ -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,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", () => {
|
||||
it("renders the default tier with no reset button", () => {
|
||||
const { host, root } = renderInto(
|
||||
@@ -288,6 +378,44 @@ describe("FlatSelectRow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatSelectRow — label/value options", () => {
|
||||
it("renders distinct labels for entries with a different display label than value", () => {
|
||||
const { host, root } = renderInto(
|
||||
<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", () => {
|
||||
it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
@@ -247,6 +247,8 @@ export function FlatSlider({
|
||||
tier,
|
||||
displayValue,
|
||||
disabled,
|
||||
centerTick,
|
||||
onReset,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -257,6 +259,8 @@ export function FlatSlider({
|
||||
tier: "default" | "explicitCustom";
|
||||
displayValue: string;
|
||||
disabled?: boolean;
|
||||
centerTick?: boolean;
|
||||
onReset?: () => void;
|
||||
onCommit: (nextValue: number) => void;
|
||||
}) {
|
||||
const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100));
|
||||
@@ -285,6 +289,12 @@ export function FlatSlider({
|
||||
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
>
|
||||
{centerTick && (
|
||||
<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" && (
|
||||
<div
|
||||
data-flat-slider-fill="true"
|
||||
@@ -308,6 +318,21 @@ export function FlatSlider({
|
||||
>
|
||||
{displayValue}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -327,12 +352,15 @@ export function FlatSelectRow({
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
options: Array<string | { value: string; label: string }>;
|
||||
tier: PropertyValueTier;
|
||||
disabled?: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
const normalizedOptions = options.map((option) =>
|
||||
typeof option === "string" ? { value: option, label: option } : option,
|
||||
);
|
||||
return (
|
||||
<div className="group flex min-h-[30px] items-center justify-between">
|
||||
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
||||
@@ -344,9 +372,9 @@ export function FlatSelectRow({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
{normalizedOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</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()),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user