threear
Version:
A marker based Augmented Reality library for Three.js
422 lines (355 loc) • 11.5 kB
text/typescript
interface SourceParameters {
parent: HTMLElement;
camera: THREE.Camera | null;
renderer: THREE.WebGLRenderer | null;
sourceType: "webcam" | "image" | "video";
sourceUrl: string;
facingMode: "user" | "environment";
deviceId: any;
sourceWidth: number;
sourceHeight: number;
displayWidth: number;
displayHeight: number;
}
/**
* Source describes how and where THREE AR should accept imagery to
* track markers for. Images, Video and the Webcam can be used as sources.
* @param parameters parameters for determining if it should come from a webcam or a video
*/
export class Source {
public domElement: HTMLImageElement | HTMLVideoElement | undefined;
private currentTorchStatus: boolean;
private parameters: SourceParameters;
constructor(parameters: Partial<SourceParameters>) {
if (!parameters.renderer) {
throw Error("ThreeJS Renderer is required");
}
if (!parameters.camera) {
throw Error("ThreeJS Camera is required");
}
this.currentTorchStatus = false;
// handle default parameters
this.parameters = {
parent: document.body,
renderer: null,
camera: null,
// type of source - ['webcam', 'image', 'video']
sourceType: "webcam",
// url of the source - valid if sourceType = image|video
sourceUrl: "",
// Device id of the camera to use (optional)
deviceId: null,
facingMode: "environment",
// resolution of at which we initialize in the source image
sourceWidth: 640,
sourceHeight: 480,
// resolution displayed for the source
displayWidth: 640,
displayHeight: 480
};
this.setParameters(parameters);
}
public setParameters(parameters: any) {
if (!parameters) {
return;
}
for (const key in parameters) {
if (key) {
const newValue = parameters[key];
if (newValue === undefined) {
console.warn(key + "' parameter is undefined.");
continue;
}
const currentValue = (this.parameters as any)[key];
if (currentValue === undefined) {
console.warn(key + "' is not a property of this Source.");
continue;
}
(this.parameters as any)[key] = newValue;
}
}
}
get renderer() {
return this.parameters.renderer;
}
get camera() {
return this.parameters.camera;
}
public dispose() {
if (this.parameters.parent && this.domElement) {
this.parameters.parent.removeChild(this.domElement);
}
}
public initialize() {
return new Promise((resolve, reject) => {
const onReady = () => {
if (!this.domElement) {
reject("domElement not defined");
return;
}
this.onResizeElement();
this.parameters.parent.appendChild(this.domElement);
resolve();
};
const onError = (message: Error | string) => {
reject(message);
};
if (this.parameters.sourceType === "image") {
this.domElement = this._initSourceImage(onReady, onError);
} else if (this.parameters.sourceType === "video") {
this.domElement = this._initSourceVideo(onReady, onError);
} else if (this.parameters.sourceType === "webcam") {
const webcam = this._initSourceWebcam(onReady, onError);
if (!webcam) {
reject("Webcam source could not be established");
return;
}
this.domElement = webcam;
} else {
reject("Source type not recognised. Try: 'image', 'video', 'webcam'");
return;
}
this.positionSourceDomElement();
return this;
});
}
/**
* Determine if the device supports torch capability
*/
public hasMobileTorch(domElement: HTMLVideoElement) {
const stream = domElement.srcObject;
if (stream instanceof MediaStream) {
const videoTrack = stream.getVideoTracks()[0];
// if videoTrack.getCapabilities() doesnt exist, return false now
if (videoTrack.getCapabilities === undefined) {
return false;
}
const capabilities = videoTrack.getCapabilities();
return (capabilities as any).torch ? true : false;
}
return false;
}
/**
* Toggle the flash/torch of the mobile phone if possible.
* See: https://www.oberhofer.co/mediastreamtrack-and-its-capabilities/
*/
public toggleMobileTorch(domElement: HTMLVideoElement) {
if (!this.hasMobileTorch(domElement) === true) {
return;
}
const stream = domElement.srcObject;
if (this.currentTorchStatus === undefined) {
this.currentTorchStatus = false;
}
if (stream instanceof MediaStream) {
const videoTrack = stream.getVideoTracks()[0];
const capabilities = videoTrack.getCapabilities();
// TypeScript doesn't recognise .torch
if (!(capabilities as any).torch) {
console.warn("Torch is not avaiable to be toggled");
return;
}
// Toggle torch status
this.currentTorchStatus = !this.currentTorchStatus;
videoTrack
.applyConstraints({
advanced: [{ torch: this.currentTorchStatus } as any]
})
.catch((error: any) => {
throw error;
});
}
}
public onResizeElement() {
if (!this.domElement) {
console.warn("Can't resize as domElement is not defined on source");
return;
}
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
let sourceHeight = 0;
let sourceWidth = 0;
// compute sourceWidth, sourceHeight
if (this.domElement instanceof HTMLImageElement) {
sourceWidth = this.domElement.naturalWidth;
sourceHeight = this.domElement.naturalHeight;
} else if (this.domElement instanceof HTMLVideoElement) {
sourceWidth = this.domElement.videoWidth;
sourceHeight = this.domElement.videoHeight;
}
// compute sourceAspect
const sourceAspect = sourceWidth / sourceHeight;
// compute screenAspect
const screenAspect = screenWidth / screenHeight;
// if screenAspect < sourceAspect, then change the width, else change the height
if (screenAspect < sourceAspect) {
// compute newWidth and set .width/.marginLeft
const newWidth = sourceAspect * screenHeight;
this.domElement.style.width = newWidth + "px";
this.domElement.style.marginLeft = -(newWidth - screenWidth) / 2 + "px";
// init style.height/.marginTop to normal value
this.domElement.style.height = screenHeight + "px";
this.domElement.style.marginTop = "0px";
} else {
// compute newHeight and set .height/.marginTop
const newHeight = 1 / (sourceAspect / screenWidth);
this.domElement.style.height = newHeight + "px";
this.domElement.style.marginTop = -(newHeight - screenHeight) / 2 + "px";
// init style.width/.marginLeft to normal value
this.domElement.style.width = screenWidth + "px";
this.domElement.style.marginLeft = "0px";
}
}
/**
* Copy the dimensions of the domElement of the source to another given domElement
* @param otherElement the target element to copy the size to, from the Source dom element
*/
public copyElementSizeTo(otherElement: any) {
if (!this.domElement) {
console.warn("Cant set size to match domElement as it is not defined");
return;
}
if (window.innerWidth > window.innerHeight) {
// landscape
otherElement.style.width = this.domElement.style.width;
otherElement.style.height = this.domElement.style.height;
otherElement.style.marginLeft = this.domElement.style.marginLeft;
otherElement.style.marginTop = this.domElement.style.marginTop;
} else {
// portrait
otherElement.style.height = this.domElement.style.height;
otherElement.style.width =
(parseInt(otherElement.style.height, 10) * 4) / 3 + "px";
otherElement.style.marginLeft =
(window.innerWidth - parseInt(otherElement.style.width, 10)) / 2 + "px";
otherElement.style.marginTop = 0;
}
}
private _initSourceImage(
onReady: () => any,
onError: (message: string) => any
) {
if (!this.parameters.sourceUrl) {
throw Error("No source URL provided");
}
const domElement = document.createElement("img");
domElement.src = this.parameters.sourceUrl;
domElement.width = this.parameters.sourceWidth;
domElement.height = this.parameters.sourceHeight;
domElement.style.width = this.parameters.displayWidth + "px";
domElement.style.height = this.parameters.displayHeight + "px";
domElement.onload = () => onReady();
return domElement;
}
private _initSourceVideo(
onReady: () => any,
onError: (message: string) => any
) {
const domElement = document.createElement("video");
domElement.src = this.parameters.sourceUrl;
domElement.style.objectFit = "initial";
domElement.autoplay = true;
(domElement as any).webkitPlaysinline = true;
domElement.controls = false;
domElement.loop = true;
domElement.muted = true;
// trick to trigger the video on android
document.body.addEventListener("click", function onClick() {
document.body.removeEventListener("click", onClick);
domElement.play();
});
domElement.width = this.parameters.sourceWidth;
domElement.height = this.parameters.sourceHeight;
domElement.style.width = this.parameters.displayWidth + "px";
domElement.style.height = this.parameters.displayHeight + "px";
// wait until the video stream is ready
domElement.addEventListener(
"loadeddata",
() => {
onReady();
},
false
);
return domElement;
}
private _initSourceWebcam(
onReady: () => any,
onError: (message: string) => any
) {
const domElement = document.createElement("video");
domElement.setAttribute("autoplay", "");
domElement.setAttribute("muted", "");
domElement.setAttribute("playsinline", "");
domElement.style.width = this.parameters.displayWidth + "px";
domElement.style.height = this.parameters.displayHeight + "px";
// check API is available
if (
navigator.mediaDevices === undefined ||
navigator.mediaDevices.enumerateDevices === undefined ||
navigator.mediaDevices.getUserMedia === undefined
) {
let fctName = "";
if (navigator.mediaDevices === undefined) {
fctName = "navigator.mediaDevices";
} else if (navigator.mediaDevices.enumerateDevices === undefined) {
fctName = "navigator.mediaDevices.enumerateDevices";
} else if (navigator.mediaDevices.getUserMedia === undefined) {
fctName = "navigator.mediaDevices.getUserMedia";
}
onError("WebRTC issue-! " + fctName + " not present in your browser");
return;
}
// get available devices
navigator.mediaDevices
.enumerateDevices()
.then(devices => {
const userMediaConstraints = {
audio: false,
video: {
facingMode: this.parameters.facingMode,
width: {
ideal: this.parameters.sourceWidth
},
height: {
ideal: this.parameters.sourceHeight
}
}
};
if (null !== this.parameters.deviceId) {
(userMediaConstraints as any).video.deviceId = {
exact: this.parameters.deviceId
};
}
// get a device which satisfy the constraints
navigator.mediaDevices
.getUserMedia(userMediaConstraints)
.then(function success(stream) {
// set the .src of the domElement
domElement.srcObject = stream;
// to start the video, when it is possible to start it only on userevent. like in android
document.body.addEventListener("click", () => {
domElement.play();
});
domElement.addEventListener("loadedmetadata", event => {
onReady();
});
})
.catch(error => {
onError(error);
});
})
.catch(error => {
onError(error);
});
return domElement;
}
private positionSourceDomElement() {
if (this.domElement) {
this.domElement.style.position = "absolute";
this.domElement.style.top = "0px";
this.domElement.style.left = "0px";
this.domElement.style.zIndex = "-2";
}
}
}
export default Source;