UNPKG

@nuralogix.ai/anura-web-core-sdk

Version:

Anura Web Core SDK

653 lines (645 loc) 21.3 kB
const CameraControllerEvents = { "CAMERA_STATUS": "cameraStatus", "SELECTED_DEVICE_CHANGED": "selectedDeviceChanged", "DEVICE_PIXEL_RATIO_CHANGED": "devicePixelRatioChanged", "MEDIA_DEVICE_LIST_CHANGED": "mediaDeviceListChanged" }; const roundValue = (value) => { let result = value.toFixed(2); if (result === "0") result = value.toPrecision(1); return Number(result); }; const getNewDeviceId = (deviceId, mediaDevices) => { let newDeviceId = ""; if (mediaDevices.length > 0) { if (!mediaDevices.find((mediaDevice) => mediaDevice.device.deviceId === deviceId) || deviceId === "") { newDeviceId = mediaDevices[0].device.deviceId; } if (mediaDevices.find((mediaDevice) => mediaDevice.device.deviceId === deviceId)) { newDeviceId = deviceId; } } return newDeviceId; }; let removeListener = null; class CameraController extends EventTarget { mediaDevices = []; selectedDeviceId = ""; cameraStream = null; cameraWidth = 0; cameraHeight = 0; cameraFrameRate = 0; settingsInputIds = ["iso", "exposureTime", "focusDistance", "colorTemperature", "zoom"]; standardSettingsIds = ["contrast", "saturation", "sharpness", "brightness", "exposureCompensation"]; videoTrackCapabilities = {}; #deviceChangeInProgress = false; constructor() { super(); navigator.mediaDevices.ondevicechange = async () => { if (!this.#deviceChangeInProgress) { this.#deviceChangeInProgress = true; await this.list(); this.#deviceChangeInProgress = false; } }; this.updatePixelRatio = this.updatePixelRatio.bind(this); this.updatePixelRatio(); } static init() { return new this(); } updatePixelRatio() { if (removeListener != null) { removeListener(); } const mqString = `(resolution: ${window.devicePixelRatio}dppx)`; const media = matchMedia(mqString); media.addEventListener("change", this.updatePixelRatio); removeListener = () => { media.removeEventListener("change", this.updatePixelRatio); }; setTimeout(() => { this.#dispatch(this.#getDeviceAspectRatioChangedEvent()); }, 1e3); this.#dispatch(this.#getDeviceAspectRatioChangedEvent()); } /** Enumerates the list of video input devices * * The label field for each MediaDevice will be empty if the permission * is not equal to `granted` */ async enumerate() { try { const devices = await navigator.mediaDevices.enumerateDevices(); const mediaDevices = devices.filter((device) => device.kind === "videoinput"); const cameras = mediaDevices.map((mediaDevice) => { const capabilities = mediaDevice.getCapabilities(); const deviceInfo = { device: { label: mediaDevice.label, deviceId: mediaDevice.deviceId, kind: "videoinput", groupId: mediaDevice.groupId }, capabilities }; return deviceInfo; }); return cameras; } catch (error) { console.error("Error enumerating devices:", error); return []; } } /** * Requests camera permissions from the user. * @returns Promise<boolean> */ async requestPermission() { try { this.cameraStream = await navigator.mediaDevices.getUserMedia({ video: true }); return true; } catch (error) { if (error instanceof DOMException && error.name === "NotAllowedError") { console.error("Camera permission denied."); } else if (error instanceof DOMException && error.name === "NotFoundError") { console.error("No camera devices found."); } else { console.error("Error requesting camera permission:", error); } return false; } finally { this.stop(); } } /** Populates the list of `mediaDevices` and sets `selectedDeviceId` * * If camera permission granted it will populate `mediaDevices` with the list of available cameras and * sets `selectedDeviceId` to that of the first available camera. * * If camera permission is blocked, it will set `mediaDevices` to [] and `selectedDeviceId` to an empty string * */ async list() { this.mediaDevices = await this.enumerate(); this.selectedDeviceId = getNewDeviceId(this.selectedDeviceId, this.mediaDevices); this.#dispatch(this.#getMediaDeviceListChangedEvent()); this.#dispatch(this.#getSelectedDeviceChangedEvent()); } /** Change selected media device Id * */ setDeviceId(deviceId) { if (this.mediaDevices.length) { if (this.mediaDevices.find((d) => d.device.deviceId === deviceId)) { this.selectedDeviceId = deviceId; this.#dispatch(this.#getSelectedDeviceChangedEvent()); } } } #setVideoTrackCapabilities() { if (this.cameraStream) { const [videoTrack] = this.cameraStream.getVideoTracks(); const videoTrackSettings = videoTrack.getSettings(); const { width, height, frameRate } = videoTrackSettings; const capabilities = videoTrack.getCapabilities(); this.cameraWidth = width || 0; this.cameraHeight = height || 0; this.cameraFrameRate = frameRate || 0; this.videoTrackCapabilities = Object.fromEntries( [...this.settingsInputIds, ...this.standardSettingsIds].map( (id) => { const mediaSettingsRange = capabilities[id]; return [id, { isSupported: mediaSettingsRange ? true : false, min: mediaSettingsRange && mediaSettingsRange.min ? roundValue(mediaSettingsRange.min) : 0, max: mediaSettingsRange && mediaSettingsRange.max ? roundValue(mediaSettingsRange.max) : 0, step: mediaSettingsRange && mediaSettingsRange.step ? roundValue(mediaSettingsRange.step) : 0, value: mediaSettingsRange && videoTrackSettings[id] ? roundValue(videoTrackSettings[id]) : 0 }]; } ) ); } } async #startCameraWithFallbackResolution() { const fallbackConstraints = { video: { width: 640, height: 360, deviceId: { exact: this.selectedDeviceId } } }; this.cameraStream = await navigator.mediaDevices.getUserMedia(fallbackConstraints); this.#setVideoTrackCapabilities(); } /** Start camera * */ async start(frameWidth, frameHeight, facingMode) { this.stop(); const supports = navigator.mediaDevices.getSupportedConstraints(); for (const constraint of ["facingMode", "aspectRatio"]) { if (!(constraint in supports)) { throw new OverconstrainedError(constraint, "not supported"); } } if (frameWidth / frameHeight !== 16 / 9) { throw new Error("Resolution must have a 16:9 aspect ratio."); } const constraints = { video: { ...facingMode ? { facingMode } : {}, deviceId: this.selectedDeviceId, aspectRatio: { exact: 16 / 9 }, frameRate: { ideal: 30, min: 15, max: 30 }, width: { ideal: frameWidth }, height: { ideal: frameHeight } } }; try { this.cameraStream = await navigator.mediaDevices.getUserMedia(constraints); this.#setVideoTrackCapabilities(); this.#dispatch(this.#getCameraStatusEvent(true)); return true; } catch (error) { if (error.name != "OverconstrainedError") { console.log("Error opening camera", error); } else { console.log(`This camera cannot produce the requested ${error.constraint}.`); } try { console.log(`Attempt to open the camera with fallback resolution: 640 x 360 pixels`); await this.#startCameraWithFallbackResolution(); this.#dispatch(this.#getCameraStatusEvent(true)); return true; } catch (fallbackError) { console.error("Fallback resolution also failed.", fallbackError); return false; } } } /** Return VideoTrackCapabilities * */ getVideoTrackCapabilities() { return this.videoTrackCapabilities; } /** Stop camera * */ stop() { if (this.cameraStream) { this.cameraStream.getTracks().forEach((track) => track.stop()); this.cameraStream = null; this.#dispatch(this.#getCameraStatusEvent(false)); this.cameraWidth = 0; this.cameraHeight = 0; this.cameraFrameRate = 0; } } #getCameraStatusEvent(isOpen) { const event = new CustomEvent(CameraControllerEvents.CAMERA_STATUS, { detail: { isOpen, capabilities: this.videoTrackCapabilities } }); return event; } #getSelectedDeviceChangedEvent() { const event = new CustomEvent(CameraControllerEvents.SELECTED_DEVICE_CHANGED, { detail: { deviceId: this.selectedDeviceId } }); return event; } #getMediaDeviceListChangedEvent() { const event = new CustomEvent(CameraControllerEvents.MEDIA_DEVICE_LIST_CHANGED, { detail: { mediaDevices: this.mediaDevices } }); return event; } #getDeviceAspectRatioChangedEvent() { const event = new CustomEvent(CameraControllerEvents.DEVICE_PIXEL_RATIO_CHANGED, { detail: { devicePixelRatio: window.devicePixelRatio } }); return event; } #dispatch(event) { this.dispatchEvent(event); } } class VideoController { mimeCodec; videoElement = document.createElement("video"); videoSource = document.createElement("source"); canvas = document.createElement("canvas"); mediaStream = null; bytesDownloaded = 0; ctx; static init(settings) { return new this(settings); } constructor(settings) { this.mimeCodec = settings.mimeCodec; this.videoLoadedCallback = settings.videoLoadedCallback; this.videoEndedCallback = settings.videoEndedCallback; this.videoFrameCallback = this.videoFrameCallback.bind(this); this.ctx = this.canvas.getContext("2d"); } // getBytesCallback(bytes: number): void {} async videoLoadedCallback() { } videoEndedCallback() { } // getMediaSource() { // // https://bitmovin.com/managed-media-source // // New Managed Media Source in Safari 17 // if (window.ManagedMediaSource) { // console.log('Using ManagedMediaSource'); // return new window.ManagedMediaSource(); // } // if (window.MediaSource) { // console.log('Using MediaSource'); // return new window.MediaSource(); // } // return null; // } videoFrameCallback(now, metadata) { if (this.ctx) this.ctx.drawImage(this.videoElement, 0, 0, this.canvas.width, this.canvas.height); this.videoElement.requestVideoFrameCallback(this.videoFrameCallback); } captureFromCanvas() { const { videoWidth, videoHeight } = this.videoElement; this.canvas.width = videoWidth; this.canvas.height = videoHeight; this.videoElement.addEventListener("play", () => { this.videoElement.requestVideoFrameCallback(this.videoFrameCallback); }); this.videoElement.addEventListener("playing", () => { this.mediaStream = this.canvas.captureStream(); }); this.videoElement.addEventListener("ended", () => { if (this.mediaStream) { this.mediaStream.getTracks().forEach((track) => track.stop()); this.videoElement.pause(); this.videoEndedCallback(); } }); } captureFromVideoElement() { if ("captureStream" in HTMLVideoElement.prototype) { this.mediaStream = this.videoElement.captureStream(); } } setMediaStream() { if ("captureStream" in HTMLVideoElement.prototype) { this.captureFromVideoElement(); this.videoElement.addEventListener("timeupdate", () => { const { currentTime, duration } = this.videoElement; if (duration === currentTime && this.mediaStream) { this.mediaStream.getTracks().forEach((track) => track.stop()); this.videoElement.pause(); this.videoEndedCallback(); } }); } else { console.log("captureFromVideoElement not supported"); this.captureFromCanvas(); } } // async getBuffer(url: string) { // this.bytesDownloaded = 0; // let videoBuffer = new ArrayBuffer(0); // const response = await fetch(url); // const { body } = response; // if (body) { // const reader = body.getReader(); // while (true) { // const {value, done} = await reader.read(); // if (done) break; // this.bytesDownloaded += value.byteLength; // this.getBytesCallback(bytesToMegaBytes(this.bytesDownloaded)); // videoBuffer = await new Blob([ videoBuffer, value ]).arrayBuffer(); // } // return videoBuffer; // } // } async init(url) { if (typeof url === "undefined" || typeof url !== "string") return; this.videoElement = document.createElement("video"); this.videoSource = document.createElement("source"); this.videoSource.type = this.mimeCodec; this.videoSource.src = url; this.videoElement.appendChild(this.videoSource); this.videoElement.playsInline = true; this.videoElement.onloadedmetadata = () => { this.setMediaStream(); this.videoLoadedCallback(); }; } } let interval; class ImageSequenceController extends EventTarget { canvas = document.createElement("canvas"); ctx; imageBitmaps = []; canDrawImage = true; settings = { url: "", imageName: { prefix: "", numberOfDigits: 4, extension: "" } }; static init(settings, videoEndedCallback) { return new this(settings, videoEndedCallback); } constructor(settings, videoEndedCallback) { super(); this.settings = settings; this.videoEndedCallback = videoEndedCallback; this.ctx = this.canvas.getContext("2d"); } videoEndedCallback() { } getImageName(sequence) { const { url, imageName } = this.settings; const { prefix, numberOfDigits, extension } = imageName; return `${url}${prefix}${sequence.toString().padStart(numberOfDigits, "0")}.${extension}`; } async getImageBitmapFromUrl(url) { const response = await fetch(url); const fileBlob = await response.blob(); const imageBitmap = await createImageBitmap(fileBlob); return imageBitmap; } async getImageBitmapFromArrayOfUrls(urls) { const imageBitmaps = await Promise.all(urls.map(async (url) => { const imageBitmap = await this.getImageBitmapFromUrl(url); return imageBitmap; })); this.setImageBitmaps(imageBitmaps); } setImageBitmaps(imageBitmaps) { this.imageBitmaps = imageBitmaps; } drawImageBitmapOnCanvas(imageBitmap) { if (this.ctx) { const { width, height } = imageBitmap; if (this.canvas.width !== width) this.canvas.width = width; if (this.canvas.height !== height) this.canvas.height = height; this.ctx.drawImage(imageBitmap, 0, 0); imageBitmap.close(); } } getMediaStreamReadyEvent(isReady) { const event = new CustomEvent("imageSequenceMediaStreamReady" /* MEDIASTREAM_READY */, { detail: { isReady } }); return event; } drawImagesOnCanvas() { const { length } = this.imageBitmaps; let frameCounter = 0; interval = setInterval( () => { this.drawImageBitmapOnCanvas(this.imageBitmaps[frameCounter]); if (frameCounter === 0) { this.dispatchEvent(this.getMediaStreamReadyEvent(true)); } if (frameCounter === length - 1 || !this.canDrawImage) { clearInterval(interval); this.videoEndedCallback(); } frameCounter += 1; }, 33 ); } } let brotliDecode = (bytes) => { return new Int8Array(bytes); }; class AssetDownloader extends EventTarget { canDecompress = false; static init() { return new this(); } /* Dispatches a custom event */ dispatch(eventType, payload) { this.dispatchEvent(new CustomEvent(eventType, { detail: payload })); } // BytesDownloaded #dispatch(event) { this.dispatchEvent(event); } getBytesDownloadedEvent(bytes, uncompressedSize, url, done) { const event = new CustomEvent("bytesDownloaded" /* BYTES_DOWNLOADED */, { detail: { bytes, uncompressedSize, url, done } }); return event; } getBytesDownloadErrorEvent(url, error) { const event = new CustomEvent("downloadedError" /* DOWNLOAD_ERROR */, { detail: { url, error } }); return event; } /** Decompresses a Brotli compressed/based 64 encoded string and returns an ArrayBuffer */ decompressBrotli(compressedBuffer) { const binaryString = atob(compressedBuffer); const byteArray = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { byteArray[i] = binaryString.charCodeAt(i); } const int8Array = new Int8Array(byteArray.length); for (let i = 0; i < byteArray.length; i++) { int8Array[i] = byteArray[i] >= 128 ? byteArray[i] - 256 : byteArray[i]; } const decompressed = brotliDecode(int8Array); return decompressed.buffer; } /** Returns either an ArrayBuffer or undefined */ async fetchAsset(assetSize, path, file, decompress, compressionType) { const url = path + file; const uncompressedSize = assetSize.find((asset) => asset.file === file).uncompressedSize; const dispatch = (bytes, uncompressedSize2, url2, done) => this.#dispatch(this.getBytesDownloadedEvent(bytes, uncompressedSize2, url2, done)); try { const response = await fetch(url); const reader = response.body?.getReader(); if (!reader) { throw new Error("Failed to get reader from response body."); } let bytes = 0; const stream = new ReadableStream({ async start(controller) { await pump(); async function pump() { const { done, value } = await reader.read(); if (done) { controller.close(); dispatch(bytes, uncompressedSize, url, true); return; } if (value) { controller.enqueue(value); bytes += value.length; dispatch(bytes, uncompressedSize, url, false); } await pump(); } } }); const newStream = new Response(stream); if (decompress) { const json = await newStream.json(); const { base64EncodedValue } = json; if (compressionType === "gzip") { await this.#setBrotliDecode(base64EncodedValue); return void 0; } const arrayBuffer = this.decompressBrotli(base64EncodedValue); return arrayBuffer; } else { const arrayBuffer = await newStream.arrayBuffer(); return arrayBuffer; } } catch (e) { this.#dispatch(this.getBytesDownloadErrorEvent(url, e)); } } async #setBrotliDecode(base64EncodedValue) { const compressedData = Uint8Array.from(atob(base64EncodedValue), (c) => c.charCodeAt(0)); const compressedStream = new Blob([compressedData]).stream(); const decompressedStream = compressedStream.pipeThrough(new DecompressionStream("gzip")); const decompressedArrayBuffer = await new Response(decompressedStream).arrayBuffer(); const text = new TextDecoder().decode(new Uint8Array(decompressedArrayBuffer)); const blob = new Blob([text], { type: "application/javascript" }); const blobUrl = URL.createObjectURL(blob); const module = await import(blobUrl); brotliDecode = module.BrotliDecode; this.canDecompress = true; URL.revokeObjectURL(blobUrl); } } const helpers = { /** * Example usage: * * ```js * const camera = CameraController.init(); * ``` * */ CameraController, /** * Example usage: * * ```js * const video = VideoController.init({ * mimeCodec: 'video/mp4', * getBytesCallback: () => {}, * videoLoadedCallback: async () => {} *}); * ``` * */ VideoController, /** * Example usage: * * ```js * const imageSequence = ImageSequenceController.init({ url: 'http://localhost/frames/', imageName: { prefix: 'img', numberOfDigits: 4, extension: 'jpg', } }); * ``` * */ ImageSequenceController, /** * Example usage: * * ```js * const assetDownloader = AssetDownloader.init(); * const onBytesDownloaded = (e: CustomEvent) => { const { bytes, uncompressedSize, url, done } = e.detail; console.log(bytes, uncompressedSize, url, done); }; assetDownloader.addEventListener('bytesDownloaded', onBytesDownloaded as EventListener); const onDownloadedError = (e: CustomEvent) => { const { error, url } = e.detail; console.log(error, url); }; assetDownloader.addEventListener('downloadedError', onDownloadedError as EventListener); * * const isSimdSupported = await assetDownloader.isSimdSupported(); * const arrayBuffer = await assetDownloader.fetchAsset(url, true, 'brotli'); * ``` * */ AssetDownloader }; export { CameraControllerEvents, helpers as default };