Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions src/components/video-editor/VideoPlayback.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -1967,7 +1969,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(

animationStateRef.current = createPlaybackAnimationState();

layoutVideoContent();
layoutVideoContentRef.current?.();
video.pause();

const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down
125 changes: 125 additions & 0 deletions src/components/video-editor/videoPlayback/previewVideoSource.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 33 additions & 0 deletions src/components/video-editor/videoPlayback/previewVideoSource.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}