/
githubmirror
/
pixijs
Обзор
Документация
Войти
/
githubmirror
/
pixijs
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
dev
src/rendering/renderers/shared/texture/sources/VideoSource.ts
592 строки
19 KB
satoren
fix(VideoSource): avoid play/mediaReady recursion before video dimensions (#11133) (#12009)
13 апр 2026, 15:08
Не верифицирован
13 апр 2026, 15:08
0c9a6a9
Код
Авторство
О чём код?
// VideoSource.ts import { ExtensionType } from '../../../../../extensions/Extensions'; import { Ticker } from '../../../../../ticker/Ticker'; import { detectVideoAlphaMode } from '../../../../../utils/browser/detectVideoAlphaMode'; import { TextureSource } from './TextureSource'; import type { ExtensionMetadata } from '../../../../../extensions/Extensions'; import type { Dict } from '../../../../../utils/types'; import type { ALPHA_MODES } from '../const'; import type { TextureSourceOptions } from './TextureSource'; /** * The type of resource used for video textures. * This is typically an HTMLVideoElement. * @category rendering * @advanced */ export type VideoResource = HTMLVideoElement; /** * Options for video sources. * @category rendering * @advanced */ export interface VideoSourceOptions extends TextureSourceOptions<VideoResource> { /** If true, the video will start loading immediately. */ autoLoad?: boolean; /** If true, the video will start playing as soon as it is loaded. */ autoPlay?: boolean; /** The number of times a second to update the texture from the video. Leave at 0 to update at every render. */ updateFPS?: number; /** If true, the video will be loaded with the `crossorigin` attribute. */ crossorigin?: boolean | string; /** If true, the video will loop when it ends. */ loop?: boolean; /** If true, the video will be muted. */ muted?: boolean; /** If true, the video will play inline. */ playsinline?: boolean; /** If true, the video will be preloaded. */ preload?: boolean; /** The time in milliseconds to wait for the video to preload before timing out. */ preloadTimeoutMs?: number; /** The alpha mode of the video. */ alphaMode?: ALPHA_MODES; } /** * A texture source that uses a video as its resource. * It automatically resizes the texture based on the video dimensions. * It also provides methods to control playback and handle video events. * This class supports automatic loading, playback, and frame updates. * It can also handle cross-origin videos and provides options for looping, muting, and inline playback. * @category rendering * @advanced */ export class VideoSource extends TextureSource<VideoResource> { public static extension: ExtensionMetadata = ExtensionType.TextureSource; /** The default options for video sources. */ public static defaultOptions: VideoSourceOptions = { ...TextureSource.defaultOptions, /** If true, the video will start loading immediately. */ autoLoad: true, /** If true, the video will start playing as soon as it is loaded. */ autoPlay: true, /** The number of times a second to update the texture from the video. Leave at 0 to update at every render. */ updateFPS: 0, /** If true, the video will be loaded with the `crossorigin` attribute. */ crossorigin: true, /** If true, the video will loop when it ends. */ loop: false, /** If true, the video will be muted. */ muted: true, /** If true, the video will play inline. */ playsinline: true, /** If true, the video will be preloaded. */ preload: false, }; // Public /** Whether or not the video is ready to play. */ public isReady = false; /** The upload method for this texture. */ public uploadMethodId = 'video'; // Protected /** * When set to true will automatically play videos used by this texture once * they are loaded. If false, it will not modify the playing state. * @default true */ protected autoPlay: boolean; // Private /** * `true` to use Ticker.shared to auto update the base texture. * @default true */ private _autoUpdate: boolean; /** * `true` if the instance is currently connected to Ticker.shared to auto update the base texture. * @default false */ private _isConnectedToTicker: boolean; /** * Promise when loading. * @default null */ private _load: Promise<this>; private _msToNextUpdate: number; private _preloadTimeout: number; /** Callback when completed with load. */ private _resolve: (value?: this | PromiseLike<this>) => void; private _reject: (error: ErrorEvent) => void; private _updateFPS: number; private _videoFrameRequestCallbackHandle: number | null; constructor( options: VideoSourceOptions ) { super(options); // Merge provided options with default ones options = { ...VideoSource.defaultOptions, ...options }; this._autoUpdate = true; this._isConnectedToTicker = false; this._updateFPS = options.updateFPS || 0; this._msToNextUpdate = 0; this.autoPlay = options.autoPlay !== false; this.alphaMode = options.alphaMode ?? 'premultiply-alpha-on-upload'; // Binding for frame updates this._videoFrameRequestCallback = this._videoFrameRequestCallback.bind(this); this._videoFrameRequestCallbackHandle = null; this._load = null; this._resolve = null; this._reject = null; // Bind for listeners this._onCanPlay = this._onCanPlay.bind(this); this._onCanPlayThrough = this._onCanPlayThrough.bind(this); this._onError = this._onError.bind(this); this._onPlayStart = this._onPlayStart.bind(this); this._onPlayStop = this._onPlayStop.bind(this); this._onSeeked = this._onSeeked.bind(this); this._onLoadedMetadata = this._onLoadedMetadata.bind(this); if (options.autoLoad !== false) { void this.load(); } } /** Update the video frame if the source is not destroyed and meets certain conditions. */ protected updateFrame(): void { if (this.destroyed) { return; } if (this._updateFPS) { // Account for if video has had its playbackRate changed const elapsedMS = Ticker.shared.elapsedMS * this.resource.playbackRate; this._msToNextUpdate = Math.floor(this._msToNextUpdate - elapsedMS); } if (!this._updateFPS || this._msToNextUpdate <= 0) { this._msToNextUpdate = this._updateFPS ? Math.floor(1000 / this._updateFPS) : 0; } if (this.isValid) { this.update(); } } /** Callback to update the video frame and potentially request the next frame update. */ private _videoFrameRequestCallback(): void { this.updateFrame(); if (this.destroyed) { this._videoFrameRequestCallbackHandle = null; } else { this._videoFrameRequestCallbackHandle = this.resource.requestVideoFrameCallback( this._videoFrameRequestCallback ); } } /** * Checks if the resource has valid dimensions. * @returns {boolean} True if width and height are set, otherwise false. */ public get isValid(): boolean { return !!this.resource.videoWidth && !!this.resource.videoHeight; } /** * Start preloading the video resource. * @returns {Promise<this>} Handle the validate event */ public async load(): Promise<this> { if (this._load) { return this._load; } const source = this.resource; const options = this.options as VideoSourceOptions; // Check if source data is enough and set it to complete if needed if ((source.readyState === source.HAVE_ENOUGH_DATA || source.readyState === source.HAVE_FUTURE_DATA) && source.width && source.height) { (source as any).complete = true; } // Add event listeners related to playback and seeking source.addEventListener('play', this._onPlayStart); source.addEventListener('pause', this._onPlayStop); source.addEventListener('seeked', this._onSeeked); // Add or handle source readiness event listeners if (!this._isSourceReady()) { if (!options.preload) { // since this event fires early, only bind if not waiting for a preload event source.addEventListener('canplay', this._onCanPlay); } source.addEventListener('canplaythrough', this._onCanPlayThrough); source.addEventListener('error', this._onError, true); } else { // Source is already ready, so handle it immediately this._mediaReady(); } // play may fire before dimensions exist (e.g. MediaStream). canplay can also precede // videoWidth/height; keep listening until we have a drawable size (#11133). if (!this.isValid) { source.addEventListener('loadedmetadata', this._onLoadedMetadata); } this.alphaMode = await detectVideoAlphaMode(); // Create and return the loading promise this._load = new Promise((resolve, reject): void => { if (this.isValid) { resolve(this); } else { this._resolve = resolve; this._reject = reject; if (options.preloadTimeoutMs !== undefined) { this._preloadTimeout = setTimeout(() => { this._onError(new ErrorEvent(`Preload exceeded timeout of ${options.preloadTimeoutMs}ms`)); }) as unknown as number; } source.load(); } }); return this._load; } /** * Handle video error events. * @param event - The error event */ private _onError(event: ErrorEvent): void { this.resource.removeEventListener('error', this._onError, true); this.emit('error', event); if (this._reject) { this._reject(event); this._reject = null; this._resolve = null; } } /** * Checks if the underlying source is playing. * @returns True if playing. */ private _isSourcePlaying(): boolean { const source = this.resource; return (!source.paused && !source.ended); } /** * Checks if the underlying source is ready for playing. * @returns True if ready. */ private _isSourceReady(): boolean { const source = this.resource; return source.readyState > 2; } /** Runs the update loop when the video is ready to play. */ private _onPlayStart(): void { // Do not call _mediaReady() when !isValid: `play` can precede intrinsic size // (MediaStream / srcObject), and `_mediaReady` calls back into `_onPlayStart` while // still playing → stack overflow (#11133). Readiness stays on canplay / canplaythrough / // loadedmetadata instead. this._configureAutoUpdate(); } /** Stops the update loop when a pause event is triggered. */ private _onPlayStop(): void { this._configureAutoUpdate(); } /** Handles behavior when the video completes seeking to the current playback position. */ private _onSeeked(): void { if (this._autoUpdate && !this._isSourcePlaying()) { this._msToNextUpdate = 0; this.updateFrame(); this._msToNextUpdate = 0; } } /** When intrinsic size becomes known after play / canplay (common with MediaStream). */ private _onLoadedMetadata(): void { if (!this.isValid) { return; } this._mediaReady(); } private _onCanPlay(): void { const source = this.resource; // Remove event listeners source.removeEventListener('canplay', this._onCanPlay); this._mediaReady(); } private _onCanPlayThrough(): void { const source = this.resource; // Remove event listeners source.removeEventListener('canplaythrough', this._onCanPlayThrough); if (this._preloadTimeout) { clearTimeout(this._preloadTimeout); this._preloadTimeout = undefined; } this._mediaReady(); } /** Fired when the video is loaded and ready to play. */ private _mediaReady(): void { const source = this.resource; if (this.isValid) { this.isReady = true; this.resize(source.videoWidth, source.videoHeight); } // Reset update timers and perform a frame update this._msToNextUpdate = 0; this.updateFrame(); this._msToNextUpdate = 0; // Resolve the loading promise only once dimensions are known (avoid resolving at 0×0). if (this._resolve && this.isValid) { this._resolve(this); this._resolve = null; this._reject = null; } // Handle play behavior based on current source status if (this._isSourcePlaying()) { this._onPlayStart(); } else if (this.autoPlay) { void this.resource.play(); } } /** Cleans up resources and event listeners associated with this texture. */ public destroy() { this._configureAutoUpdate(); const source = this.resource; if (source) { // Remove event listeners source.removeEventListener('play', this._onPlayStart); source.removeEventListener('pause', this._onPlayStop); source.removeEventListener('seeked', this._onSeeked); source.removeEventListener('canplay', this._onCanPlay); source.removeEventListener('canplaythrough', this._onCanPlayThrough); source.removeEventListener('loadedmetadata', this._onLoadedMetadata); source.removeEventListener('error', this._onError, true); // Clear the video source and pause source.pause(); source.src = ''; source.load(); } super.destroy(); } /** Should the base texture automatically update itself, set to true by default. */ get autoUpdate(): boolean { return this._autoUpdate; } set autoUpdate(value: boolean) { if (value !== this._autoUpdate) { this._autoUpdate = value; this._configureAutoUpdate(); } } /** * How many times a second to update the texture from the video. * Leave at 0 to update at every render. * A lower fps can help performance, as updating the texture at 60fps on a 30ps video may not be efficient. */ get updateFPS(): number { return this._updateFPS; } set updateFPS(value: number) { if (value !== this._updateFPS) { this._updateFPS = value; this._configureAutoUpdate(); } } /** * Configures the updating mechanism based on the current state and settings. * * This method decides between using the browser's native video frame callback or a custom ticker * for updating the video frame. It ensures optimal performance and responsiveness * based on the video's state, playback status, and the desired frames-per-second setting. * * - If `_autoUpdate` is enabled and the video source is playing: * - It will prefer the native video frame callback if available and no specific FPS is set. * - Otherwise, it will use a custom ticker for manual updates. * - If `_autoUpdate` is disabled or the video isn't playing, any active update mechanisms are halted. */ private _configureAutoUpdate(): void { // Check if automatic updating is enabled and if the source is currently playing if (this._autoUpdate && this._isSourcePlaying()) { // Determine if we should use the browser's native video frame callback (generally for better performance) if (!this._updateFPS && this.resource.requestVideoFrameCallback) { // If connected to a custom ticker, remove the update frame function from it if (this._isConnectedToTicker) { Ticker.shared.remove(this.updateFrame, this); this._isConnectedToTicker = false; // Reset the time until the next update this._msToNextUpdate = 0; } // Check if we haven't already requested a video frame callback, and if not, request one if (this._videoFrameRequestCallbackHandle === null) { this._videoFrameRequestCallbackHandle = this.resource.requestVideoFrameCallback( this._videoFrameRequestCallback ); } } else { // If a video frame request callback exists, cancel it, as we are switching to manual ticker-based updates if (this._videoFrameRequestCallbackHandle !== null) { this.resource.cancelVideoFrameCallback(this._videoFrameRequestCallbackHandle); this._videoFrameRequestCallbackHandle = null; } // If not connected to the custom ticker, add the update frame function to it if (!this._isConnectedToTicker) { Ticker.shared.add(this.updateFrame, this); this._isConnectedToTicker = true; // Reset the time until the next update this._msToNextUpdate = 0; } } } else { // If automatic updating is disabled or the source isn't playing, perform cleanup // Cancel any existing video frame callback request if (this._videoFrameRequestCallbackHandle !== null) { this.resource.cancelVideoFrameCallback(this._videoFrameRequestCallbackHandle); this._videoFrameRequestCallbackHandle = null; } // Remove the update frame function from the custom ticker if (this._isConnectedToTicker) { Ticker.shared.remove(this.updateFrame, this); this._isConnectedToTicker = false; // Reset the time until the next update this._msToNextUpdate = 0; } } } /** * Map of video MIME types that can't be directly derived from file extensions. * @readonly */ public static MIME_TYPES: Dict<string> = { ogv: 'video/ogg', mov: 'video/quicktime', m4v: 'video/mp4', }; public static test(resource: any): resource is VideoResource { return (globalThis.HTMLVideoElement && resource instanceof HTMLVideoElement); } }