@polygonjs/polygonjs
Version:
node-based WebGL 3D engine https://polygonjs.com
434 lines (412 loc) • 13.2 kB
text/typescript
/**
* Imports a video
*
* @remarks
* TIP: to ensure that your video starts as soon as possible, make sure to pre-process it with a tool like qt-faststart. There are many places where you can find it, but here are some suggestions:
*
* - download it from [https://pypi.org/project/qtfaststart/](https://pypi.org/project/qtfaststart/)
* - download it from [https://manpages.debian.org/stretch/ffmpeg/qt-faststart.1.en.html](https://manpages.debian.org/stretch/ffmpeg/qt-faststart.1.en.html)
* - with ffmpeg, you can use the following command line: `ffmpeg -i in.mp4 -c copy -map 0 -movflags +faststart out.mp4
`
In a future version of this node, it will also be possible to link it to a video tag that could already be in your html DOM. This way, you could sets multiple source tags (one with mp4 and one with ogv) instead of a single url.
*/
import {ParamEvent} from './../../poly/ParamEvent';
import {Constructor} from '../../../types/GlobalTypes';
import {VideoTexture} from 'three';
import {Texture} from 'three';
import {TypedCopNode} from './_Base';
import {BaseNodeType} from '../_Base';
import {BaseParamType} from '../../params/_Base';
import {NodeParamsConfig, ParamConfig} from '../utils/params/ParamsConfig';
import {isUrlVideo} from '../../../core/FileTypeController';
import {TextureParamsController, TextureParamConfig} from './utils/TextureParamsController';
import {isBooleanTrue} from '../../../core/BooleanValue';
import {InputCloneMode} from '../../poly/InputCloneMode';
import {CopType} from '../../poly/registers/nodes/types/Cop';
import {FileTypeCheckCopParamConfig} from './utils/CheckFileType';
import {Poly} from '../../Poly';
import {CoreVideoTextureLoader} from '../../../core/loader/texture/Video';
import {VideoEvent, VIDEO_EVENTS} from '../../../core/VideoEvent';
import {EXTENSIONS_BY_NODE_TYPE_BY_CONTEXT} from '../../../core/loader/FileExtensionRegister';
import {isHTMLVideoElementLoaded} from '../../../core/DomUtils';
import {NodeContext} from '../../poly/NodeContext';
// import {TypeAssert} from '../../poly/Assert'
import {StringParam} from '../../params/String';
export enum VideoMode {
FROM_URLS = 'From Urls',
FROM_HTML_ELEMENT = 'From HTML Element',
}
const VIDEO_MODES: VideoMode[] = [VideoMode.FROM_URLS, VideoMode.FROM_HTML_ELEMENT];
const VIDEO_MODE_ENTRIES = VIDEO_MODES.map((name, value) => ({name, value}));
const VIDEO_MODE_FROM_URLS = VIDEO_MODES.indexOf(VideoMode.FROM_URLS);
const VIDEO_MODE_FROM_HTML_ELEMENT = VIDEO_MODES.indexOf(VideoMode.FROM_HTML_ELEMENT);
function VideoCopParamConfig<TBase extends Constructor>(Base: TBase) {
return class Mixin extends Base {
/** @param mode */
mode = ParamConfig.INTEGER(VIDEO_MODE_FROM_URLS, {
menu: {
entries: VIDEO_MODE_ENTRIES,
},
});
/** @param number of video files to fetch */
urlsCount = ParamConfig.INTEGER(2, {
range: [1, 3],
rangeLocked: [true, true],
visibleIf: {
mode: VIDEO_MODE_FROM_URLS,
},
});
/** @param url to fetch the video from */
url1 = ParamConfig.STRING('', {
fileBrowse: {extensions: EXTENSIONS_BY_NODE_TYPE_BY_CONTEXT[NodeContext.COP][CopType.VIDEO]},
});
/** @param url to fetch the video from */
url2 = ParamConfig.STRING('', {
fileBrowse: {extensions: EXTENSIONS_BY_NODE_TYPE_BY_CONTEXT[NodeContext.COP][CopType.VIDEO]},
visibleIf: [
{mode: VIDEO_MODE_FROM_URLS, urlsCount: 2},
{mode: VIDEO_MODE_FROM_URLS, urlsCount: 3},
],
});
/** @param url to fetch the video from */
url3 = ParamConfig.STRING('', {
fileBrowse: {extensions: EXTENSIONS_BY_NODE_TYPE_BY_CONTEXT[NodeContext.COP][CopType.VIDEO]},
visibleIf: {mode: VIDEO_MODE_FROM_URLS, urlsCount: 3},
});
/** @param selector */
selector = ParamConfig.STRING('', {
visibleIf: {mode: VIDEO_MODE_FROM_HTML_ELEMENT},
});
/** @param reload the video */
reload = ParamConfig.BUTTON(null, {
callback: (node: BaseNodeType, param: BaseParamType) => {
VideoCopNode.PARAM_CALLBACK_reload(node as VideoCopNode, param);
},
});
/** @param play the video */
play = ParamConfig.BOOLEAN(1, {
cook: false,
callback: (node: BaseNodeType) => {
VideoCopNode.PARAM_CALLBACK_videoUpdatePlay(node as VideoCopNode);
},
});
/** @param set the video muted attribute */
muted = ParamConfig.BOOLEAN(1, {
cook: false,
callback: (node: BaseNodeType) => {
VideoCopNode.PARAM_CALLBACK_videoUpdateMuted(node as VideoCopNode);
},
});
/** @param set the video loop attribute */
loop = ParamConfig.BOOLEAN(1, {
cook: false,
callback: (node: BaseNodeType) => {
VideoCopNode.PARAM_CALLBACK_videoUpdateLoop(node as VideoCopNode);
},
});
/** @param set the video time */
videoTime = ParamConfig.FLOAT(0, {
cook: false,
// do not use videoTime, as calling "this._video.currentTime =" every frame is really expensive
});
/** @param seek the video at the time specified in videoTime */
setVideoTime = ParamConfig.BUTTON(null, {
cook: false,
callback: (node: BaseNodeType) => {
VideoCopNode.PARAM_CALLBACK_videoUpdateTime(node as VideoCopNode);
},
});
};
}
class VideoCopParamsConfig extends FileTypeCheckCopParamConfig(
TextureParamConfig(VideoCopParamConfig(NodeParamsConfig))
) {}
const ParamsConfig = new VideoCopParamsConfig();
export class VideoCopNode extends TypedCopNode<VideoCopParamsConfig> {
override paramsConfig = ParamsConfig;
static override type() {
return CopType.VIDEO;
}
private _video: HTMLVideoElement | undefined;
HTMLVideoElement() {
return this._video;
}
// private _data_texture_controller: DataTextureController | undefined;
public readonly textureParamsController: TextureParamsController = new TextureParamsController(this);
override initializeNode() {
this.io.inputs.setCount(0, 1);
this.io.inputs.initInputsClonedState(InputCloneMode.NEVER);
}
override dispose(): void {
super.dispose();
this._disposeHTMLVideoElement();
Poly.blobs.clearBlobsForNode(this);
}
private _disposeHTMLVideoElement() {
if (this._video) {
this._removeVideoEvents(this._video);
this._video.parentElement?.removeChild(this._video);
}
}
setMode(mode: VideoMode) {
this.p.mode.set(VIDEO_MODES.indexOf(mode));
}
mode() {
return VIDEO_MODES[this.pv.mode];
}
override async cook(input_contents: Texture[]) {
const mode = this.mode();
if (mode == VideoMode.FROM_URLS && isBooleanTrue(this.pv.checkFileType)) {
const setUrlNotVideoError = (param: StringParam) => {
this.states.error.set(`url from param ${param.name()} is not a video ('${param.value}')`);
};
if (!isUrlVideo(this.pv.url1)) {
return setUrlNotVideoError(this.p.url1);
}
const urlsCount = this.pv.urlsCount;
if (urlsCount >= 2) {
if (!isUrlVideo(this.pv.url2)) {
return setUrlNotVideoError(this.p.url2);
}
}
if (urlsCount >= 3) {
if (!isUrlVideo(this.pv.url3)) {
return setUrlNotVideoError(this.p.url3);
}
}
}
const texture =
mode == VideoMode.FROM_URLS ? await this._loadTexture() : await this._videoTextureFromSelector();
if (texture) {
this._disposeHTMLVideoElement();
this._video = texture.image;
if (this._video) {
this._addVideoEvents(this._video);
// document.body.appendChild(this._video);
}
const inputTexture = input_contents[0];
if (inputTexture) {
TextureParamsController.copyTextureAttributes(texture, inputTexture);
}
this.videoUpdateLoop();
this.videoUpdateMuted();
this.videoUpdatePlay();
this.videoUpdateTime();
await this.textureParamsController.update(texture);
this.setTexture(texture);
} else {
this.cookController.endCook();
}
}
private _videoBoundEvents: Record<VideoEvent, () => void> = {
play: this._onVideoEventPlay.bind(this),
pause: this._onVideoEventPause.bind(this),
timeupdate: this._onVideoEventTimeUpdate.bind(this),
volumechange: this._onVideoEventVolumeChange.bind(this),
};
private _addVideoEvents(video: HTMLVideoElement) {
for (const eventName of VIDEO_EVENTS) {
video.addEventListener(eventName, this._videoBoundEvents[eventName]);
}
}
private _removeVideoEvents(video: HTMLVideoElement) {
for (const eventName of VIDEO_EVENTS) {
video.removeEventListener(eventName, this._videoBoundEvents[eventName]);
}
}
private _onVideoEvent(eventName: VideoEvent) {
this.dispatchEvent({type: eventName});
}
private _onVideoEventPlay() {
this._onVideoEvent(VideoEvent.PLAY);
}
private _onVideoEventPause() {
this._onVideoEvent(VideoEvent.PAUSE);
}
private _onVideoEventTimeUpdate() {
this._onVideoEvent(VideoEvent.TIME_UPDATE);
}
private _onVideoEventVolumeChange() {
this._onVideoEvent(VideoEvent.VOLUME_CHANGE);
}
videoStatePlaying() {
return this._video ? !this._video.paused : false;
}
videoStateMuted() {
return this._video ? this._video.muted : true;
}
videoDuration() {
return this._video?.duration || 0;
}
videoCurrentTime() {
return this._video?.currentTime || 0;
}
//
//
// VIDEO STATE
//
//
//
// time
static PARAM_CALLBACK_videoUpdateTime(node: VideoCopNode) {
node.videoUpdateTime();
}
private async videoUpdateTime() {
if (this._video) {
const param = this.p.videoTime;
if (param.isDirty()) {
await param.compute();
}
this._videoUpdateTime(this._video);
}
}
private async _videoUpdateTime(video: HTMLVideoElement) {
video.currentTime = this.pv.videoTime;
}
// play
static PARAM_CALLBACK_videoUpdatePlay(node: VideoCopNode) {
node.videoUpdatePlay();
}
private videoUpdatePlay() {
if (this._video) {
this._videoUpdatePlay(this._video);
}
}
private _videoUpdatePlay(video: HTMLVideoElement) {
if (isBooleanTrue(this.pv.play)) {
video.play();
} else {
video.pause();
}
}
// muted
static PARAM_CALLBACK_videoUpdateMuted(node: VideoCopNode) {
node.videoUpdateMuted();
}
private videoUpdateMuted() {
if (this._video) {
this._videoUpdateMuted(this._video);
}
}
private _videoUpdateMuted(video: HTMLVideoElement) {
video.muted = isBooleanTrue(this.pv.muted);
}
// loop
static PARAM_CALLBACK_videoUpdateLoop(node: VideoCopNode) {
node.videoUpdateLoop();
}
private videoUpdateLoop() {
if (this._video) {
this._videoUpdateLoop(this._video);
}
}
private _videoUpdateLoop(video: HTMLVideoElement) {
video.loop = isBooleanTrue(this.pv.loop);
}
//
// VIDEO CREATE / GET
//
//
// private _createVideoElement(){
// const mode = VIDEO_MODES[this.pv.mode]
// switch(mode){
// case VideoMode.FROM_URLS:{
// return this._videoElementFromUrls()
// }
// case VideoMode.FROM_HTML_ELEMENT:{
// return this._videoElementFromSelector()
// }
// }
// TypeAssert.unreachable(mode)
// }
// private _videoElementFromUrls(){
// const _createVideoTag=()=>{
// const video = document.createElement('video')
// this._videoUpdateLoop(video)
// this._videoUpdateMuted(video)
// video.setAttribute('crossOrigin', 'anonymous');
// video.setAttribute('autoplay', `${true}`); // to ensure it loads
// return video
// }
// const _addUrls=(video:HTMLVideoElement, urls:string[])=>{
// for(let url of urls){
// const source = document.createElement('source')
// source.src = url;
// video.append(source)
// }
// }
// return _addUrls(_createVideoTag(),this._urlParams().map(p=>p.value))
// }
private async _videoTextureFromSelector(): Promise<VideoTexture | undefined> {
const selector = this.pv.selector;
const element = document.querySelector(selector);
if (!element) {
return;
}
if (!(element instanceof HTMLVideoElement)) {
this.states.error.set(`element found with selector '${selector}' is not a video`);
return;
}
const texture = new VideoTexture(element);
return new Promise((resolve) => {
if (isHTMLVideoElementLoaded(element)) {
resolve(texture);
}
element.onloadedmetadata = () => {
resolve(texture);
};
});
}
//
//
// UTILS
//
//
urlParams() {
const urlsCount = this.pv.urlsCount;
switch (urlsCount) {
case 1: {
return [this.p.url1];
}
case 2: {
return [this.p.url1, this.p.url2];
}
case 3: {
return [this.p.url1, this.p.url2, this.p.url3];
}
}
return [];
}
private _urlsToLoad() {
return this.urlParams().map((p) => p.value);
}
static PARAM_CALLBACK_reload(node: VideoCopNode, param: BaseParamType) {
node.paramCallbackReload();
}
private paramCallbackReload() {
// set the param dirty is preferable to just the successors, in case the expression result needs to be updated
// this.p.url.set_successors_dirty();
// this.p.url.setDirty();
// this.p.url.emit(ParamEvent.ASSET_RELOAD_REQUEST);
const urlParams = this.urlParams();
for (const urlParam of urlParams) {
urlParam.setDirty();
urlParam.emit(ParamEvent.ASSET_RELOAD_REQUEST);
}
}
private async _loadTexture() {
const urls = this._urlsToLoad();
let texture: Texture | VideoTexture | null = null;
const loader = new CoreVideoTextureLoader(urls, this);
try {
texture = await loader.loadVideo();
if (texture) {
texture.matrixAutoUpdate = false;
}
} catch (e) {}
if (!texture) {
this.states.error.set(`could not load video from textures '${urls.join(',')}'`);
}
return texture;
}
}