@polygonjs/polygonjs
Version:
node-based WebGL 3D engine https://polygonjs.com
238 lines (215 loc) • 7.37 kB
text/typescript
/**
* Imports a video from your webcam
*
*/
import {Constructor} from '../../../types/GlobalTypes';
import {TypedCopNode} from './_Base';
import {VideoTexture, Texture, SRGBColorSpace} from 'three';
import {NodeParamsConfig, ParamConfig} from '../utils/params/ParamsConfig';
import {TextureParamsController, TextureParamConfig} from './utils/TextureParamsController';
import {InputCloneMode} from '../../poly/InputCloneMode';
import {CopType} from '../../poly/registers/nodes/types/Cop';
// import {coreDebug, coreMountDebugElement} from '../../../core/DebugUtils';
enum WebCamFacingMode {
USER = 'user',
ENVIRONMENT = 'environment',
}
const WEBCAM_FACING_MODES: WebCamFacingMode[] = [WebCamFacingMode.USER, WebCamFacingMode.ENVIRONMENT];
let videoCount = 0;
let streamCount = 0;
let canPlayCount = 0;
interface StreamAcquireResult {
dimensionsSwapRequired: boolean;
}
export function WebCamCopParamConfig<TBase extends Constructor>(Base: TBase) {
return class Mixin extends Base {
/** @param texture resolution */
res = ParamConfig.VECTOR2([1024, 1024]);
/** @param facingMode (on a mobile device, 'user' is the front camera, 'environment' is the back one ) */
facingMode = ParamConfig.INTEGER(WEBCAM_FACING_MODES.indexOf(WebCamFacingMode.USER), {
menu: {
entries: WEBCAM_FACING_MODES.map((name, value) => ({name, value})),
},
});
};
}
class WebCamCopParamsConfig extends TextureParamConfig(WebCamCopParamConfig(NodeParamsConfig), {
tcolorSpace: true,
colorSpace: SRGBColorSpace,
}) {}
const ParamsConfig = new WebCamCopParamsConfig();
export class WebCamCopNode extends TypedCopNode<WebCamCopParamsConfig> {
override paramsConfig = ParamsConfig;
static override type() {
return CopType.WEB_CAM;
}
private _video: HTMLVideoElement | undefined;
HTMLVideoElement() {
return this._video;
}
public readonly textureParamsController: TextureParamsController = new TextureParamsController(this);
override initializeNode() {
// coreMountDebugElement();
this.io.inputs.setCount(0, 1);
this.io.inputs.initInputsClonedState(InputCloneMode.NEVER);
}
override dispose() {
super.dispose();
this._cancelWebcamRequest();
}
setFacingMode(facingMode: WebCamFacingMode) {
this.p.facingMode.set(WEBCAM_FACING_MODES.indexOf(facingMode));
}
private _stream: MediaStream | undefined;
private _cancelWebcamRequest() {
try {
if (this._stream) {
this._stream.getTracks().forEach((track) => track.stop());
}
} catch (err) {
console.error(err);
console.warn(`failed to cancel webcam request`);
}
}
private _createHTMLVideoElement(width: number, height: number) {
// if (this._video) {
// document.body.removeChild(this._video);
// }
const element = document.createElement('video');
// element.style.display = 'none';
// // make sure to call this during the cook method
// // to ensure that res.x and res.y have a value
element.width = width;
element.height = height;
element.autoplay = true;
element.setAttribute('autoplay', 'true');
element.setAttribute('muted', 'true');
element.setAttribute('playsinline', 'true');
// coreDebug({orient: screenOrientation()});
// document.body.appendChild(element);
return element;
}
override async cook(inputContents: Texture[]) {
const inputTexture = inputContents[0];
const result = await this._streamToTexture(inputTexture, false);
if (result && result.dimensionsSwapRequired) {
await this._streamToTexture(inputTexture, true);
}
}
private async _streamToTexture(
inputTexture: Texture | null,
swapDimensions: boolean
): Promise<StreamAcquireResult | undefined> {
const x = swapDimensions ? this.pv.res.y : this.pv.res.x;
const y = swapDimensions ? this.pv.res.x : this.pv.res.y;
const width = x;
const height = y;
if (width <= 0 || height <= 0) {
this.states.error.set(`invalid resolution ${width}x${height}`);
return;
}
const _checkDimensionsValid = (): boolean => {
return width == x && height == y;
};
const setErrorFromInvalidDimensions = () => {
this.states.error.set(`recompute needed ${width}x${height} is not ${x}x${y}`);
};
const videoElement = this._createHTMLVideoElement(width, height);
// coreDebug({width, height, videoCount});
videoCount++;
const texture = new VideoTexture(videoElement);
if (inputTexture) {
TextureParamsController.copyTextureAttributes(texture, inputTexture);
}
await this.textureParamsController.update(texture);
if (!_checkDimensionsValid()) {
setErrorFromInvalidDimensions();
return;
}
return new Promise((resolve) => {
if (navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
const facingMode = WEBCAM_FACING_MODES[this.pv.facingMode];
const constraints: MediaStreamConstraints = {
video: {
width,
height,
aspectRatio: width / height,
facingMode,
},
audio: false,
};
this._cancelWebcamRequest();
navigator.mediaDevices
.getUserMedia(constraints)
.then((stream) => {
if (!_checkDimensionsValid()) {
setErrorFromInvalidDimensions();
return;
}
this._stream = stream;
// apply the stream to the video element used in the texture
// video.onload = () => {
// console.log('onLoad');
// };
// .setTexture is called only in .oncanplay
// as otherwise, if it is given to actor/trackHand
// too early
// then the computer vision would crash
// coreDebug({streamCount});
streamCount++;
let _onCanPlayProcessed = false;
videoElement.oncanplay = () => {
if (_onCanPlayProcessed) {
// make sure we only process this once
return;
}
_onCanPlayProcessed = true;
// coreDebug({
// canPlayCount,
// [canPlayCount]: `${width}x${height}`,
// });
canPlayCount++;
if (!_checkDimensionsValid()) {
setErrorFromInvalidDimensions();
return;
}
/**
* we currently need to check if the resulting video
* has the expected dimensions, as there are situations
* on mobile where it does not.
* When that is the case, we try again to get the stream
* with swapped dimensions.
*/
this._video = videoElement;
const dimensionsMatchWithoutSwap =
Math.round(videoElement.videoWidth) == Math.round(width) &&
Math.round(videoElement.videoHeight) == Math.round(height);
if (swapDimensions || dimensionsMatchWithoutSwap) {
this.setTexture(texture);
} else {
resolve({dimensionsSwapRequired: true});
}
};
videoElement.onerror = (err) => {
this.states.error.set(`webcam video error: ${err}`);
};
videoElement.srcObject = stream;
videoElement.play();
})
.catch((error) => {
console.log(`error ${width}x${height}`, error, {constraints});
this.states.error.set('Unable to access the camera/webcam');
});
} else {
const isHttps = globalThis.location.protocol.startsWith('https');
if (isHttps) {
this.states.error.set(
'MediaDevices interface not available. Please check that your connection is secure (using https)'
);
} else {
this.states.error.set('https is required to use the webcam node');
}
}
});
}
}