mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): add color grading inspector
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -27,9 +28,103 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
||||
import { useDomEditContext } from "../contexts/DomEditContext";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import type {
|
||||
BackgroundRemovalProgress,
|
||||
BackgroundRemovalResult,
|
||||
} from "./editor/propertyPanelHelpers";
|
||||
|
||||
const MIN_INSPECTOR_SPLIT_PERCENT = 20;
|
||||
const MAX_INSPECTOR_SPLIT_PERCENT = 75;
|
||||
const MEDIA_JOB_RECONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
function hasRelativeLutSource(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as { lut?: { src?: unknown } } | null;
|
||||
const src = typeof parsed?.lut?.src === "string" ? parsed.lut.src.trim() : "";
|
||||
return Boolean(src && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(src));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForMediaJob(
|
||||
jobId: string,
|
||||
onProgress?: (progress: BackgroundRemovalProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BackgroundRemovalResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Background removal was cancelled", "AbortError"));
|
||||
return;
|
||||
}
|
||||
const events = new EventSource(`/api/media-jobs/${encodeURIComponent(jobId)}/progress`);
|
||||
let settled = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimer === null) return;
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
};
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearReconnectTimer();
|
||||
signal?.removeEventListener("abort", handleAbort);
|
||||
events.close();
|
||||
callback();
|
||||
};
|
||||
const handleAbort = () => {
|
||||
finish(() => reject(new DOMException("Background removal was cancelled", "AbortError")));
|
||||
};
|
||||
signal?.addEventListener("abort", handleAbort, { once: true });
|
||||
|
||||
events.addEventListener("progress", (event) => {
|
||||
let progress: BackgroundRemovalProgress;
|
||||
try {
|
||||
progress = JSON.parse((event as MessageEvent).data) as BackgroundRemovalProgress;
|
||||
} catch {
|
||||
finish(() => reject(new Error("Invalid background-removal progress event")));
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
onProgress?.(progress);
|
||||
if (progress.status === "complete") {
|
||||
if (!progress.outputPath) {
|
||||
finish(() => reject(new Error("Background removal finished without an output path")));
|
||||
return;
|
||||
}
|
||||
const outputPath = progress.outputPath;
|
||||
finish(() => {
|
||||
resolve({
|
||||
outputPath,
|
||||
backgroundOutputPath: progress.backgroundOutputPath,
|
||||
provider: progress.provider,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (progress.status === "failed") {
|
||||
finish(() => reject(new Error(progress.error || "Background removal failed")));
|
||||
}
|
||||
});
|
||||
events.onopen = clearReconnectTimer;
|
||||
events.onerror = () => {
|
||||
if (events.readyState === EventSource.CLOSED) {
|
||||
finish(() => reject(new Error("Lost connection to background-removal job")));
|
||||
return;
|
||||
}
|
||||
if (reconnectTimer === null) {
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
finish(() => reject(new Error("Lost connection to background-removal job")));
|
||||
}, MEDIA_JOB_RECONNECT_TIMEOUT_MS);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface StudioRightPanelProps {
|
||||
designPanelActive: boolean;
|
||||
@@ -82,6 +177,7 @@ export function StudioRightPanel({
|
||||
previewIframeRef,
|
||||
projectId,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
compositionDimensions,
|
||||
waitForPendingDomEditSaves,
|
||||
renderQueue,
|
||||
@@ -136,8 +232,10 @@ export function StudioRightPanel({
|
||||
projectDir,
|
||||
handleImportFiles,
|
||||
handleImportFonts,
|
||||
refreshFileTree,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
fileTree,
|
||||
} = useFileManagerContext();
|
||||
|
||||
// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
|
||||
@@ -172,6 +270,14 @@ export function StudioRightPanel({
|
||||
startPercent: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
backgroundRemovalAbortRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderJobs = renderQueue.jobs as RenderJob[];
|
||||
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
|
||||
@@ -233,6 +339,128 @@ export function StudioRightPanel({
|
||||
splitDragRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleApplyColorGradingScope = useCallback(
|
||||
async (scope: "source-file" | "project", value: string | null) => {
|
||||
try {
|
||||
await waitForPendingDomEditSaves();
|
||||
if (scope === "project" && hasRelativeLutSource(value)) {
|
||||
showToast(
|
||||
"Project-wide color grading cannot copy relative LUT paths. Apply to this file or use a URL/data LUT.",
|
||||
"error",
|
||||
);
|
||||
return { changedFiles: 0, changedElements: 0 };
|
||||
}
|
||||
const selectedSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html";
|
||||
const paths =
|
||||
scope === "source-file"
|
||||
? [selectedSourceFile]
|
||||
: fileTree.filter((path) => /\.html?$/i.test(path));
|
||||
const snapshots = await Promise.all(
|
||||
Array.from(new Set(paths)).map(
|
||||
async (path) => [path, await readProjectFile(path)] as const,
|
||||
),
|
||||
);
|
||||
const files: Record<string, string> = {};
|
||||
let changedElements = 0;
|
||||
|
||||
for (const [path, before] of snapshots) {
|
||||
const result = patchMediaColorGradingInHtml(before, value);
|
||||
if (result.html !== before) {
|
||||
files[path] = result.html;
|
||||
changedElements += result.count;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
showToast("No color grading changed", "info");
|
||||
return { changedFiles: 0, changedElements: 0 };
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
const changedPaths = await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label: value ? "Apply color grading" : "Clear color grading",
|
||||
kind: "manual",
|
||||
files,
|
||||
readFile: readProjectFile,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
reloadPreview();
|
||||
showToast(
|
||||
`${value ? "Applied" : "Cleared"} color grading on ${changedElements} media item${changedElements === 1 ? "" : "s"}`,
|
||||
"info",
|
||||
);
|
||||
return { changedFiles: changedPaths.length, changedElements };
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
return { changedFiles: 0, changedElements: 0 };
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
domEditSelection?.sourceFile,
|
||||
fileTree,
|
||||
projectId,
|
||||
readProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
waitForPendingDomEditSaves,
|
||||
writeProjectFile,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRemoveBackground = useCallback(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
createBackgroundPlate?: boolean;
|
||||
quality?: "fast" | "balanced" | "best";
|
||||
onProgress?: (progress: BackgroundRemovalProgress) => void;
|
||||
},
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
inputPath,
|
||||
createBackgroundPlate: options.createBackgroundPlate === true,
|
||||
quality: options.quality ?? "balanced",
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = (await response.json().catch(() => ({}))) as {
|
||||
jobId?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!response.ok || !data.jobId) {
|
||||
throw new Error(data.error || `Background removal failed (${response.status})`);
|
||||
}
|
||||
showToast("Removing background...", "info");
|
||||
backgroundRemovalAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
backgroundRemovalAbortRef.current = controller;
|
||||
try {
|
||||
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
|
||||
await refreshFileTree();
|
||||
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
|
||||
return result;
|
||||
} finally {
|
||||
if (backgroundRemovalAbortRef.current === controller) {
|
||||
backgroundRemovalAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectId, refreshFileTree, showToast],
|
||||
);
|
||||
|
||||
const propertyPanel = (
|
||||
<PropertyPanel
|
||||
projectId={projectId}
|
||||
@@ -246,7 +474,9 @@ export function StudioRightPanel({
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
onSetAttributeLive={handleDomAttributeLiveCommit}
|
||||
onApplyColorGradingScope={handleApplyColorGradingScope}
|
||||
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
|
||||
onRemoveBackground={handleRemoveBackground}
|
||||
onSetManualOffset={handleDomPathOffsetCommit}
|
||||
onSetManualSize={handleDomBoxSizeCommit}
|
||||
onSetManualRotation={handleDomRotationCommit}
|
||||
@@ -325,14 +555,14 @@ export function StudioRightPanel({
|
||||
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
|
||||
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
|
||||
style={{ width: rightWidth }}
|
||||
>
|
||||
{captionEditMode ? (
|
||||
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
|
||||
{STUDIO_INSPECTOR_PANELS_ENABLED && (
|
||||
<>
|
||||
<Tooltip label="Element styles and properties" side="bottom">
|
||||
@@ -390,7 +620,7 @@ export function StudioRightPanel({
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{rightPanelTab === "block-params" && activeBlockParams ? (
|
||||
<BlockParamsPanel
|
||||
blockName={activeBlockParams.blockName}
|
||||
@@ -406,7 +636,7 @@ export function StudioRightPanel({
|
||||
onPersistNotes={onPersistSlideshowNotes}
|
||||
/>
|
||||
) : layersPaneOpen && designPaneOpen ? (
|
||||
<div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
|
||||
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div
|
||||
className="min-h-[120px] overflow-hidden"
|
||||
style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
|
||||
|
||||
Reference in New Issue
Block a user