Source: rendering/renderers/shared/texture/sources/VideoSource.ts

// 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';

type VideoResource = HTMLVideoElement;

/**
 * Options for video sources.
 * @memberof rendering
 */
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;
}

export interface VideoResourceOptionsElement
{
    src: string;
    mime: string;
}

/**
 * A source for video-based textures.
 * @memberof rendering
 */
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);

        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();
        }

        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
    {
        // Handle edge case where video might not have received its "can play" event yet
        if (!this.isValid)
        {
            this._mediaReady();
        }

        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;
        }
    }

    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._onCanPlay);

        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 if it exists
        if (this._resolve)
        {
            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('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)
            || (globalThis.VideoFrame && resource instanceof VideoFrame);
    }
}