mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user