import { ExtensionType } from '../../../../extensions/Extensions';
import { VideoSource } from '../../../../rendering/renderers/shared/texture/sources/VideoSource';
import { detectVideoAlphaMode } from '../../../../utils/browser/detectVideoAlphaMode';
import { getResolutionOfUrl } from '../../../../utils/network/getResolutionOfUrl';
import { checkDataUrl } from '../../../utils/checkDataUrl';
import { checkExtension } from '../../../utils/checkExtension';
import { createTexture } from './utils/createTexture';
import type { VideoSourceOptions } from '../../../../rendering/renderers/shared/texture/sources/VideoSource';
import type { Texture } from '../../../../rendering/renderers/shared/texture/Texture';
import type { ResolvedAsset } from '../../../types';
import type { Loader } from '../../Loader';
import type { LoaderParser } from '../LoaderParser';
const validVideoExtensions = ['.mp4', '.m4v', '.webm', '.ogg', '.ogv', '.h264', '.avi', '.mov'];
const validVideoMIMEs = validVideoExtensions.map((ext) => `video/${ext.substring(1)}`);
/**
* Set cross origin based detecting the url and the crossorigin
* @param element - Element to apply crossOrigin
* @param url - URL to check
* @param crossorigin - Cross origin value to use
* @memberof assets
*/
export function crossOrigin(element: HTMLImageElement | HTMLVideoElement, url: string, crossorigin?: boolean | string): void
{
if (crossorigin === undefined && !url.startsWith('data:'))
{
element.crossOrigin = determineCrossOrigin(url);
}
else if (crossorigin !== false)
{
element.crossOrigin = typeof crossorigin === 'string' ? crossorigin : 'anonymous';
}
}
/**
* Preload a video element
* @param element - Video element to preload
*/
export function preloadVideo(element: HTMLVideoElement): Promise<void>
{
return new Promise((resolve, reject) =>
{
element.addEventListener('canplaythrough', loaded);
element.addEventListener('error', error);
element.load();
function loaded(): void
{
cleanup();
resolve();
}
function error(err: ErrorEvent): void
{
cleanup();
reject(err);
}
function cleanup(): void
{
element.removeEventListener('canplaythrough', loaded);
element.removeEventListener('error', error);
}
});
}
/**
* Sets the `crossOrigin` property for this resource based on if the url
* for this resource is cross-origin. If crossOrigin was manually set, this
* function does nothing.
* Nipped from the resource loader!
* @ignore
* @param url - The url to test.
* @param {object} [loc=window.location] - The location object to test against.
* @returns The crossOrigin value to use (or empty string for none).
* @memberof assets
*/
export function determineCrossOrigin(url: string, loc: Location = globalThis.location): string
{
// data: and javascript: urls are considered same-origin
if (url.startsWith('data:'))
{
return '';
}
// default is window.location
loc = loc || globalThis.location;
const parsedUrl = new URL(url, document.baseURI);
// if cross origin
if (parsedUrl.hostname !== loc.hostname || parsedUrl.port !== loc.port || parsedUrl.protocol !== loc.protocol)
{
return 'anonymous';
}
return '';
}
/**
* A simple plugin to load video textures.
*
* You can pass VideoSource options to the loader via the .data property of the asset descriptor
* when using Asset.load().
* ```js
* // Set the data
* const texture = await Assets.load({
* src: './assets/city.mp4',
* data: {
* preload: true,
* autoPlay: true,
* },
* });
* ```
* @memberof assets
*/
export const loadVideoTextures = {
name: 'loadVideo',
extension: {
type: ExtensionType.LoadParser,
name: 'loadVideo',
},
test(url: string): boolean
{
const isValidDataUrl = checkDataUrl(url, validVideoMIMEs);
const isValidExtension = checkExtension(url, validVideoExtensions);
return isValidDataUrl || isValidExtension;
},
async load(url: string, asset: ResolvedAsset<VideoSourceOptions>, loader: Loader): Promise<Texture>
{
// --- Merge default and provided options ---
const options: VideoSourceOptions = {
...VideoSource.defaultOptions,
resolution: asset.data?.resolution || getResolutionOfUrl(url),
alphaMode: asset.data?.alphaMode || await detectVideoAlphaMode(),
...asset.data,
};
// --- Create and configure HTMLVideoElement ---
const videoElement = document.createElement('video');
// Set attributes based on options
const attributeMap = {
preload: options.autoLoad !== false ? 'auto' : undefined,
'webkit-playsinline': options.playsinline !== false ? '' : undefined,
playsinline: options.playsinline !== false ? '' : undefined,
muted: options.muted === true ? '' : undefined,
loop: options.loop === true ? '' : undefined,
autoplay: options.autoPlay !== false ? '' : undefined
};
Object.keys(attributeMap).forEach((key) =>
{
const value = attributeMap[key as keyof typeof attributeMap];
if (value !== undefined) videoElement.setAttribute(key, value);
});
if (options.muted === true)
{
videoElement.muted = true;
}
crossOrigin(videoElement, url, options.crossorigin); // Assume crossOrigin is globally available
// --- Set up source and MIME type ---
const sourceElement = document.createElement('source');
// Determine MIME type
let mime: string | undefined;
if (url.startsWith('data:'))
{
mime = url.slice(5, url.indexOf(';'));
}
else if (!url.startsWith('blob:'))
{
const ext = url.split('?')[0].slice(url.lastIndexOf('.') + 1).toLowerCase();
mime = VideoSource.MIME_TYPES[ext] || `video/${ext}`;
}
sourceElement.src = url;
if (mime)
{
sourceElement.type = mime;
}
// this promise will make sure that video is ready to play - as in we have a valid width, height and it can be
// uploaded to the GPU. Our textures are kind of dumb now, and don't want to handle resizing right now.
return new Promise((resolve) =>
{
const onCanPlay = async () =>
{
const base = new VideoSource({ ...options, resource: videoElement });
videoElement.removeEventListener('canplay', onCanPlay);
if (asset.data.preload)
{
await preloadVideo(videoElement);
}
resolve(createTexture(base, loader, url));
};
videoElement.addEventListener('canplay', onCanPlay);
videoElement.appendChild(sourceElement);
});
},
unload(texture: Texture): void
{
texture.destroy(true);
}
} satisfies LoaderParser<Texture, VideoSourceOptions>;