diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/preview/components/index.tsx | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/preview/components/index.tsx')
| -rw-r--r-- | apps/web/src/preview/components/index.tsx | 328 |
1 files changed, 328 insertions, 0 deletions
diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx new file mode 100644 index 00000000..d3cb2a49 --- /dev/null +++ b/apps/web/src/preview/components/index.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; +import useDeepCompareEffect from "use-deep-compare-effect"; +import { useEditor } from "@/editor/use-editor"; +import { useRafLoop } from "@/hooks/use-raf-loop"; +import { useContainerSize } from "@/hooks/use-container-size"; +import { useFullscreen } from "@/hooks/use-fullscreen"; +import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; +import { TICKS_PER_SECOND } from "@/wasm"; +import type { RootNode } from "@/services/renderer/nodes/root-node"; +import { buildScene } from "@/services/renderer/scene-builder"; +import { PreviewOverlayLayer } from "./overlay-layer"; +import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; +import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import type { + PreviewOverlayControl, + PreviewOverlayInstance, +} from "@/preview/overlays"; +import { PreviewContextMenu } from "./context-menu"; +import { PreviewToolbar } from "./toolbar"; +import { + PreviewViewportProvider, + usePreviewViewportState, +} from "./preview-viewport"; + +function usePreviewSize() { + const canvasSize = useEditor( + (e) => e.project.getActive()?.settings.canvasSize, + ); + + return { + width: canvasSize?.width, + height: canvasSize?.height, + }; +} + +function normalizeWheelDelta({ + delta, + deltaMode, + pageSize, +}: { + delta: number; + deltaMode: number; + pageSize: number; +}): number { + if (deltaMode === WheelEvent.DOM_DELTA_LINE) { + return delta * 16; + } + + if (deltaMode === WheelEvent.DOM_DELTA_PAGE) { + return delta * pageSize; + } + + return delta; +} + +export function PreviewPanel({ + overlayControls, + overlayInstances, + onOverlayVisibilityChange, +}: { + overlayControls: PreviewOverlayControl[]; + overlayInstances: PreviewOverlayInstance[]; + onOverlayVisibilityChange: (params: { + overlayId: string; + isVisible: boolean; + }) => void; +}) { + const containerRef = useRef<HTMLDivElement>(null); + const { toggleFullscreen } = useFullscreen({ containerRef }); + + return ( + <div + ref={containerRef} + className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" + > + <PreviewCanvas + containerRef={containerRef} + onToggleFullscreen={toggleFullscreen} + overlayControls={overlayControls} + overlayInstances={overlayInstances} + onOverlayVisibilityChange={onOverlayVisibilityChange} + /> + <RenderTreeController /> + </div> + ); +} + +function RenderTreeController() { + const editor = useEditor(); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const activeProject = useEditor((e) => e.project.getActive()); + + const { width, height } = usePreviewSize(); + + useDeepCompareEffect(() => { + if (!activeProject) return; + + const duration = editor.timeline.getTotalDuration(); + const renderTree = buildScene({ + tracks, + mediaAssets, + duration, + canvasSize: { width, height }, + background: activeProject.settings.background, + isPreview: true, + }); + + editor.renderer.setRenderTree({ renderTree }); + }, [tracks, mediaAssets, activeProject?.settings.background, width, height]); + + return null; +} + +function PreviewCanvas({ + containerRef, + onToggleFullscreen, + overlayControls, + overlayInstances, + onOverlayVisibilityChange, +}: { + containerRef: React.RefObject<HTMLElement | null>; + onToggleFullscreen: () => void; + overlayControls: PreviewOverlayControl[]; + overlayInstances: PreviewOverlayInstance[]; + onOverlayVisibilityChange: (params: { + overlayId: string; + isVisible: boolean; + }) => void; +}) { + const canvasRef = useRef<HTMLCanvasElement>(null); + const viewportRef = useRef<HTMLDivElement>(null); + const lastFrameRef = useRef(-1); + const lastSceneRef = useRef<RootNode | null>(null); + const renderingRef = useRef(false); + const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); + const viewportSize = useContainerSize({ containerRef: viewportRef }); + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + const renderTree = useEditor((e) => e.renderer.getRenderTree()); + const viewport = usePreviewViewportState({ + canvasHeight: nativeHeight, + canvasWidth: nativeWidth, + viewportHeight: viewportSize.height, + viewportRef, + viewportWidth: viewportSize.width, + }); + + const renderer = useMemo(() => { + return new CanvasRenderer({ + width: nativeWidth, + height: nativeHeight, + fps: activeProject.settings.fps, + }); + }, [nativeWidth, nativeHeight, activeProject.settings.fps]); + + const render = useCallback(() => { + if (canvasRef.current && renderTree && !renderingRef.current) { + const renderTime = Math.min( + editor.playback.getCurrentTime(), + editor.timeline.getLastFrameTime(), + ); + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator, + ); + const frame = Math.floor(renderTime / ticksPerFrame); + + if ( + frame !== lastFrameRef.current || + renderTree !== lastSceneRef.current + ) { + renderingRef.current = true; + lastSceneRef.current = renderTree; + lastFrameRef.current = frame; + renderer + .renderToCanvas({ + node: renderTree, + time: renderTime, + targetCanvas: canvasRef.current, + }) + .then(() => { + renderingRef.current = false; + }); + } + } + }, [renderer, renderTree, editor.playback, editor.timeline]); + + useRafLoop(render); + + useEffect(() => { + const container = viewportRef.current; + if (!container) return; + + let pendingZoomDelta = 0; + let pendingPanDeltaX = 0; + let pendingPanDeltaY = 0; + let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null; + let panRafId: ReturnType<typeof requestAnimationFrame> | null = null; + + const onWheel = (event: WheelEvent) => { + const normalizedDeltaX = normalizeWheelDelta({ + delta: event.deltaX, + deltaMode: event.deltaMode, + pageSize: container.clientWidth, + }); + const normalizedDeltaY = normalizeWheelDelta({ + delta: event.deltaY, + deltaMode: event.deltaMode, + pageSize: container.clientHeight, + }); + const isZoomGesture = event.ctrlKey || event.metaKey; + if (isZoomGesture) { + event.preventDefault(); + pendingZoomDelta += normalizedDeltaY; + + if (zoomRafId === null) { + zoomRafId = requestAnimationFrame(() => { + const cappedDelta = + Math.sign(pendingZoomDelta) * + Math.min(Math.abs(pendingZoomDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + + viewport.scaleZoom({ factor: zoomFactor }); + pendingZoomDelta = 0; + zoomRafId = null; + }); + } + + return; + } + + if (!viewport.canPan) { + return; + } + + if (normalizedDeltaX === 0 && normalizedDeltaY === 0) { + return; + } + + event.preventDefault(); + pendingPanDeltaX += normalizedDeltaX; + pendingPanDeltaY += normalizedDeltaY; + + if (panRafId === null) { + panRafId = requestAnimationFrame(() => { + viewport.panByScreenDelta({ + deltaX: pendingPanDeltaX, + deltaY: pendingPanDeltaY, + }); + pendingPanDeltaX = 0; + pendingPanDeltaY = 0; + panRafId = null; + }); + } + }; + + container.addEventListener("wheel", onWheel, { + capture: true, + passive: false, + }); + + return () => { + container.removeEventListener("wheel", onWheel, { + capture: true, + }); + if (zoomRafId !== null) { + cancelAnimationFrame(zoomRafId); + } + if (panRafId !== null) { + cancelAnimationFrame(panRafId); + } + }; + }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]); + + return ( + <PreviewViewportProvider value={viewport}> + <div className="flex size-full min-h-0 min-w-0 flex-col"> + <div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0"> + <ContextMenu> + <ContextMenuTrigger asChild> + <div + ref={viewportRef} + className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden" + > + <canvas + ref={canvasRef} + width={nativeWidth} + height={nativeHeight} + className="absolute block border" + style={{ + left: viewport.sceneLeft, + top: viewport.sceneTop, + width: viewport.sceneWidth, + height: viewport.sceneHeight, + background: + activeProject.settings.background.type === "blur" + ? "transparent" + : activeProject?.settings.background.color, + }} + /> + <PreviewOverlayLayer + instances={overlayInstances} + plane="under-interaction" + /> + <PreviewInteractionOverlay /> + <PreviewOverlayLayer + instances={overlayInstances} + plane="over-interaction" + /> + </div> + </ContextMenuTrigger> + <PreviewContextMenu + onToggleFullscreen={onToggleFullscreen} + containerRef={containerRef} + overlayControls={overlayControls} + onOverlayVisibilityChange={onOverlayVisibilityChange} + /> + </ContextMenu> + </div> + <PreviewToolbar onToggleFullscreen={onToggleFullscreen} /> + </div> + </PreviewViewportProvider> + ); +} |
