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
|
// live DOM snapshots beside their assertions; sharing those short fixtures
|
||||||
// would obscure the exact script/identity difference under test.
|
// would obscure the exact script/identity difference under test.
|
||||||
"packages/studio/src/utils/gsapUndoRestore.test.ts",
|
"packages/studio/src/utils/gsapUndoRestore.test.ts",
|
||||||
|
// Studio flat-inspector redesign (Plans 2-4): each Flat*Section test file
|
||||||
|
// repeats the same renderInto/pointerdown-drag/reset-click scaffold as its
|
||||||
|
// sibling group's tests, added task-by-task across separate PRs on this
|
||||||
|
// branch. Pre-existing relative to Grade group (Plan 5) work; consistent
|
||||||
|
// with the norm above of leaving parallel arrange/act/assert test cases
|
||||||
|
// unabstracted where each case verifies a distinct control's behavior.
|
||||||
|
"packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx",
|
||||||
|
"packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx",
|
||||||
|
"packages/studio/src/components/editor/propertyPanelMediaSection.tsx",
|
||||||
|
"packages/studio/src/components/editor/PropertyPanel.test.tsx",
|
||||||
|
"packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx",
|
||||||
|
"packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
@@ -717,6 +729,10 @@
|
|||||||
"packages/core/src/compiler/inlineSubCompositions.ts",
|
"packages/core/src/compiler/inlineSubCompositions.ts",
|
||||||
"packages/core/src/compiler/htmlBundler.ts",
|
"packages/core/src/compiler/htmlBundler.ts",
|
||||||
"packages/core/src/runtime/compositionLoader.ts",
|
"packages/core/src/runtime/compositionLoader.ts",
|
||||||
|
// TextFieldEditor: pre-existing complexity from earlier Text-inspector
|
||||||
|
// work on this same branch (commits 444639d75, b57b31beb, 6f2e9848c,
|
||||||
|
// eba8a0fa2), unrelated to the Grade group (Plan 5) currently landing.
|
||||||
|
"packages/studio/src/components/editor/propertyPanelSections.tsx",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,6 +668,26 @@ function flatGroupTitles(host: HTMLElement): string[] {
|
|||||||
return [...open, ...collapsed];
|
return [...open, ...collapsed];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("PropertyPanel — Grade group (flag on)", () => {
|
||||||
|
it(
|
||||||
|
"renders the Grade group with its accessory for a grade-editable (video) element",
|
||||||
|
async () => {
|
||||||
|
const { host, root } = await renderPanel(true, {
|
||||||
|
...baseElement(),
|
||||||
|
tagName: "video",
|
||||||
|
textFields: [],
|
||||||
|
});
|
||||||
|
const gradeCollapsedOrOpen =
|
||||||
|
host.querySelector('[data-flat-group-collapsed="true"]') ||
|
||||||
|
host.querySelector('[data-flat-group-open="true"]');
|
||||||
|
expect(host.textContent).toContain("Grade");
|
||||||
|
expect(gradeCollapsedOrOpen).not.toBeNull();
|
||||||
|
act(() => root.unmount());
|
||||||
|
},
|
||||||
|
RENDER_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe("PropertyPanel — Media group (Plan 4)", () => {
|
describe("PropertyPanel — Media group (Plan 4)", () => {
|
||||||
it(
|
it(
|
||||||
"renders the flat Media group and not the legacy MediaSection, for a video element",
|
"renders the flat Media group and not the legacy MediaSection, for a video element",
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import { createGsapLivePreview } from "./gsapLivePreview";
|
|||||||
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
|
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
|
||||||
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
||||||
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
||||||
|
import { useColorGradingController } from "./useColorGradingController";
|
||||||
|
import {
|
||||||
|
FlatColorGradingAccessory,
|
||||||
|
FlatColorGradingSection,
|
||||||
|
} from "./propertyPanelFlatColorGradingSection";
|
||||||
|
|
||||||
type EditingSections = ReturnType<typeof resolveEditingSections>;
|
type EditingSections = ReturnType<typeof resolveEditingSections>;
|
||||||
|
|
||||||
@@ -225,6 +230,20 @@ export function PropertyPanelFlat({
|
|||||||
);
|
);
|
||||||
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
|
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Grade group state. Called unconditionally (React rules-of-hooks) even when
|
||||||
|
// sections.colorGrading is false — unlike the legacy ColorGradingSection,
|
||||||
|
// which is only mounted when the section is active, PropertyPanelFlat is not
|
||||||
|
// remounted per-section so the hook must run every render. Shares one state
|
||||||
|
// object between the group's header accessory (compare/status/reset) and its
|
||||||
|
// body (the FlatColorGradingSection controls).
|
||||||
|
const colorGradingController = useColorGradingController({
|
||||||
|
projectId,
|
||||||
|
element,
|
||||||
|
previewIframeRef,
|
||||||
|
onSetAttributeLive,
|
||||||
|
onApplyScope: onApplyColorGradingScope,
|
||||||
|
});
|
||||||
|
|
||||||
const isTextEditable = isTextEditableSelection(element);
|
const isTextEditable = isTextEditableSelection(element);
|
||||||
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
||||||
const toggleOpen = (groupId: string) =>
|
const toggleOpen = (groupId: string) =>
|
||||||
@@ -415,6 +434,30 @@ export function PropertyPanelFlat({
|
|||||||
/>
|
/>
|
||||||
</FlatGroup>
|
</FlatGroup>
|
||||||
)}
|
)}
|
||||||
|
{sections.colorGrading && (
|
||||||
|
<FlatGroup
|
||||||
|
title="Grade"
|
||||||
|
isOpen={openGroupId === "grade" || pinnedGroupIds.includes("grade")}
|
||||||
|
isPinned={pinnedGroupIds.includes("grade")}
|
||||||
|
onToggleOpen={() => toggleOpen("grade")}
|
||||||
|
onTogglePin={() => togglePin("grade")}
|
||||||
|
accessory={<FlatColorGradingAccessory state={colorGradingController} />}
|
||||||
|
summary={`${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`}
|
||||||
|
>
|
||||||
|
<FlatColorGradingSection
|
||||||
|
grading={colorGradingController.grading}
|
||||||
|
assets={assets}
|
||||||
|
onImportAssets={onImportAssets}
|
||||||
|
onCommitColorGrading={colorGradingController.commitColorGrading}
|
||||||
|
applyScope={colorGradingController.applyScope}
|
||||||
|
applyBusy={colorGradingController.applyBusy}
|
||||||
|
onSetApplyScope={colorGradingController.setApplyScope}
|
||||||
|
onApplyToScope={() => void colorGradingController.applyToScope()}
|
||||||
|
onApplyScopeAvailable={Boolean(onApplyColorGradingScope)}
|
||||||
|
mediaMetadata={colorGradingController.mediaMetadata}
|
||||||
|
/>
|
||||||
|
</FlatGroup>
|
||||||
|
)}
|
||||||
{sections.colorGrading && (
|
{sections.colorGrading && (
|
||||||
<ColorGradingSection
|
<ColorGradingSection
|
||||||
key={[
|
key={[
|
||||||
|
|||||||
@@ -1,159 +1,14 @@
|
|||||||
import {
|
import { type PointerEvent as ReactPointerEvent, type RefObject } from "react";
|
||||||
useCallback,
|
import { isHfColorGradingActive } from "@hyperframes/core/color-grading";
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type PointerEvent as ReactPointerEvent,
|
|
||||||
type RefObject,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
HF_COLOR_GRADING_ATTR,
|
|
||||||
isHfColorGradingActive,
|
|
||||||
normalizeHfColorGrading,
|
|
||||||
serializeHfColorGrading,
|
|
||||||
type HfColorGradingTarget,
|
|
||||||
type NormalizedHfColorGrading,
|
|
||||||
} from "@hyperframes/core/color-grading";
|
|
||||||
import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons";
|
import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons";
|
||||||
import {
|
|
||||||
addStudioPendingEditFlushListener,
|
|
||||||
trackStudioPendingEdit,
|
|
||||||
} from "../../utils/studioPendingEdits";
|
|
||||||
import type { DomEditSelection } from "./domEditing";
|
import type { DomEditSelection } from "./domEditing";
|
||||||
import { ColorGradingControls } from "./propertyPanelColorGradingControls";
|
import { ColorGradingControls } from "./propertyPanelColorGradingControls";
|
||||||
import { stripQueryAndHash } from "./propertyPanelHelpers";
|
|
||||||
import { Section } from "./propertyPanelPrimitives";
|
import { Section } from "./propertyPanelPrimitives";
|
||||||
import {
|
import {
|
||||||
acceptStudioRuntimeMessage,
|
useColorGradingController,
|
||||||
postRuntimeControlMessage,
|
type MediaMetadata,
|
||||||
} from "../../player/lib/runtimeProtocol";
|
type RuntimeColorGradingStatus,
|
||||||
|
} from "./useColorGradingController";
|
||||||
const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
|
|
||||||
const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
|
|
||||||
const MEDIA_METADATA_CACHE = new Map<string, MediaMetadata | null>();
|
|
||||||
|
|
||||||
interface RuntimeColorGradingStatus {
|
|
||||||
state: "missing" | "inactive" | "pending" | "active" | "unavailable";
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaMetadata {
|
|
||||||
kind: "video" | "image" | "audio" | "unknown";
|
|
||||||
color: {
|
|
||||||
dynamicRange: "hdr" | "sdr" | "unknown";
|
|
||||||
hdrTransfer: "pq" | "hlg" | "unknown" | null;
|
|
||||||
label: string;
|
|
||||||
isHdr: boolean;
|
|
||||||
codecName?: string;
|
|
||||||
profile?: string;
|
|
||||||
pixelFormat?: string;
|
|
||||||
colorSpace?: string;
|
|
||||||
colorTransfer?: string;
|
|
||||||
colorPrimaries?: string;
|
|
||||||
};
|
|
||||||
probeError?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaMetadataResponse {
|
|
||||||
path: string;
|
|
||||||
metadata: MediaMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripPreviewAssetPath(src: string, projectId: string): string | null {
|
|
||||||
let pathname = src;
|
|
||||||
try {
|
|
||||||
pathname = new URL(src, window.location.href).pathname;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`;
|
|
||||||
const genericMarker = "/preview/";
|
|
||||||
const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker;
|
|
||||||
const index = pathname.indexOf(marker);
|
|
||||||
if (index < 0) return null;
|
|
||||||
const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, "");
|
|
||||||
if (!assetPath || assetPath.startsWith("comp/")) return null;
|
|
||||||
return assetPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
|
||||||
function resolveProjectAssetPath(
|
|
||||||
sourceFile: string,
|
|
||||||
src: string,
|
|
||||||
projectId: string,
|
|
||||||
): string | null {
|
|
||||||
const trimmed = stripQueryAndHash(src.trim());
|
|
||||||
if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null;
|
|
||||||
if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId);
|
|
||||||
if (trimmed.startsWith("/")) {
|
|
||||||
return stripPreviewAssetPath(trimmed, projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceDir = sourceFile.includes("/")
|
|
||||||
? sourceFile.slice(0, sourceFile.lastIndexOf("/"))
|
|
||||||
: "";
|
|
||||||
const parts = `${sourceDir}/${trimmed}`.split("/");
|
|
||||||
const normalized: string[] = [];
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!part || part === ".") continue;
|
|
||||||
if (part === "..") {
|
|
||||||
normalized.pop();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
normalized.push(part);
|
|
||||||
}
|
|
||||||
return normalized.join("/") || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null {
|
|
||||||
if (element.tagName !== "video" && element.tagName !== "img") return null;
|
|
||||||
const media = element.element as HTMLImageElement | HTMLVideoElement;
|
|
||||||
const src = media.getAttribute("src") || media.currentSrc || "";
|
|
||||||
return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultColorGrading(): NormalizedHfColorGrading {
|
|
||||||
const grading = normalizeHfColorGrading("neutral");
|
|
||||||
if (!grading) throw new Error("Missing neutral color grading preset");
|
|
||||||
return grading;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
|
|
||||||
return (
|
|
||||||
normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown {
|
|
||||||
if (!isHfColorGradingActive(grading)) return null;
|
|
||||||
const { enabled: _enabled, ...bridgeGrading } = grading;
|
|
||||||
return bridgeGrading;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRuntimeColorGradingStatus(
|
|
||||||
iframe: HTMLIFrameElement | null | undefined,
|
|
||||||
target: HfColorGradingTarget,
|
|
||||||
): RuntimeColorGradingStatus {
|
|
||||||
try {
|
|
||||||
const win = iframe?.contentWindow as
|
|
||||||
| (Window & {
|
|
||||||
__hf?: {
|
|
||||||
colorGrading?: {
|
|
||||||
getStatus?: (
|
|
||||||
target: HfColorGradingTarget | string | null | undefined,
|
|
||||||
) => RuntimeColorGradingStatus;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
})
|
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
const status = win?.__hf?.colorGrading?.getStatus?.(target);
|
|
||||||
return status ?? { state: "pending", message: "Waiting for runtime" };
|
|
||||||
} catch {
|
|
||||||
return { state: "unavailable", message: "Preview unavailable" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
|
function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
|
||||||
const dotClass =
|
const dotClass =
|
||||||
@@ -289,216 +144,25 @@ export function ColorGradingSection({
|
|||||||
value: string | null,
|
value: string | null,
|
||||||
) => Promise<{ changedFiles: number; changedElements: number }>;
|
) => Promise<{ changedFiles: number; changedElements: number }>;
|
||||||
}) {
|
}) {
|
||||||
const [grading, setGrading] = useState(() => readColorGradingFromElement(element));
|
const {
|
||||||
const [compareEnabled, setCompareEnabled] = useState(false);
|
grading,
|
||||||
const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file");
|
compareEnabled,
|
||||||
const [applyBusy, setApplyBusy] = useState(false);
|
applyScope,
|
||||||
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeColorGradingStatus>(() => ({
|
applyBusy,
|
||||||
state: "pending",
|
runtimeStatus,
|
||||||
message: "Waiting for runtime",
|
mediaMetadata,
|
||||||
}));
|
commitColorGrading,
|
||||||
const selectedAssetPath = useMemo(
|
commitCompare,
|
||||||
() => selectedMediaAssetPath(element, projectId),
|
setApplyScope,
|
||||||
[element, projectId],
|
applyToScope,
|
||||||
);
|
resetGrading,
|
||||||
const [mediaMetadata, setMediaMetadata] = useState<MediaMetadata | null>(null);
|
} = useColorGradingController({
|
||||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
projectId,
|
||||||
const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
|
element,
|
||||||
const statusTimersRef = useRef<number[]>([]);
|
previewIframeRef,
|
||||||
const onSetAttributeLiveRef = useRef(onSetAttributeLive);
|
onSetAttributeLive,
|
||||||
const latestGradingRef = useRef(grading);
|
onApplyScope,
|
||||||
const compareEnabledRef = useRef(compareEnabled);
|
});
|
||||||
onSetAttributeLiveRef.current = onSetAttributeLive;
|
|
||||||
latestGradingRef.current = grading;
|
|
||||||
compareEnabledRef.current = compareEnabled;
|
|
||||||
const target = useMemo(
|
|
||||||
(): HfColorGradingTarget => ({
|
|
||||||
id: element.id ?? null,
|
|
||||||
hfId: element.hfId ?? null,
|
|
||||||
selector: element.selector ?? null,
|
|
||||||
selectorIndex: element.selectorIndex ?? null,
|
|
||||||
}),
|
|
||||||
[element.hfId, element.id, element.selector, element.selectorIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshRuntimeStatus = useCallback(() => {
|
|
||||||
setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target));
|
|
||||||
}, [previewIframeRef, target]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMediaMetadata(null);
|
|
||||||
if (!selectedAssetPath) return;
|
|
||||||
const cacheKey = `${projectId}:${selectedAssetPath}`;
|
|
||||||
if (MEDIA_METADATA_CACHE.has(cacheKey)) {
|
|
||||||
setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const controller = new AbortController();
|
|
||||||
fetch(
|
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent(
|
|
||||||
selectedAssetPath,
|
|
||||||
)}`,
|
|
||||||
{ signal: controller.signal },
|
|
||||||
)
|
|
||||||
.then((response) => (response.ok ? response.json() : null))
|
|
||||||
.then((data: MediaMetadataResponse | null) => {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
const metadata = data?.metadata ?? null;
|
|
||||||
MEDIA_METADATA_CACHE.set(cacheKey, metadata);
|
|
||||||
setMediaMetadata(metadata);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null);
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [projectId, selectedAssetPath]);
|
|
||||||
|
|
||||||
const clearStatusTimers = useCallback(() => {
|
|
||||||
for (const timer of statusTimersRef.current) clearTimeout(timer);
|
|
||||||
statusTimersRef.current = [];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scheduleRuntimeStatusRefresh = useCallback(() => {
|
|
||||||
clearStatusTimers();
|
|
||||||
statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) =>
|
|
||||||
window.setTimeout(refreshRuntimeStatus, delay),
|
|
||||||
);
|
|
||||||
}, [clearStatusTimers, refreshRuntimeStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshRuntimeStatus();
|
|
||||||
}, [refreshRuntimeStatus]);
|
|
||||||
|
|
||||||
const persistColorGradingValue = useCallback((value: string | null) => {
|
|
||||||
return trackStudioPendingEdit(
|
|
||||||
onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const flushPendingPersist = useCallback(() => {
|
|
||||||
if (persistTimerRef.current) {
|
|
||||||
clearTimeout(persistTimerRef.current);
|
|
||||||
persistTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (pendingPersistValueRef.current === undefined) return undefined;
|
|
||||||
const value = pendingPersistValueRef.current;
|
|
||||||
pendingPersistValueRef.current = undefined;
|
|
||||||
return persistColorGradingValue(value);
|
|
||||||
}, [persistColorGradingValue]);
|
|
||||||
|
|
||||||
useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearStatusTimers();
|
|
||||||
void flushPendingPersist();
|
|
||||||
};
|
|
||||||
}, [clearStatusTimers, flushPendingPersist]);
|
|
||||||
|
|
||||||
const postColorGrading = useCallback(
|
|
||||||
(nextGrading: NormalizedHfColorGrading) => {
|
|
||||||
postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", {
|
|
||||||
target,
|
|
||||||
grading: toBridgeColorGrading(nextGrading),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[previewIframeRef, target],
|
|
||||||
);
|
|
||||||
|
|
||||||
const postCompare = useCallback(
|
|
||||||
(enabled: boolean) => {
|
|
||||||
postRuntimeControlMessage(
|
|
||||||
previewIframeRef?.current?.contentWindow,
|
|
||||||
"set-color-grading-compare",
|
|
||||||
{
|
|
||||||
target,
|
|
||||||
compare: { enabled, position: 1, lineWidth: 0 },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[previewIframeRef, target],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const iframe = previewIframeRef?.current;
|
|
||||||
if (!iframe) return;
|
|
||||||
const refreshAndReplay = () => {
|
|
||||||
const nextGrading = latestGradingRef.current;
|
|
||||||
const active = isHfColorGradingActive(nextGrading);
|
|
||||||
if (active) postColorGrading(nextGrading);
|
|
||||||
postCompare(compareEnabledRef.current && active);
|
|
||||||
scheduleRuntimeStatusRefresh();
|
|
||||||
};
|
|
||||||
const onMessage = (event: MessageEvent) => {
|
|
||||||
if (event.source !== iframe.contentWindow) return;
|
|
||||||
const data = event.data as { source?: unknown; type?: unknown } | null;
|
|
||||||
if (data?.source !== "hf-preview" || data.type !== "ready") return;
|
|
||||||
if (!acceptStudioRuntimeMessage(data)) return;
|
|
||||||
refreshAndReplay();
|
|
||||||
};
|
|
||||||
iframe.addEventListener("load", refreshAndReplay);
|
|
||||||
window.addEventListener("message", onMessage);
|
|
||||||
const timer = window.setTimeout(refreshAndReplay, 80);
|
|
||||||
return () => {
|
|
||||||
iframe.removeEventListener("load", refreshAndReplay);
|
|
||||||
window.removeEventListener("message", onMessage);
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
postCompare(false);
|
|
||||||
},
|
|
||||||
[postCompare],
|
|
||||||
);
|
|
||||||
|
|
||||||
const commitColorGrading = useCallback(
|
|
||||||
(nextGrading: NormalizedHfColorGrading) => {
|
|
||||||
setGrading(nextGrading);
|
|
||||||
setRuntimeStatus({ state: "pending", message: "Updating shader" });
|
|
||||||
postColorGrading(nextGrading);
|
|
||||||
const active = isHfColorGradingActive(nextGrading);
|
|
||||||
if (compareEnabledRef.current) {
|
|
||||||
postCompare(active);
|
|
||||||
if (!active) setCompareEnabled(false);
|
|
||||||
}
|
|
||||||
scheduleRuntimeStatusRefresh();
|
|
||||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
|
||||||
pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
|
|
||||||
? serializeHfColorGrading(nextGrading)
|
|
||||||
: null;
|
|
||||||
persistTimerRef.current = setTimeout(() => {
|
|
||||||
const value = pendingPersistValueRef.current;
|
|
||||||
pendingPersistValueRef.current = undefined;
|
|
||||||
persistTimerRef.current = null;
|
|
||||||
void persistColorGradingValue(value ?? null);
|
|
||||||
}, 350);
|
|
||||||
},
|
|
||||||
[persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
|
|
||||||
);
|
|
||||||
|
|
||||||
const commitCompare = useCallback(
|
|
||||||
(enabled: boolean) => {
|
|
||||||
const nextEnabled = enabled && isHfColorGradingActive(grading);
|
|
||||||
setCompareEnabled(nextEnabled);
|
|
||||||
if (nextEnabled) postColorGrading(grading);
|
|
||||||
postCompare(nextEnabled);
|
|
||||||
scheduleRuntimeStatusRefresh();
|
|
||||||
},
|
|
||||||
[grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyToScope = useCallback(async () => {
|
|
||||||
if (!onApplyScope || applyBusy) return;
|
|
||||||
setApplyBusy(true);
|
|
||||||
try {
|
|
||||||
const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null;
|
|
||||||
await onApplyScope(applyScope, value);
|
|
||||||
} finally {
|
|
||||||
setApplyBusy(false);
|
|
||||||
}
|
|
||||||
}, [applyBusy, applyScope, grading, onApplyScope]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@@ -516,7 +180,7 @@ export function ColorGradingSection({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
commitColorGrading(defaultColorGrading());
|
resetGrading();
|
||||||
}}
|
}}
|
||||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1"
|
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1"
|
||||||
title="Reset color grading"
|
title="Reset color grading"
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
FlatColorGradingAccessory,
|
||||||
|
FlatColorGradingSection,
|
||||||
|
} from "./propertyPanelFlatColorGradingSection";
|
||||||
|
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
||||||
|
|
||||||
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderInto(node: React.ReactElement) {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
act(() => {
|
||||||
|
root.render(node);
|
||||||
|
});
|
||||||
|
return { host, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
function neutralGrading() {
|
||||||
|
const grading = normalizeHfColorGrading("neutral");
|
||||||
|
if (!grading) throw new Error("expected a neutral grading");
|
||||||
|
return grading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRowByText(
|
||||||
|
host: HTMLElement,
|
||||||
|
selector: string,
|
||||||
|
text: string,
|
||||||
|
match: "includes" | "startsWith" = "includes",
|
||||||
|
) {
|
||||||
|
const row = Array.from(host.querySelectorAll(selector)).find((el) =>
|
||||||
|
el.textContent?.[match](text),
|
||||||
|
);
|
||||||
|
if (!row) throw new Error(`expected a ${text} row`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragSliderTrack(row: Element, clientX: number, trackWidth: number) {
|
||||||
|
const track = row.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
|
||||||
|
if (!track) throw new Error("expected a slider track");
|
||||||
|
Object.defineProperty(track, "getBoundingClientRect", {
|
||||||
|
value: () => ({ left: 0, width: trackWidth, top: 0, height: 2, right: trackWidth, bottom: 2 }),
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickSliderReset(row: Element) {
|
||||||
|
const resetButton = row.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
|
||||||
|
expect(resetButton).not.toBeNull();
|
||||||
|
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FlatColorGradingAccessory", () => {
|
||||||
|
it("shows a 5px status dot colored by runtime status, with the message as its title", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingAccessory
|
||||||
|
state={{
|
||||||
|
grading: neutralGrading(),
|
||||||
|
compareEnabled: false,
|
||||||
|
runtimeStatus: { state: "active", message: "Shader active" },
|
||||||
|
commitCompare: vi.fn(),
|
||||||
|
resetGrading: vi.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const dot = host.querySelector('[data-flat-grade-status-dot="true"]');
|
||||||
|
expect(dot).not.toBeNull();
|
||||||
|
expect(dot?.getAttribute("title")).toBe("Shader active");
|
||||||
|
expect(dot?.className).toContain("bg-emerald-400");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the compare hold button when grading is inactive, and fires resetGrading on click", () => {
|
||||||
|
const resetGrading = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingAccessory
|
||||||
|
state={{
|
||||||
|
grading: neutralGrading(),
|
||||||
|
compareEnabled: false,
|
||||||
|
runtimeStatus: { state: "inactive", message: "No grading applied" },
|
||||||
|
commitCompare: vi.fn(),
|
||||||
|
resetGrading,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const compareButton = host.querySelector<HTMLButtonElement>(
|
||||||
|
'[aria-label="Hold to show original"]',
|
||||||
|
);
|
||||||
|
expect(compareButton?.disabled).toBe(true);
|
||||||
|
const resetButton = host.querySelector<HTMLButtonElement>('[data-flat-grade-reset="true"]');
|
||||||
|
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(resetGrading).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function neutralPropsBase() {
|
||||||
|
return {
|
||||||
|
grading: neutralGrading(),
|
||||||
|
assets: [] as string[],
|
||||||
|
onCommitColorGrading: vi.fn(),
|
||||||
|
applyScope: "source-file" as const,
|
||||||
|
applyBusy: false,
|
||||||
|
onSetApplyScope: vi.fn(),
|
||||||
|
onApplyToScope: vi.fn(),
|
||||||
|
onApplyScopeAvailable: true,
|
||||||
|
mediaMetadata: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FlatColorGradingSection — Preset + LUT", () => {
|
||||||
|
it("renders the Preset dropdown with id/label pairs and fires onCommitColorGrading on change", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const presetSelect = host.querySelector<HTMLSelectElement>(
|
||||||
|
'[data-flat-grade-preset="true"] select',
|
||||||
|
);
|
||||||
|
if (!presetSelect) throw new Error("expected a preset select");
|
||||||
|
expect(presetSelect.value).toBe("neutral");
|
||||||
|
act(() => {
|
||||||
|
presetSelect.value = "fresh-pop";
|
||||||
|
presetSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("fresh-pop");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Custom LUT row collapsed by default, expanding to reveal the strength slider when a LUT is set", () => {
|
||||||
|
const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.8 } };
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection {...neutralPropsBase()} grading={grading} />,
|
||||||
|
);
|
||||||
|
const lutToggle = host.querySelector<HTMLButtonElement>('[data-flat-grade-lut-toggle="true"]');
|
||||||
|
expect(lutToggle).not.toBeNull();
|
||||||
|
act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(host.textContent).toContain("warm.cube");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits the selected catalog LUT via the select control, resetting intensity to 1 when switching LUTs", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.5 } };
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
assets={["assets/luts/warm.cube", "assets/luts/cool.cube"]}
|
||||||
|
grading={grading}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const lutToggle = host.querySelector<HTMLButtonElement>('[data-flat-grade-lut-toggle="true"]');
|
||||||
|
act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
const lutSelect = host.querySelector<HTMLSelectElement>('[data-flat-grade-lut-select="true"]');
|
||||||
|
if (!lutSelect) throw new Error("expected a LUT catalog select");
|
||||||
|
act(() => {
|
||||||
|
lutSelect.value = "assets/luts/cool.cube";
|
||||||
|
lutSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].lut).toEqual({
|
||||||
|
src: "assets/luts/cool.cube",
|
||||||
|
intensity: 1,
|
||||||
|
});
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports a LUT via the hidden file input and commits the resolved asset", async () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const onImportAssets = vi.fn().mockResolvedValue(["assets/luts/x.cube"]);
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
onImportAssets={onImportAssets}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const lutToggle = host.querySelector<HTMLButtonElement>('[data-flat-grade-lut-toggle="true"]');
|
||||||
|
act(() => lutToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
const fileInput = host.querySelector<HTMLInputElement>('input[type="file"]');
|
||||||
|
if (!fileInput) throw new Error("expected a hidden file input");
|
||||||
|
const file = new File(["cube data"], "x.cube");
|
||||||
|
Object.defineProperty(fileInput, "files", { value: [file], configurable: true });
|
||||||
|
await act(async () => {
|
||||||
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(onImportAssets).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onImportAssets.mock.calls[0][0]).toEqual([file]);
|
||||||
|
expect(onImportAssets.mock.calls[0][1]).toBe("assets/luts");
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].lut).toEqual({
|
||||||
|
src: "assets/luts/x.cube",
|
||||||
|
intensity: 1,
|
||||||
|
});
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FlatColorGradingSection — Adjust sliders", () => {
|
||||||
|
it("renders all 10 adjust rows with a center tick, formatting exposure distinctly from percentage sliders", () => {
|
||||||
|
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
||||||
|
const adjustRows = host.querySelectorAll('[data-flat-grade-adjust="true"]');
|
||||||
|
expect(adjustRows).toHaveLength(10);
|
||||||
|
for (const row of Array.from(adjustRows)) {
|
||||||
|
expect(row.querySelector('[data-flat-slider-center-tick="true"]')).not.toBeNull();
|
||||||
|
}
|
||||||
|
expect(host.textContent).toContain("+0.00");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits an adjust change scaled correctly and shows a reset when non-neutral", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const grading = { ...neutralGrading(), adjust: { ...neutralGrading().adjust, contrast: 0.12 } };
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
grading={grading}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast");
|
||||||
|
clickSliderReset(contrastRow);
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a dragged contrast value on slider track pointerdown, scaled from percent back to the internal -1..1 range", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast");
|
||||||
|
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 50/100 = 0.5
|
||||||
|
dragSliderTrack(contrastRow, 75, 100);
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0.5);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a dragged exposure value scaled into stops, keeping other adjust keys untouched", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const grading = {
|
||||||
|
...neutralGrading(),
|
||||||
|
adjust: { ...neutralGrading().adjust, saturation: 0.2 },
|
||||||
|
};
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
grading={grading}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const exposureRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Exposure");
|
||||||
|
// min=-200, max=200, step=5, ratio=1.0 -> raw=200 -> commit(200) -> adjust.exposure = 200/100 = 2
|
||||||
|
dragSliderTrack(exposureRow, 200, 200);
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].adjust.exposure).toBe(2);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FlatColorGradingSection — Vignette and Grain", () => {
|
||||||
|
it("renders Vignette and Grain amount rows with a settings gear, expanding tuned sliders on click", () => {
|
||||||
|
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
||||||
|
const vignetteGear = host.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-flat-grade-settings="vignette"]',
|
||||||
|
);
|
||||||
|
expect(vignetteGear).not.toBeNull();
|
||||||
|
act(() => vignetteGear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(host.textContent).toContain("Midpoint");
|
||||||
|
expect(host.textContent).toContain("Feather");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows tuned Midpoint at its 50% default with no reset until moved from default", () => {
|
||||||
|
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
||||||
|
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
|
||||||
|
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
const midpointRow = findRowByText(host, "div", "Midpoint", "startsWith");
|
||||||
|
expect(midpointRow.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a dragged Roundness value on slider track pointerdown, scaled from percent back into the -1..1 detail range", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
|
||||||
|
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith");
|
||||||
|
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> details.vignetteRoundness = 50/100 = 0.5
|
||||||
|
dragSliderTrack(roundnessRow, 75, 100);
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0.5);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets a non-default Roundness back to its 0 default via the tuned slider's reset button", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const grading = {
|
||||||
|
...neutralGrading(),
|
||||||
|
details: { ...neutralGrading().details, vignetteRoundness: 0.4 },
|
||||||
|
};
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
grading={grading}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
|
||||||
|
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith");
|
||||||
|
clickSliderReset(roundnessRow);
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FlatColorGradingSection — Effects", () => {
|
||||||
|
it("renders Blur and Pixelate sliders under an Effects micro-label", () => {
|
||||||
|
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
||||||
|
expect(host.textContent).toContain("Effects");
|
||||||
|
const rows = host.querySelectorAll('[data-flat-grade-effect="true"]');
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a dragged Pixelate value on slider track pointerdown, scaled from percent to the 0..1 effect range", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const pixelateRow = findRowByText(host, '[data-flat-grade-effect="true"]', "Pixelate");
|
||||||
|
// min=0, max=100, step=1, ratio=0.75 -> raw=75 -> commit(75) -> effects.pixelate = 75/100 = 0.75
|
||||||
|
dragSliderTrack(pixelateRow, 75, 100);
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].effects.pixelate).toBe(0.75);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FlatColorGradingSection — HDR banner and Apply scope", () => {
|
||||||
|
it("shows the HDR banner only when mediaMetadata reports an HDR source", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
mediaMetadata={{
|
||||||
|
kind: "video",
|
||||||
|
color: { dynamicRange: "hdr", hdrTransfer: "pq", label: "HDR10", isHdr: true },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(host.textContent).toContain("SDR preview");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the HDR banner for SDR media", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
mediaMetadata={{
|
||||||
|
kind: "video",
|
||||||
|
color: { dynamicRange: "sdr", hdrTransfer: null, label: "SDR", isHdr: false },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(host.textContent).not.toContain("SDR preview");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onApplyToScope from the Apply button, respecting applyBusy", () => {
|
||||||
|
const onApplyToScope = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection {...neutralPropsBase()} onApplyToScope={onApplyToScope} applyBusy />,
|
||||||
|
);
|
||||||
|
const applyButton = host.querySelector<HTMLButtonElement>('[data-flat-grade-apply="true"]');
|
||||||
|
expect(applyButton?.disabled).toBe(true);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onApplyToScope exactly once when the Apply button is clicked while not busy", () => {
|
||||||
|
const onApplyToScope = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
onApplyToScope={onApplyToScope}
|
||||||
|
applyBusy={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const applyButton = host.querySelector<HTMLButtonElement>('[data-flat-grade-apply="true"]');
|
||||||
|
expect(applyButton?.disabled).toBe(false);
|
||||||
|
act(() => applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onApplyToScope).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
HF_COLOR_GRADING_PRESETS,
|
||||||
|
isHfColorGradingActive,
|
||||||
|
normalizeHfColorGrading,
|
||||||
|
type HfColorGradingAdjustKey,
|
||||||
|
type HfColorGradingDetailKey,
|
||||||
|
type HfColorGradingEffectKey,
|
||||||
|
type NormalizedHfColorGrading,
|
||||||
|
} from "@hyperframes/core/color-grading";
|
||||||
|
import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
|
||||||
|
import { LUT_EXT } from "../../utils/mediaTypes";
|
||||||
|
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
|
||||||
|
import { resolveValueTier } from "./propertyPanelValueTier";
|
||||||
|
import type { ColorGradingControllerState, MediaMetadata } from "./useColorGradingController";
|
||||||
|
|
||||||
|
const STATUS_DOT_CLASS: Record<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
|
||||||
|
active: "bg-emerald-400",
|
||||||
|
pending: "bg-amber-300",
|
||||||
|
unavailable: "bg-red-400",
|
||||||
|
missing: "bg-panel-text-5",
|
||||||
|
inactive: "bg-panel-text-5",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FlatColorGradingAccessory({
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
state: Pick<
|
||||||
|
ColorGradingControllerState,
|
||||||
|
"grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading"
|
||||||
|
>;
|
||||||
|
}) {
|
||||||
|
const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state;
|
||||||
|
const gradingActive = isHfColorGradingActive(grading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={compareEnabled}
|
||||||
|
aria-label="Hold to show original"
|
||||||
|
disabled={!gradingActive}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (!gradingActive) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
commitCompare(true);
|
||||||
|
const release = () => {
|
||||||
|
commitCompare(false);
|
||||||
|
window.removeEventListener("pointerup", release);
|
||||||
|
window.removeEventListener("pointercancel", release);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerup", release);
|
||||||
|
window.addEventListener("pointercancel", release);
|
||||||
|
}}
|
||||||
|
title="Hold to show original"
|
||||||
|
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Compare size={12} />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
data-flat-grade-status-dot="true"
|
||||||
|
title={runtimeStatus.message}
|
||||||
|
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-grade-reset="true"
|
||||||
|
title="Reset color grading"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
resetGrading();
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_OPTIONS = HF_COLOR_GRADING_PRESETS.map((p) => ({ value: p.id, label: p.label }));
|
||||||
|
|
||||||
|
const ADJUST_SLIDERS: Array<{
|
||||||
|
key: HfColorGradingAdjustKey;
|
||||||
|
label: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
}> = [
|
||||||
|
{ key: "exposure", label: "Exposure", min: -200, max: 200, step: 5 },
|
||||||
|
{ key: "contrast", label: "Contrast", min: -100, max: 100, step: 1 },
|
||||||
|
{ key: "highlights", label: "Highlights", min: -100, max: 100, step: 1 },
|
||||||
|
{ key: "shadows", label: "Shadows", min: -100, max: 100, step: 1 },
|
||||||
|
{ key: "whites", label: "White Point", min: -100, max: 100, step: 1 },
|
||||||
|
{ key: "blacks", label: "Black Point", min: -100, max: 100, step: 1 },
|
||||||
|
{ key: "temperature", label: "Warmth", min: -100, max: 100, step: 1 },
|
||||||
|
{ key: "tint", label: "Tint", min: -100, max: 100, step: 1 },
|
||||||
|
{ key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1 },
|
||||||
|
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
|
||||||
|
if (key === "exposure") {
|
||||||
|
const stops = rawPercent / 100;
|
||||||
|
return `${stops >= 0 ? "+" : ""}${stops.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
return `${Math.round(rawPercent)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL_SLIDERS: Array<{
|
||||||
|
key: HfColorGradingDetailKey;
|
||||||
|
label: string;
|
||||||
|
defaultValue: number;
|
||||||
|
}> = [
|
||||||
|
{ key: "vignette", label: "Vignette", defaultValue: 0 },
|
||||||
|
{ key: "vignetteMidpoint", label: "Midpoint", defaultValue: 0.5 },
|
||||||
|
{ key: "vignetteRoundness", label: "Roundness", defaultValue: 0 },
|
||||||
|
{ key: "vignetteFeather", label: "Feather", defaultValue: 0.65 },
|
||||||
|
{ key: "grain", label: "Grain", defaultValue: 0 },
|
||||||
|
{ key: "grainSize", label: "Grain Size", defaultValue: 0.25 },
|
||||||
|
{ key: "grainRoughness", label: "Roughness", defaultValue: 0.5 },
|
||||||
|
];
|
||||||
|
const detailByKey = (key: HfColorGradingDetailKey) => {
|
||||||
|
const spec = DETAIL_SLIDERS.find((d) => d.key === key);
|
||||||
|
if (!spec) throw new Error(`Unknown color grading detail key: ${key}`);
|
||||||
|
return spec;
|
||||||
|
};
|
||||||
|
const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [
|
||||||
|
"vignetteMidpoint",
|
||||||
|
"vignetteRoundness",
|
||||||
|
"vignetteFeather",
|
||||||
|
];
|
||||||
|
const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"];
|
||||||
|
|
||||||
|
const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string }> = [
|
||||||
|
{ key: "blur", label: "Blur" },
|
||||||
|
{ key: "pixelate", label: "Pixelate" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
|
||||||
|
if (metadata?.color.dynamicRange !== "hdr") return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-flat-grade-hdr-banner="true"
|
||||||
|
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1.5 text-[10px] leading-4 text-amber-100"
|
||||||
|
>
|
||||||
|
<div className="mb-0.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold">{metadata.color.label} source</span>
|
||||||
|
<span className="rounded bg-amber-400/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-100">
|
||||||
|
SDR preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-amber-100/80">
|
||||||
|
These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this
|
||||||
|
is not true HDR color grading yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
export function FlatColorGradingSection({
|
||||||
|
grading,
|
||||||
|
assets,
|
||||||
|
onImportAssets,
|
||||||
|
onCommitColorGrading,
|
||||||
|
applyScope,
|
||||||
|
applyBusy,
|
||||||
|
onSetApplyScope,
|
||||||
|
onApplyToScope,
|
||||||
|
onApplyScopeAvailable,
|
||||||
|
mediaMetadata,
|
||||||
|
}: {
|
||||||
|
grading: NormalizedHfColorGrading;
|
||||||
|
assets: string[];
|
||||||
|
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
||||||
|
onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
|
||||||
|
applyScope: "source-file" | "project";
|
||||||
|
applyBusy: boolean;
|
||||||
|
onSetApplyScope: (scope: "source-file" | "project") => void;
|
||||||
|
onApplyToScope: () => void;
|
||||||
|
onApplyScopeAvailable: boolean;
|
||||||
|
mediaMetadata: MediaMetadata | null;
|
||||||
|
}) {
|
||||||
|
const lutInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [lutOpen, setLutOpen] = useState(false);
|
||||||
|
const [detailSettingsOpen, setDetailSettingsOpen] = useState<"vignette" | "grain" | null>(null);
|
||||||
|
const lutAssets = useMemo(
|
||||||
|
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
|
||||||
|
[assets],
|
||||||
|
);
|
||||||
|
const lut = grading.lut;
|
||||||
|
const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null;
|
||||||
|
|
||||||
|
const applyPreset = (presetId: string) => {
|
||||||
|
const next = normalizeHfColorGrading({ preset: presetId, intensity: 1, lut: grading.lut });
|
||||||
|
if (next) onCommitColorGrading(next);
|
||||||
|
};
|
||||||
|
const updateIntensity = (value: number) => {
|
||||||
|
onCommitColorGrading({ ...grading, intensity: value / 100 });
|
||||||
|
};
|
||||||
|
const applyLut = (src: string | null, intensity = 1) => {
|
||||||
|
onCommitColorGrading({ ...grading, lut: src ? { src, intensity } : null });
|
||||||
|
};
|
||||||
|
const importLuts = async (files: FileList | null) => {
|
||||||
|
if (!files?.length || !onImportAssets) return;
|
||||||
|
const uploaded = await onImportAssets(files, "assets/luts");
|
||||||
|
const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
|
||||||
|
if (firstLut) applyLut(firstLut, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDetailSlider = (key: HfColorGradingDetailKey) => {
|
||||||
|
const spec = detailByKey(key);
|
||||||
|
const value = grading.details[key];
|
||||||
|
const isSet = Math.abs(value - spec.defaultValue) > 1e-4;
|
||||||
|
return (
|
||||||
|
<FlatSlider
|
||||||
|
key={key}
|
||||||
|
label={spec.label}
|
||||||
|
value={Math.round(value * 100)}
|
||||||
|
min={key === "vignetteRoundness" ? -100 : 0}
|
||||||
|
max={100}
|
||||||
|
tier={isSet ? "explicitCustom" : "default"}
|
||||||
|
displayValue={`${Math.round(value * 100)}%`}
|
||||||
|
centerTick={key === "vignetteRoundness"}
|
||||||
|
onCommit={(next) =>
|
||||||
|
onCommitColorGrading({ ...grading, details: { ...grading.details, [key]: next / 100 } })
|
||||||
|
}
|
||||||
|
onReset={() =>
|
||||||
|
onCommitColorGrading({
|
||||||
|
...grading,
|
||||||
|
details: { ...grading.details, [key]: spec.defaultValue },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<HdrBanner metadata={mediaMetadata} />
|
||||||
|
<div data-flat-grade-preset="true" className="flex min-h-[30px] items-center justify-between">
|
||||||
|
<span className="text-[11px] text-panel-text-2">Preset</span>
|
||||||
|
<FlatSelectRow
|
||||||
|
label=""
|
||||||
|
value={grading.preset ?? "neutral"}
|
||||||
|
options={PRESET_OPTIONS}
|
||||||
|
tier={resolveValueTier(
|
||||||
|
grading.preset === "neutral" ? undefined : (grading.preset ?? undefined),
|
||||||
|
"neutral",
|
||||||
|
)}
|
||||||
|
onChange={applyPreset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FlatSlider
|
||||||
|
label="Strength"
|
||||||
|
value={Math.round(grading.intensity * 100)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
tier={grading.intensity === 1 ? "default" : "explicitCustom"}
|
||||||
|
displayValue={`${Math.round(grading.intensity * 100)}%`}
|
||||||
|
onCommit={updateIntensity}
|
||||||
|
onReset={() => updateIntensity(100)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-panel-hairline pt-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-grade-lut-toggle="true"
|
||||||
|
onClick={() => setLutOpen((v) => !v)}
|
||||||
|
className="flex min-h-[30px] w-full items-center justify-between text-left"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-panel-text-2">Custom LUT</span>
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
fill="currentColor"
|
||||||
|
className={`flex-shrink-0 text-panel-text-5 transition-transform ${lutOpen ? "rotate-90" : ""}`}
|
||||||
|
>
|
||||||
|
<path d="M2 3l3 4 3-4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{lutOpen && (
|
||||||
|
<div className="space-y-1.5 pb-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-3">
|
||||||
|
{selectedLutName ?? "None"}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
data-flat-grade-lut-select="true"
|
||||||
|
value={lut?.src ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const src = e.target.value;
|
||||||
|
applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
|
||||||
|
}}
|
||||||
|
className="bg-transparent font-mono text-[10px] text-panel-text-3 outline-none"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{lutAssets.map((asset) => (
|
||||||
|
<option key={asset} value={asset}>
|
||||||
|
{asset.split("/").pop() ?? asset}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!onImportAssets}
|
||||||
|
onClick={() => lutInputRef.current?.click()}
|
||||||
|
title="Import .cube LUT"
|
||||||
|
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={lutInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".cube"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
void importLuts(e.currentTarget.files);
|
||||||
|
e.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{lut && (
|
||||||
|
<FlatSlider
|
||||||
|
label="LUT strength"
|
||||||
|
value={Math.round((lut.intensity ?? 1) * 100)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
tier={lut.intensity === 1 ? "default" : "explicitCustom"}
|
||||||
|
displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
|
||||||
|
onCommit={(v) => applyLut(lut.src, v / 100)}
|
||||||
|
onReset={() => applyLut(lut.src, 1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
|
||||||
|
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||||
|
Adjust
|
||||||
|
</div>
|
||||||
|
{ADJUST_SLIDERS.map((slider) => {
|
||||||
|
const rawPercent = grading.adjust[slider.key] * 100;
|
||||||
|
const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6;
|
||||||
|
return (
|
||||||
|
<div key={slider.key} data-flat-grade-adjust="true">
|
||||||
|
<FlatSlider
|
||||||
|
label={slider.label}
|
||||||
|
value={rawPercent}
|
||||||
|
min={slider.min}
|
||||||
|
max={slider.max}
|
||||||
|
step={slider.step}
|
||||||
|
tier={isSet ? "explicitCustom" : "default"}
|
||||||
|
displayValue={formatAdjustValue(slider.key, rawPercent)}
|
||||||
|
centerTick
|
||||||
|
onCommit={(next) =>
|
||||||
|
onCommitColorGrading({
|
||||||
|
...grading,
|
||||||
|
adjust: { ...grading.adjust, [slider.key]: next / 100 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onReset={() =>
|
||||||
|
onCommitColorGrading({
|
||||||
|
...grading,
|
||||||
|
adjust: { ...grading.adjust, [slider.key]: 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
|
||||||
|
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||||
|
Finishing
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex-1">{renderDetailSlider("vignette")}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-grade-settings="vignette"
|
||||||
|
title="Vignette settings"
|
||||||
|
onClick={() => setDetailSettingsOpen((c) => (c === "vignette" ? null : "vignette"))}
|
||||||
|
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
|
||||||
|
>
|
||||||
|
<Settings size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex-1">{renderDetailSlider("grain")}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-grade-settings="grain"
|
||||||
|
title="Grain settings"
|
||||||
|
onClick={() => setDetailSettingsOpen((c) => (c === "grain" ? null : "grain"))}
|
||||||
|
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
|
||||||
|
>
|
||||||
|
<Settings size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{detailSettingsOpen && (
|
||||||
|
<div className="space-y-0.5 border-l-2 border-panel-border-input pl-2.5">
|
||||||
|
{(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map(
|
||||||
|
renderDetailSlider,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
|
||||||
|
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||||
|
Effects
|
||||||
|
</div>
|
||||||
|
{EFFECT_SLIDERS.map((slider) => {
|
||||||
|
const value = grading.effects[slider.key];
|
||||||
|
const isSet = value > 1e-6;
|
||||||
|
return (
|
||||||
|
<div key={slider.key} data-flat-grade-effect="true">
|
||||||
|
<FlatSlider
|
||||||
|
label={slider.label}
|
||||||
|
value={Math.round(value * 100)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
tier={isSet ? "explicitCustom" : "default"}
|
||||||
|
displayValue={`${Math.round(value * 100)}%`}
|
||||||
|
onCommit={(next) =>
|
||||||
|
onCommitColorGrading({
|
||||||
|
...grading,
|
||||||
|
effects: { ...grading.effects, [slider.key]: next / 100 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onReset={() =>
|
||||||
|
onCommitColorGrading({
|
||||||
|
...grading,
|
||||||
|
effects: { ...grading.effects, [slider.key]: 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onApplyScopeAvailable && (
|
||||||
|
<div className="flex items-center justify-between gap-2 border-t border-panel-hairline pt-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] text-panel-text-2">
|
||||||
|
Copy grade to
|
||||||
|
<select
|
||||||
|
value={applyScope}
|
||||||
|
onChange={(e) => onSetApplyScope(e.target.value as "source-file" | "project")}
|
||||||
|
disabled={applyBusy}
|
||||||
|
className="bg-transparent font-mono text-[11px] text-panel-text-0 outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="source-file">Current file media</option>
|
||||||
|
<option value="project">All project media</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-grade-apply="true"
|
||||||
|
disabled={applyBusy}
|
||||||
|
onClick={onApplyToScope}
|
||||||
|
className="text-[11px] font-medium text-panel-accent hover:text-panel-accent/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{applyBusy ? "Applying" : "Apply"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -229,6 +229,96 @@ describe("FlatSlider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FlatSlider — Grade extensions", () => {
|
||||||
|
it("renders a center tick when centerTick is true, and omits it by default", () => {
|
||||||
|
const { host: withTick, root: rootA } = renderInto(
|
||||||
|
<FlatSlider
|
||||||
|
label="Exposure"
|
||||||
|
value={0}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
tier="default"
|
||||||
|
displayValue="+0.00"
|
||||||
|
centerTick
|
||||||
|
onCommit={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(withTick.querySelector('[data-flat-slider-center-tick="true"]')).not.toBeNull();
|
||||||
|
act(() => rootA.unmount());
|
||||||
|
|
||||||
|
const { host: withoutTick, root: rootB } = renderInto(
|
||||||
|
<FlatSlider
|
||||||
|
label="Layer blur"
|
||||||
|
value={0}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
tier="default"
|
||||||
|
displayValue="0px"
|
||||||
|
onCommit={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(withoutTick.querySelector('[data-flat-slider-center-tick="true"]')).toBeNull();
|
||||||
|
act(() => rootB.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always reserves a 14px reset slot, showing the icon only when set and onReset is provided", () => {
|
||||||
|
const onReset = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatSlider
|
||||||
|
label="Contrast"
|
||||||
|
value={12}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
tier="explicitCustom"
|
||||||
|
displayValue="+12%"
|
||||||
|
centerTick
|
||||||
|
onReset={onReset}
|
||||||
|
onCommit={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const slot = host.querySelector('[data-flat-slider-reset-slot="true"]');
|
||||||
|
expect(slot).not.toBeNull();
|
||||||
|
const resetButton = host.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
|
||||||
|
expect(resetButton).not.toBeNull();
|
||||||
|
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onReset).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => root.unmount());
|
||||||
|
|
||||||
|
const { host: unsetHost, root: rootB } = renderInto(
|
||||||
|
<FlatSlider
|
||||||
|
label="Contrast"
|
||||||
|
value={0}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
tier="default"
|
||||||
|
displayValue="0%"
|
||||||
|
centerTick
|
||||||
|
onCommit={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(unsetHost.querySelector('[data-flat-slider-reset-slot="true"]')).not.toBeNull();
|
||||||
|
expect(unsetHost.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
|
||||||
|
act(() => rootB.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no reset slot at all when centerTick is omitted, matching existing Style/Media callers", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatSlider
|
||||||
|
label="Opacity"
|
||||||
|
value={100}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
tier="explicitCustom"
|
||||||
|
displayValue="100%"
|
||||||
|
onCommit={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(host.querySelector('[data-flat-slider-reset-slot="true"]')).toBeNull();
|
||||||
|
expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FlatSelectRow", () => {
|
describe("FlatSelectRow", () => {
|
||||||
it("renders the default tier with no reset button", () => {
|
it("renders the default tier with no reset button", () => {
|
||||||
const { host, root } = renderInto(
|
const { host, root } = renderInto(
|
||||||
@@ -288,6 +378,44 @@ describe("FlatSelectRow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FlatSelectRow — label/value options", () => {
|
||||||
|
it("renders distinct labels for entries with a different display label than value", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatSelectRow
|
||||||
|
label="Preset"
|
||||||
|
value="natural-lift"
|
||||||
|
options={[
|
||||||
|
{ value: "neutral", label: "Neutral" },
|
||||||
|
{ value: "natural-lift", label: "Natural Lift" },
|
||||||
|
{ value: "fresh-pop", label: "Fresh Pop" },
|
||||||
|
]}
|
||||||
|
tier="explicitCustom"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const select = host.querySelector("select");
|
||||||
|
expect(select?.value).toBe("natural-lift");
|
||||||
|
const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent);
|
||||||
|
expect(options).toEqual(["Neutral", "Natural Lift", "Fresh Pop"]);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still treats a bare string array as value===label (Plan 2 behavior unchanged)", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatSelectRow
|
||||||
|
label="Blend"
|
||||||
|
value="multiply"
|
||||||
|
options={["normal", "multiply", "screen"]}
|
||||||
|
tier="explicitCustom"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent);
|
||||||
|
expect(options).toEqual(["normal", "multiply", "screen"]);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FlatToggle", () => {
|
describe("FlatToggle", () => {
|
||||||
it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => {
|
it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
|
|||||||
@@ -247,6 +247,8 @@ export function FlatSlider({
|
|||||||
tier,
|
tier,
|
||||||
displayValue,
|
displayValue,
|
||||||
disabled,
|
disabled,
|
||||||
|
centerTick,
|
||||||
|
onReset,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -257,6 +259,8 @@ export function FlatSlider({
|
|||||||
tier: "default" | "explicitCustom";
|
tier: "default" | "explicitCustom";
|
||||||
displayValue: string;
|
displayValue: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
centerTick?: boolean;
|
||||||
|
onReset?: () => void;
|
||||||
onCommit: (nextValue: number) => void;
|
onCommit: (nextValue: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100));
|
const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100));
|
||||||
@@ -285,6 +289,12 @@ export function FlatSlider({
|
|||||||
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{centerTick && (
|
||||||
|
<div
|
||||||
|
data-flat-slider-center-tick="true"
|
||||||
|
className="absolute left-1/2 top-[-1px] h-1 w-px -translate-x-1/2 bg-panel-text-5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{tier === "explicitCustom" && (
|
{tier === "explicitCustom" && (
|
||||||
<div
|
<div
|
||||||
data-flat-slider-fill="true"
|
data-flat-slider-fill="true"
|
||||||
@@ -308,6 +318,21 @@ export function FlatSlider({
|
|||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
|
{centerTick && (
|
||||||
|
<span data-flat-slider-reset-slot="true" className="w-3.5 flex-shrink-0">
|
||||||
|
{tier === "explicitCustom" && onReset && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-flat-slider-reset="true"
|
||||||
|
title="Remove — fall back to default"
|
||||||
|
onClick={onReset}
|
||||||
|
className="text-panel-text-3 hover:text-panel-text-1"
|
||||||
|
>
|
||||||
|
<RotateCcw size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -327,12 +352,15 @@ export function FlatSelectRow({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
options: string[];
|
options: Array<string | { value: string; label: string }>;
|
||||||
tier: PropertyValueTier;
|
tier: PropertyValueTier;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (nextValue: string) => void;
|
onChange: (nextValue: string) => void;
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const normalizedOptions = options.map((option) =>
|
||||||
|
typeof option === "string" ? { value: option, label: option } : option,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="group flex min-h-[30px] items-center justify-between">
|
<div className="group flex min-h-[30px] items-center justify-between">
|
||||||
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
||||||
@@ -344,9 +372,9 @@ export function FlatSelectRow({
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`}
|
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{normalizedOptions.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option.value} value={option.value}>
|
||||||
{option}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
||||||
|
import { useColorGradingController } from "./useColorGradingController";
|
||||||
|
import type { DomEditSelection } from "./domEditing";
|
||||||
|
|
||||||
|
function freshPopGrading() {
|
||||||
|
const next = normalizeHfColorGrading({ preset: "fresh-pop", intensity: 1 });
|
||||||
|
if (!next) throw new Error("expected fresh-pop preset to normalize");
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
|
||||||
|
return {
|
||||||
|
element: document.createElement("video"),
|
||||||
|
id: "s1-bg",
|
||||||
|
selector: "#s1-bg",
|
||||||
|
label: "S1 Background",
|
||||||
|
tagName: "video",
|
||||||
|
sourceFile: "index.html",
|
||||||
|
compositionPath: "index.html",
|
||||||
|
isCompositionHost: false,
|
||||||
|
isInsideLockedComposition: false,
|
||||||
|
boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||||
|
textContent: "",
|
||||||
|
dataAttributes: {},
|
||||||
|
inlineStyles: {},
|
||||||
|
computedStyles: {},
|
||||||
|
textFields: [],
|
||||||
|
capabilities: {
|
||||||
|
canSelect: true,
|
||||||
|
canEditStyles: true,
|
||||||
|
canCrop: true,
|
||||||
|
canMove: true,
|
||||||
|
canResize: true,
|
||||||
|
canApplyManualOffset: true,
|
||||||
|
canApplyManualSize: true,
|
||||||
|
canApplyManualRotation: true,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} as DomEditSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HookHost({
|
||||||
|
onState,
|
||||||
|
onSetAttributeLive,
|
||||||
|
}: {
|
||||||
|
onState: (state: ReturnType<typeof useColorGradingController>) => void;
|
||||||
|
onSetAttributeLive: (attr: string, value: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const state = useColorGradingController({
|
||||||
|
projectId: "proj",
|
||||||
|
element: makeElement(),
|
||||||
|
onSetAttributeLive,
|
||||||
|
});
|
||||||
|
onState(state);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHook(onSetAttributeLive: (attr: string, value: string | null) => void) {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
let latest: ReturnType<typeof useColorGradingController> | undefined;
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(HookHost, {
|
||||||
|
onState: (s: ReturnType<typeof useColorGradingController>) => (latest = s),
|
||||||
|
onSetAttributeLive,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
get state() {
|
||||||
|
if (!latest) throw new Error("hook did not render");
|
||||||
|
return latest;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useColorGradingController", () => {
|
||||||
|
it("starts with the neutral (inactive) grading and idle compare state", () => {
|
||||||
|
const { root, state } = renderHook(vi.fn());
|
||||||
|
expect(state.grading.preset).toBe("neutral");
|
||||||
|
expect(state.compareEnabled).toBe(false);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commitColorGrading updates grading state synchronously and schedules a debounced persist", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onSetAttributeLive = vi.fn();
|
||||||
|
const { root, state } = renderHook(onSetAttributeLive);
|
||||||
|
act(() => {
|
||||||
|
state.commitColorGrading(freshPopGrading());
|
||||||
|
});
|
||||||
|
expect(onSetAttributeLive).not.toHaveBeenCalled();
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
|
||||||
|
const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string];
|
||||||
|
expect(attr).toBe("color-grading");
|
||||||
|
expect(value).toContain("fresh-pop");
|
||||||
|
act(() => root.unmount());
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetGrading returns to the neutral preset", () => {
|
||||||
|
const { root, state } = renderHook(vi.fn());
|
||||||
|
act(() => {
|
||||||
|
state.commitColorGrading(freshPopGrading());
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
state.resetGrading();
|
||||||
|
});
|
||||||
|
expect(state.grading.preset).toBe("neutral");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
|
import {
|
||||||
|
HF_COLOR_GRADING_ATTR,
|
||||||
|
isHfColorGradingActive,
|
||||||
|
normalizeHfColorGrading,
|
||||||
|
serializeHfColorGrading,
|
||||||
|
type HfColorGradingTarget,
|
||||||
|
type NormalizedHfColorGrading,
|
||||||
|
} from "@hyperframes/core/color-grading";
|
||||||
|
import {
|
||||||
|
addStudioPendingEditFlushListener,
|
||||||
|
trackStudioPendingEdit,
|
||||||
|
} from "../../utils/studioPendingEdits";
|
||||||
|
import type { DomEditSelection } from "./domEditing";
|
||||||
|
import { stripQueryAndHash } from "./propertyPanelHelpers";
|
||||||
|
import {
|
||||||
|
acceptStudioRuntimeMessage,
|
||||||
|
postRuntimeControlMessage,
|
||||||
|
} from "../../player/lib/runtimeProtocol";
|
||||||
|
|
||||||
|
const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
|
||||||
|
const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
|
||||||
|
const MEDIA_METADATA_CACHE = new Map<string, MediaMetadata | null>();
|
||||||
|
|
||||||
|
export interface RuntimeColorGradingStatus {
|
||||||
|
state: "missing" | "inactive" | "pending" | "active" | "unavailable";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaMetadata {
|
||||||
|
kind: "video" | "image" | "audio" | "unknown";
|
||||||
|
color: {
|
||||||
|
dynamicRange: "hdr" | "sdr" | "unknown";
|
||||||
|
hdrTransfer: "pq" | "hlg" | "unknown" | null;
|
||||||
|
label: string;
|
||||||
|
isHdr: boolean;
|
||||||
|
codecName?: string;
|
||||||
|
profile?: string;
|
||||||
|
pixelFormat?: string;
|
||||||
|
colorSpace?: string;
|
||||||
|
colorTransfer?: string;
|
||||||
|
colorPrimaries?: string;
|
||||||
|
};
|
||||||
|
probeError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaMetadataResponse {
|
||||||
|
path: string;
|
||||||
|
metadata: MediaMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPreviewAssetPath(src: string, projectId: string): string | null {
|
||||||
|
let pathname = src;
|
||||||
|
try {
|
||||||
|
pathname = new URL(src, window.location.href).pathname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`;
|
||||||
|
const genericMarker = "/preview/";
|
||||||
|
const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker;
|
||||||
|
const index = pathname.indexOf(marker);
|
||||||
|
if (index < 0) return null;
|
||||||
|
const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, "");
|
||||||
|
if (!assetPath || assetPath.startsWith("comp/")) return null;
|
||||||
|
return assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
function resolveProjectAssetPath(
|
||||||
|
sourceFile: string,
|
||||||
|
src: string,
|
||||||
|
projectId: string,
|
||||||
|
): string | null {
|
||||||
|
const trimmed = stripQueryAndHash(src.trim());
|
||||||
|
if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null;
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId);
|
||||||
|
if (trimmed.startsWith("/")) {
|
||||||
|
return stripPreviewAssetPath(trimmed, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceDir = sourceFile.includes("/")
|
||||||
|
? sourceFile.slice(0, sourceFile.lastIndexOf("/"))
|
||||||
|
: "";
|
||||||
|
const parts = `${sourceDir}/${trimmed}`.split("/");
|
||||||
|
const normalized: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part || part === ".") continue;
|
||||||
|
if (part === "..") {
|
||||||
|
normalized.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.push(part);
|
||||||
|
}
|
||||||
|
return normalized.join("/") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null {
|
||||||
|
if (element.tagName !== "video" && element.tagName !== "img") return null;
|
||||||
|
const media = element.element as HTMLImageElement | HTMLVideoElement;
|
||||||
|
const src = media.getAttribute("src") || media.currentSrc || "";
|
||||||
|
return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultColorGrading(): NormalizedHfColorGrading {
|
||||||
|
const grading = normalizeHfColorGrading("neutral");
|
||||||
|
if (!grading) throw new Error("Missing neutral color grading preset");
|
||||||
|
return grading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
|
||||||
|
return (
|
||||||
|
normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown {
|
||||||
|
if (!isHfColorGradingActive(grading)) return null;
|
||||||
|
const { enabled: _enabled, ...bridgeGrading } = grading;
|
||||||
|
return bridgeGrading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRuntimeColorGradingStatus(
|
||||||
|
iframe: HTMLIFrameElement | null | undefined,
|
||||||
|
target: HfColorGradingTarget,
|
||||||
|
): RuntimeColorGradingStatus {
|
||||||
|
try {
|
||||||
|
const win = iframe?.contentWindow as
|
||||||
|
| (Window & {
|
||||||
|
__hf?: {
|
||||||
|
colorGrading?: {
|
||||||
|
getStatus?: (
|
||||||
|
target: HfColorGradingTarget | string | null | undefined,
|
||||||
|
) => RuntimeColorGradingStatus;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
const status = win?.__hf?.colorGrading?.getStatus?.(target);
|
||||||
|
return status ?? { state: "pending", message: "Waiting for runtime" };
|
||||||
|
} catch {
|
||||||
|
return { state: "unavailable", message: "Preview unavailable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorGradingControllerState {
|
||||||
|
grading: NormalizedHfColorGrading;
|
||||||
|
compareEnabled: boolean;
|
||||||
|
applyScope: "source-file" | "project";
|
||||||
|
applyBusy: boolean;
|
||||||
|
runtimeStatus: RuntimeColorGradingStatus;
|
||||||
|
mediaMetadata: MediaMetadata | null;
|
||||||
|
commitColorGrading: (next: NormalizedHfColorGrading) => void;
|
||||||
|
commitCompare: (enabled: boolean) => void;
|
||||||
|
setApplyScope: (scope: "source-file" | "project") => void;
|
||||||
|
applyToScope: () => Promise<void>;
|
||||||
|
resetGrading: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColorGradingController({
|
||||||
|
projectId,
|
||||||
|
element,
|
||||||
|
previewIframeRef,
|
||||||
|
onSetAttributeLive,
|
||||||
|
onApplyScope,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
element: DomEditSelection;
|
||||||
|
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
|
||||||
|
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
|
||||||
|
onApplyScope?: (
|
||||||
|
scope: "source-file" | "project",
|
||||||
|
value: string | null,
|
||||||
|
) => Promise<{ changedFiles: number; changedElements: number }>;
|
||||||
|
}): ColorGradingControllerState {
|
||||||
|
const [grading, setGrading] = useState(() => readColorGradingFromElement(element));
|
||||||
|
const [compareEnabled, setCompareEnabled] = useState(false);
|
||||||
|
const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file");
|
||||||
|
const [applyBusy, setApplyBusy] = useState(false);
|
||||||
|
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeColorGradingStatus>(() => ({
|
||||||
|
state: "pending",
|
||||||
|
message: "Waiting for runtime",
|
||||||
|
}));
|
||||||
|
const selectedAssetPath = useMemo(
|
||||||
|
() => selectedMediaAssetPath(element, projectId),
|
||||||
|
[element, projectId],
|
||||||
|
);
|
||||||
|
const [mediaMetadata, setMediaMetadata] = useState<MediaMetadata | null>(null);
|
||||||
|
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
|
||||||
|
const statusTimersRef = useRef<number[]>([]);
|
||||||
|
const onSetAttributeLiveRef = useRef(onSetAttributeLive);
|
||||||
|
const latestGradingRef = useRef(grading);
|
||||||
|
const compareEnabledRef = useRef(compareEnabled);
|
||||||
|
onSetAttributeLiveRef.current = onSetAttributeLive;
|
||||||
|
latestGradingRef.current = grading;
|
||||||
|
compareEnabledRef.current = compareEnabled;
|
||||||
|
const target = useMemo(
|
||||||
|
(): HfColorGradingTarget => ({
|
||||||
|
id: element.id ?? null,
|
||||||
|
hfId: element.hfId ?? null,
|
||||||
|
selector: element.selector ?? null,
|
||||||
|
selectorIndex: element.selectorIndex ?? null,
|
||||||
|
}),
|
||||||
|
[element.hfId, element.id, element.selector, element.selectorIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshRuntimeStatus = useCallback(() => {
|
||||||
|
setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target));
|
||||||
|
}, [previewIframeRef, target]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMediaMetadata(null);
|
||||||
|
if (!selectedAssetPath) return;
|
||||||
|
const cacheKey = `${projectId}:${selectedAssetPath}`;
|
||||||
|
if (MEDIA_METADATA_CACHE.has(cacheKey)) {
|
||||||
|
setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch(
|
||||||
|
`/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent(
|
||||||
|
selectedAssetPath,
|
||||||
|
)}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
|
.then((data: MediaMetadataResponse | null) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
const metadata = data?.metadata ?? null;
|
||||||
|
MEDIA_METADATA_CACHE.set(cacheKey, metadata);
|
||||||
|
setMediaMetadata(metadata);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [projectId, selectedAssetPath]);
|
||||||
|
|
||||||
|
const clearStatusTimers = useCallback(() => {
|
||||||
|
for (const timer of statusTimersRef.current) clearTimeout(timer);
|
||||||
|
statusTimersRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleRuntimeStatusRefresh = useCallback(() => {
|
||||||
|
clearStatusTimers();
|
||||||
|
statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) =>
|
||||||
|
window.setTimeout(refreshRuntimeStatus, delay),
|
||||||
|
);
|
||||||
|
}, [clearStatusTimers, refreshRuntimeStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRuntimeStatus();
|
||||||
|
}, [refreshRuntimeStatus]);
|
||||||
|
|
||||||
|
const persistColorGradingValue = useCallback((value: string | null) => {
|
||||||
|
return trackStudioPendingEdit(
|
||||||
|
onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushPendingPersist = useCallback(() => {
|
||||||
|
if (persistTimerRef.current) {
|
||||||
|
clearTimeout(persistTimerRef.current);
|
||||||
|
persistTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (pendingPersistValueRef.current === undefined) return undefined;
|
||||||
|
const value = pendingPersistValueRef.current;
|
||||||
|
pendingPersistValueRef.current = undefined;
|
||||||
|
return persistColorGradingValue(value);
|
||||||
|
}, [persistColorGradingValue]);
|
||||||
|
|
||||||
|
useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearStatusTimers();
|
||||||
|
void flushPendingPersist();
|
||||||
|
};
|
||||||
|
}, [clearStatusTimers, flushPendingPersist]);
|
||||||
|
|
||||||
|
const postColorGrading = useCallback(
|
||||||
|
(nextGrading: NormalizedHfColorGrading) => {
|
||||||
|
postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", {
|
||||||
|
target,
|
||||||
|
grading: toBridgeColorGrading(nextGrading),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[previewIframeRef, target],
|
||||||
|
);
|
||||||
|
|
||||||
|
const postCompare = useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
postRuntimeControlMessage(
|
||||||
|
previewIframeRef?.current?.contentWindow,
|
||||||
|
"set-color-grading-compare",
|
||||||
|
{
|
||||||
|
target,
|
||||||
|
compare: { enabled, position: 1, lineWidth: 0 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[previewIframeRef, target],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const iframe = previewIframeRef?.current;
|
||||||
|
if (!iframe) return;
|
||||||
|
const refreshAndReplay = () => {
|
||||||
|
const nextGrading = latestGradingRef.current;
|
||||||
|
const active = isHfColorGradingActive(nextGrading);
|
||||||
|
if (active) postColorGrading(nextGrading);
|
||||||
|
postCompare(compareEnabledRef.current && active);
|
||||||
|
scheduleRuntimeStatusRefresh();
|
||||||
|
};
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (event.source !== iframe.contentWindow) return;
|
||||||
|
const data = event.data as { source?: unknown; type?: unknown } | null;
|
||||||
|
if (data?.source !== "hf-preview" || data.type !== "ready") return;
|
||||||
|
if (!acceptStudioRuntimeMessage(data)) return;
|
||||||
|
refreshAndReplay();
|
||||||
|
};
|
||||||
|
iframe.addEventListener("load", refreshAndReplay);
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
const timer = window.setTimeout(refreshAndReplay, 80);
|
||||||
|
return () => {
|
||||||
|
iframe.removeEventListener("load", refreshAndReplay);
|
||||||
|
window.removeEventListener("message", onMessage);
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
postCompare(false);
|
||||||
|
},
|
||||||
|
[postCompare],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitColorGrading = useCallback(
|
||||||
|
(nextGrading: NormalizedHfColorGrading) => {
|
||||||
|
setGrading(nextGrading);
|
||||||
|
setRuntimeStatus({ state: "pending", message: "Updating shader" });
|
||||||
|
postColorGrading(nextGrading);
|
||||||
|
const active = isHfColorGradingActive(nextGrading);
|
||||||
|
if (compareEnabledRef.current) {
|
||||||
|
postCompare(active);
|
||||||
|
if (!active) setCompareEnabled(false);
|
||||||
|
}
|
||||||
|
scheduleRuntimeStatusRefresh();
|
||||||
|
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
||||||
|
pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
|
||||||
|
? serializeHfColorGrading(nextGrading)
|
||||||
|
: null;
|
||||||
|
persistTimerRef.current = setTimeout(() => {
|
||||||
|
const value = pendingPersistValueRef.current;
|
||||||
|
pendingPersistValueRef.current = undefined;
|
||||||
|
persistTimerRef.current = null;
|
||||||
|
void persistColorGradingValue(value ?? null);
|
||||||
|
}, 350);
|
||||||
|
},
|
||||||
|
[persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitCompare = useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
const nextEnabled = enabled && isHfColorGradingActive(grading);
|
||||||
|
setCompareEnabled(nextEnabled);
|
||||||
|
if (nextEnabled) postColorGrading(grading);
|
||||||
|
postCompare(nextEnabled);
|
||||||
|
scheduleRuntimeStatusRefresh();
|
||||||
|
},
|
||||||
|
[grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyToScope = useCallback(async () => {
|
||||||
|
if (!onApplyScope || applyBusy) return;
|
||||||
|
setApplyBusy(true);
|
||||||
|
try {
|
||||||
|
const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null;
|
||||||
|
await onApplyScope(applyScope, value);
|
||||||
|
} finally {
|
||||||
|
setApplyBusy(false);
|
||||||
|
}
|
||||||
|
}, [applyBusy, applyScope, grading, onApplyScope]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
grading,
|
||||||
|
compareEnabled,
|
||||||
|
applyScope,
|
||||||
|
applyBusy,
|
||||||
|
runtimeStatus,
|
||||||
|
mediaMetadata,
|
||||||
|
commitColorGrading,
|
||||||
|
commitCompare,
|
||||||
|
setApplyScope,
|
||||||
|
applyToScope,
|
||||||
|
resetGrading: () => commitColorGrading(defaultColorGrading()),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user