Merge pull request #892 from stevenahhh/fix/preview-video-source-lifetime

fix(preview): stop leaking video sources on layout changes
This commit is contained in:
webadderall
2026-09-09 21:29:59 +10:00
committed by GitHub
3 changed files with 172 additions and 11 deletions
+14 -11
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
ref,
) => {
const videoRef = useRef<HTMLVideoElement | null>(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<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
@@ -1944,13 +1952,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
animationStateRef.current = createPlaybackAnimationState();
layoutVideoContent();
layoutVideoContentRef.current?.();
video.pause();
const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } =
@@ -2004,10 +2006,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
{/* Keep the source video off-screen instead of display:none so the
browser continues producing presented frames for Pixi and preview sync. */}
<video
ref={videoRef}
ref={attachVideo}
src={videoPath}
className={fallbackVideoClassName}
preload="metadata"
@@ -0,0 +1,125 @@
import { DOMAdapter, Texture } from "pixi.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { PreviewVideoSource } from "./previewVideoSource";
class TestVideo extends EventTarget {
videoWidth = 5120;
videoHeight = 2880;
width = 5120;
height = 2880;
readyState = 4;
HAVE_ENOUGH_DATA = 4;
HAVE_FUTURE_DATA = 3;
paused = true;
ended = false;
src = "recording.mp4";
currentTime = 5;
playbackRate = 1;
play = vi.fn();
pause = vi.fn();
load = vi.fn();
requestVideoFrameCallback = vi.fn(() => 1);
cancelVideoFrameCallback = vi.fn();
listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
override addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
super.addEventListener(type, listener);
}
override removeEventListener(type: string, listener: EventListenerOrEventListenerObject) {
this.listeners.get(type)?.delete(listener);
super.removeEventListener(type, listener);
}
asElement() {
return this as unknown as HTMLVideoElement;
}
}
const originalAdapter = DOMAdapter.get();
beforeAll(() => {
DOMAdapter.set({
...originalAdapter,
createCanvas: () => ({ getContext: () => null }) as unknown as HTMLCanvasElement,
});
});
afterAll(() => DOMAdapter.set(originalAdapter));
describe("PreviewVideoSource", () => {
it("reuses a single source and listener set across repeated texture lifecycles", async () => {
const video = new TestVideo();
const owner = new PreviewVideoSource();
owner.setVideo(video.asElement());
const source = owner.getSource();
await source.load();
const unload = vi.fn();
source.on("unload", unload);
for (let i = 0; i < 100; i++) {
expect(owner.getSource()).toBe(source);
const texture = new Texture({ source });
texture.destroy(false);
owner.suspend();
}
expect(source.destroyed).toBe(false);
expect(unload).toHaveBeenCalledTimes(100);
for (const type of ["play", "pause", "seeked"]) {
expect(video.listeners.get(type)?.size).toBe(1);
}
expect(video.src).toBe("recording.mp4");
expect(video.currentTime).toBe(5);
expect(video.play).not.toHaveBeenCalled();
expect(video.load).not.toHaveBeenCalled();
owner.setVideo(null);
expect(source.destroyed).toBe(true);
for (const listeners of video.listeners.values()) expect(listeners.size).toBe(0);
});
it("updates paused seeks and new media dimensions after resuming", async () => {
const video = new TestVideo();
const owner = new PreviewVideoSource();
owner.setVideo(video.asElement());
const source = owner.getSource();
await source.load();
const update = vi.fn();
source.on("update", update);
video.dispatchEvent(new Event("seeked"));
expect(update).toHaveBeenCalledTimes(1);
owner.suspend();
video.dispatchEvent(new Event("seeked"));
expect(update).toHaveBeenCalledTimes(1);
video.videoWidth = 1920;
video.videoHeight = 1080;
owner.getSource();
expect(source.pixelWidth).toBe(1920);
expect(source.pixelHeight).toBe(1080);
owner.setVideo(null);
});
it("cancels frame callbacks and releases the source when the element detaches", async () => {
const video = new TestVideo();
const owner = new PreviewVideoSource();
owner.setVideo(video.asElement());
const source = owner.getSource();
await source.load();
video.paused = false;
video.dispatchEvent(new Event("play"));
expect(video.requestVideoFrameCallback).toHaveBeenCalledTimes(1);
owner.setVideo(null);
expect(video.cancelVideoFrameCallback).toHaveBeenCalledWith(1);
expect(source.destroyed).toBe(true);
owner.setVideo(null);
expect(video.load).toHaveBeenCalledTimes(1);
expect(() => owner.getSource()).toThrow("not attached");
const nextVideo = new TestVideo();
owner.setVideo(nextVideo.asElement());
expect(owner.getSource()).not.toBe(source);
await owner.getSource().load();
owner.setVideo(null);
});
});
@@ -0,0 +1,33 @@
import { VideoSource } from "pixi.js";
/** Own the GPU source for the lifetime of React's persistent video element. */
export class PreviewVideoSource {
private video: HTMLVideoElement | null = null;
private source: VideoSource | null = null;
setVideo(video: HTMLVideoElement | null): void {
if (video === this.video) return;
if (this.source) {
this.source.autoUpdate = false;
this.source.destroy();
this.source = null;
}
this.video = video;
}
getSource(): VideoSource {
if (!this.video) throw new Error("Preview video element is not attached");
this.source ??= new VideoSource({ resource: this.video, autoPlay: false });
this.source.autoUpdate = true;
// React can load another media URL into the same element.
this.source.update();
return this.source;
}
suspend(): void {
if (!this.source) return;
this.source.autoUpdate = false;
this.source.unload();
}
}