import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, } from "react"; import { Tooltip } from "./ui"; import { PropertyPanel } from "./editor/PropertyPanel"; import { LayersPanel } from "./editor/LayersPanel"; import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel"; import { BlockParamsPanel } from "./editor/BlockParamsPanel"; import { RenderQueue } from "./renders/RenderQueue"; import { SlideshowPanel } from "./panels/SlideshowPanel"; import type { SceneInfo } from "./panels/SlideshowPanel"; import type { RenderJob } from "./renders/useRenderQueue"; import type { BlockParam } from "@hyperframes/core/registry"; import type { IframeWindow } from "../player/lib/playbackTypes"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability"; import type { Composition } from "@hyperframes/sdk"; import type { EditHistoryKind } from "../utils/editHistory"; import { useSlideshowPersist } from "../hooks/useSlideshowPersist"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; 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 { 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; activeBlockParams?: { blockName: string; blockTitle: string; params: BlockParam[]; compositionPath: string; } | null; onCloseBlockParams?: () => void; recordingState?: "idle" | "recording" | "preview"; recordingDuration?: number; onToggleRecording?: () => void; /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */ sdkSession: Composition | null; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; recordEdit: (entry: { label: string; kind: EditHistoryKind; files: Record; }) => Promise; } // fallow-ignore-next-line complexity export function StudioRightPanel({ designPanelActive, activeBlockParams, onCloseBlockParams, recordingState, recordingDuration, onToggleRecording, sdkSession, reloadPreview, domEditSaveTimestampRef, recordEdit, }: StudioRightPanelProps) { const { rightWidth, rightPanelTab, setRightPanelTab, rightInspectorPanes, toggleRightInspectorPane, handlePanelResizeStart, handlePanelResizeMove, handlePanelResizeEnd, } = usePanelLayoutContext(); const { previewIframeRef, projectId, activeCompPath, showToast, compositionDimensions, waitForPendingDomEditSaves, renderQueue, } = useStudioShellContext(); const { captionEditMode, refreshKey } = useStudioPlaybackContext(); const { domEditSelection, domEditGroupSelections, copiedAgentPrompt, clearDomSelection, handleUngroupSelection, handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleDomTextCommit, handleDomTextFieldStyleCommit, handleDomAddTextField, handleDomRemoveTextField, handleAskAgent, selectedGsapAnimations, gsapMultipleTimelines, gsapUnsupportedTimelinePattern, handleGsapUpdateProperty, handleGsapUpdateMeta, handleGsapDeleteAnimation, handleGsapAddAnimation, handleGsapAddProperty, handleGsapRemoveProperty, handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, commitAnimatedProperty, commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, handleUpdateKeyframeEase, handleSetAllKeyframeEases, handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, } = useDomEditContext(); const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts, refreshFileTree, readProjectFile, writeProjectFile, fileTree, } = useFileManagerContext(); // Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately, // no coalescing — each is a distinct user action that deserves its own undo entry. const onPersistSlideshow = useSlideshowPersist({ sdkSession, activeCompPath, readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef, }); // Notes path: persists are debounced in SlideshowPanel; coalesceKey ensures // rapid writes collapse into a single undo entry via the save-queue infra. const onPersistSlideshowNotes = useSlideshowPersist({ sdkSession, activeCompPath, readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef, coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes", }); const [layersPanePercent, setLayersPanePercent] = useState(40); const splitContainerRef = useRef(null); const splitDragRef = useRef<{ startY: number; startPercent: number; height: number; } | null>(null); const backgroundRemovalAbortRef = useRef(null); useEffect( () => () => { backgroundRemovalAbortRef.current?.abort(); }, [], ); const renderJobs = renderQueue.jobs as RenderJob[]; const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers"; // Derive scene list from the live clip manifest in the preview iframe. // fallow-ignore-next-line complexity const slideshowScenes = useMemo(() => { try { const win = previewIframeRef.current?.contentWindow as IframeWindow | null; return (win?.__clipManifest?.scenes ?? []).map((s) => ({ id: s.id, label: s.label, start: s.start, duration: s.duration, })); } catch { return []; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [previewIframeRef, rightPanelTab, refreshKey]); const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive; const layersPaneOpen = inspectorTabActive && rightInspectorPanes.layers && STUDIO_INSPECTOR_PANELS_ENABLED; const handleInspectorPaneButtonClick = (pane: "design" | "layers") => { if (!inspectorTabActive) { setRightPanelTab(pane); return; } toggleRightInspectorPane(pane); }; const handleInspectorSplitResizeStart = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); const height = splitContainerRef.current?.getBoundingClientRect().height ?? 0; splitDragRef.current = { startY: event.clientY, startPercent: layersPanePercent, height, }; }, [layersPanePercent], ); const handleInspectorSplitResizeMove = useCallback((event: ReactPointerEvent) => { const drag = splitDragRef.current; if (!drag || drag.height <= 0) return; const deltaPercent = ((event.clientY - drag.startY) / drag.height) * 100; const next = Math.min( MAX_INSPECTOR_SPLIT_PERCENT, Math.max(MIN_INSPECTOR_SPLIT_PERCENT, drag.startPercent + deltaPercent), ); setLayersPanePercent(next); }, []); const handleInspectorSplitResizeEnd = useCallback(() => { 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 = {}; 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 = ( 1 ? null : domEditSelection} multiSelectCount={domEditGroupSelections.length} copiedAgentPrompt={copiedAgentPrompt} onClearSelection={clearDomSelection} onUngroup={handleUngroupSelection} onSetStyle={handleDomStyleCommit} onSetAttribute={handleDomAttributeCommit} onSetAttributeLive={handleDomAttributeLiveCommit} onApplyColorGradingScope={handleApplyColorGradingScope} onSetHtmlAttribute={handleDomHtmlAttributeCommit} onRemoveBackground={handleRemoveBackground} onSetManualOffset={handleDomPathOffsetCommit} onSetManualSize={handleDomBoxSizeCommit} onSetManualRotation={handleDomRotationCommit} onSetText={handleDomTextCommit} onSetTextFieldStyle={handleDomTextFieldStyleCommit} onAddTextField={handleDomAddTextField} onRemoveTextField={handleDomRemoveTextField} onAskAgent={handleAskAgent} onImportAssets={handleImportFiles} fontAssets={fontAssets} onImportFonts={handleImportFonts} previewIframeRef={previewIframeRef} gsapAnimations={selectedGsapAnimations} gsapMultipleTimelines={gsapMultipleTimelines} gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern} onUpdateGsapProperty={handleGsapUpdateProperty} onUpdateGsapMeta={handleGsapUpdateMeta} onDeleteGsapAnimation={handleGsapDeleteAnimation} onAddGsapProperty={handleGsapAddProperty} onRemoveGsapProperty={handleGsapRemoveProperty} onUpdateGsapFromProperty={handleGsapUpdateFromProperty} onAddGsapFromProperty={handleGsapAddFromProperty} onRemoveGsapFromProperty={handleGsapRemoveFromProperty} onAddGsapAnimation={handleGsapAddAnimation} onCommitAnimatedProperty={commitAnimatedProperty} onCommitAnimatedProperties={commitAnimatedProperties} onAddKeyframe={handleGsapAddKeyframe} onRemoveKeyframe={handleGsapRemoveKeyframe} onConvertToKeyframes={(animId, duration) => handleGsapConvertToKeyframes(animId, undefined, duration) } onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)} onSetArcPath={handleSetArcPath} onUpdateArcSegment={handleUpdateArcSegment} onUnroll={handleUnroll} onUpdateKeyframeEase={handleUpdateKeyframeEase} onSetAllKeyframeEases={handleSetAllKeyframeEases} recordingState={recordingState} recordingDuration={recordingDuration} onToggleRecording={onToggleRecording} /> ); const renderQueuePanel = ( { await waitForPendingDomEditSaves(); const composition = activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined; await renderQueue.startRender({ fps, quality, format, resolution, composition, }); }} compositionDimensions={compositionDimensions} isRendering={renderQueue.isRendering} /> ); return ( <>
handlePanelResizeStart("right", e)} onPointerMove={handlePanelResizeMove} onPointerUp={handlePanelResizeEnd} >
{captionEditMode ? ( ) : ( <>
{STUDIO_INSPECTOR_PANELS_ENABLED && ( <> )}
{rightPanelTab === "block-params" && activeBlockParams ? ( {})} /> ) : rightPanelTab === "slideshow" ? ( ) : layersPaneOpen && designPaneOpen ? (
{propertyPanel}
) : layersPaneOpen ? ( ) : designPaneOpen ? ( propertyPanel ) : ( renderQueuePanel )}
)}
); }