mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(studio): extract useColorGradingController from ColorGradingSection
This commit is contained in:
@@ -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,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