From f0faa06a0b4c755b25d13f53293081a10f6b09ca Mon Sep 17 00:00:00 2001 From: Sungwang Ahn <54258067+stevenahhh@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:20:39 +0900 Subject: [PATCH] fix(preview): reuse video source and release GPU resources --- src/components/video-editor/VideoPlayback.tsx | 25 ++-- .../videoPlayback/previewVideoSource.test.ts | 125 ++++++++++++++++++ .../videoPlayback/previewVideoSource.ts | 33 +++++ 3 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/previewVideoSource.test.ts create mode 100644 src/components/video-editor/videoPlayback/previewVideoSource.ts diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 739baaf03..e3e221d07 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1,4 +1,4 @@ -import { Application, Container, Graphics, Rectangle, Sprite, Texture, VideoSource } from "pixi.js"; +import { Application, Container, Graphics, Rectangle, Sprite, Texture } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; import { ZoomBlurFilter } from "pixi-filters/zoom-blur"; import type React from "react"; @@ -111,6 +111,7 @@ import { stepSpringValue, } from "./videoPlayback/motionSmoothing"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; +import { PreviewVideoSource } from "./videoPlayback/previewVideoSource"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { getWebcamMediaTargetTimeSeconds, @@ -381,6 +382,13 @@ const VideoPlayback = forwardRef( ref, ) => { const videoRef = useRef(null); + const previewVideoSourceRef = useRef(new PreviewVideoSource()); + const attachVideo = useCallback((video: HTMLVideoElement | null) => { + // VideoSource.destroy() clears the media URL, so only destroy it when + // React detaches the element, never during a layout effect cleanup. + previewVideoSourceRef.current.setVideo(video); + videoRef.current = video; + }, []); const previewFrameRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); @@ -1944,13 +1952,7 @@ const VideoPlayback = forwardRef( return; if (video.videoWidth === 0 || video.videoHeight === 0) return; - const source = VideoSource.from(video); - if ("autoPlay" in source) { - (source as { autoPlay?: boolean }).autoPlay = false; - } - if ("autoUpdate" in source) { - (source as { autoUpdate?: boolean }).autoUpdate = true; - } + const source = previewVideoSourceRef.current.getSource(); const videoTexture = Texture.from(source); const videoSprite = new Sprite(videoTexture); @@ -1967,7 +1969,7 @@ const VideoPlayback = forwardRef( animationStateRef.current = createPlaybackAnimationState(); - layoutVideoContent(); + layoutVideoContentRef.current?.(); video.pause(); const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } = @@ -2004,10 +2006,11 @@ const VideoPlayback = forwardRef( destroyPixiContainer(maskGraphics); maskGraphicsRef.current = null; if (!videoTexture.destroyed) videoTexture.destroy(false); + previewVideoSourceRef.current.suspend(); videoSpriteRef.current = null; }; - }, [layoutVideoContent, onPlayStateChange, onTimeUpdate, pixiReady, videoReady]); + }, [onPlayStateChange, onTimeUpdate, pixiReady, videoReady]); useEffect(() => { if (!pixiReady || !videoReady) return; @@ -2885,7 +2888,7 @@ const VideoPlayback = forwardRef( {/* Keep the source video off-screen instead of display:none so the browser continues producing presented frames for Pixi and preview sync. */}