1
0
mirror of https://github.com/psychopy/psychojs.git synced 2025-05-10 10:40:54 +00:00

Camera: unified API with PsychoPy, Shelf: throttling

This commit is contained in:
Alain Pitiot 2022-05-27 13:04:48 +02:00
parent 3b0308a77f
commit 195991eec3
10 changed files with 264 additions and 181 deletions

View File

@ -312,7 +312,8 @@ export class Window extends PsychObject
* @function
* @public
*/
addPixiObject (pixiObject) {
addPixiObject(pixiObject)
{
this._stimsContainer.addChild(pixiObject);
}
@ -323,7 +324,8 @@ export class Window extends PsychObject
* @function
* @public
*/
removePixiObject (pixiObject) {
removePixiObject(pixiObject)
{
this._stimsContainer.removeChild(pixiObject);
}

View File

@ -41,6 +41,14 @@ export class Shelf extends PsychObject
this._addAttribute('autoLog', autoLog);
this._addAttribute('status', Shelf.Status.READY);
// minimum period of time, in ms, before two calls to Shelf methods, i.e. throttling:
this._throttlingPeriod_ms = 5000.0;
// timestamp of the last actual call to a Shelf method:
this._lastCallTimestamp = 0.0;
// timestamp of the last scheduled call to a Shelf method:
this._lastScheduledCallTimestamp = 0.0;
}
/**
@ -60,12 +68,9 @@ export class Shelf extends PsychObject
context: `when getting the value associated with key: ${JSON.stringify(key)}`
};
// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
try
{
this._checkAvailability("getValue");
this._status = Shelf.Status.BUSY;
await this._checkAvailability("getValue");
this._checkKey(key);
// prepare the request:
@ -130,12 +135,9 @@ export class Shelf extends PsychObject
context: `when setting the value associated with key: ${JSON.stringify(key)}`
};
// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
try
{
this._checkAvailability("setValue");
this._status = Shelf.Status.BUSY;
await this._checkAvailability("setValue");
this._checkKey(key);
// prepare the request:
@ -195,12 +197,9 @@ export class Shelf extends PsychObject
context: `when getting the names of the fields in the dictionary record associated with key: ${JSON.stringify(key)}`
};
// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
try
{
this._checkAvailability("getDictionaryFieldNames");
this._status = Shelf.Status.BUSY;
await this._checkAvailability("getDictionaryFieldNames");
this._checkKey(key);
// prepare the request:
@ -260,12 +259,9 @@ export class Shelf extends PsychObject
context: `when getting value of field: ${fieldName} in the dictionary record associated with key: ${JSON.stringify(key)}`
};
// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
try
{
this._checkAvailability("getDictionaryValue");
this._status = Shelf.Status.BUSY;
await this._checkAvailability("getDictionaryValue");
this._checkKey(key);
// prepare the request:
@ -330,12 +326,9 @@ export class Shelf extends PsychObject
context: `when setting a field with name: ${fieldName} in the dictionary record associated with key: ${JSON.stringify(key)}`
};
// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
try
{
this._checkAvailability("setDictionaryField");
this._status = Shelf.Status.BUSY;
await this._checkAvailability("setDictionaryField");
this._checkKey(key);
// prepare the request:
@ -451,12 +444,9 @@ export class Shelf extends PsychObject
context: `when incrementing the integer counter with key: ${JSON.stringify(key)}`
};
// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
try
{
this._checkAvailability("increment");
this._status = Shelf.Status.BUSY;
await this._checkAvailability("increment");
this._checkKey(key);
// prepare the request:
@ -518,12 +508,9 @@ export class Shelf extends PsychObject
context: `when getting the name of a group, using a counterbalanced design, with key: ${JSON.stringify(key)}`
};
// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
try
{
this._checkAvailability("counterBalanceSelect");
this._status = Shelf.Status.BUSY;
await this._checkAvailability("counterBalanceSelect");
this._checkKey(key);
// prepare the request:
@ -575,7 +562,7 @@ export class Shelf extends PsychObject
* @function
* @public
* @param {string} [methodName=""] name of the method requiring a check
* @throw {Object.<string, *>} exception when it is not possible to run shelf commands
* @throw {Object.<string, *>} exception when it is not possible to run the given shelf command
*/
_checkAvailability(methodName = "")
{
@ -588,6 +575,37 @@ export class Shelf extends PsychObject
error: 'the experiment has to be run on the server: shelf commands are not available locally'
}
}
// throttle calls to Shelf methods:
const self = this;
return new Promise((resolve, reject) =>
{
const now = performance.now();
// if the last scheduled call already occurred, schedule this one as soon as possible,
// taking into account the throttling period:
let timeoutDuration;
if (now > self._lastScheduledCallTimestamp)
{
timeoutDuration = Math.max(0.0, self._throttlingPeriod_ms - (now - self._lastCallTimestamp));
self._lastScheduledCallTimestamp = now + timeoutDuration;
}
// otherwise, schedule it after the next call:
else
{
self._lastScheduledCallTimestamp += self._throttlingPeriod_ms;
timeoutDuration = self._lastScheduledCallTimestamp;
}
setTimeout(
() => {
self._lastCallTimestamp = performance.now();
self._status = Shelf.Status.BUSY;
resolve();
},
timeoutDuration
);
});
}
/**

View File

@ -2,7 +2,7 @@
* Manager handling the recording of video signal.
*
* @author Alain Pitiot
* @version 2021.2.0
* @version 2022.2.0
* @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
@ -18,15 +18,11 @@ import {ExperimentHandler} from "../data/ExperimentHandler.js";
/**
* <p>This manager handles the recording of video signal.</p>
*
* @name module:visual.Camera
* @name module:hardware.Camera
* @class
* @param {Object} options
* @param {module:core.Window} options.win - the associated Window
* @param {string} [options.format='video/webm;codecs=vp9'] the video format
* @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the
* participant to wait for the camera to be initialised
* @param {string} [options.dialogMsg="Please wait a few moments while the camera initialises"] -
* default message informing the participant to wait for the camera to initialise
* @param {Clock} [options.clock= undefined] - an optional clock
* @param {boolean} [options.autoLog= false] - whether or not to log
*
@ -34,7 +30,7 @@ import {ExperimentHandler} from "../data/ExperimentHandler.js";
*/
export class Camera extends PsychObject
{
constructor({win, name, format, showDialog, dialogMsg = "Please wait a few moments while the camera initialises", clock, autoLog} = {})
constructor({win, name, format, clock, autoLog} = {})
{
super(win._psychoJS);
@ -45,23 +41,8 @@ export class Camera extends PsychObject
this._addAttribute("autoLog", autoLog, false);
this._addAttribute("status", PsychoJS.Status.NOT_STARTED);
// open pop-up dialog:
if (showDialog)
{
this.psychoJS.gui.dialog({
warning: dialogMsg,
showOK: false,
});
}
// prepare the recording:
this._prepareRecording().then( () =>
{
if (showDialog)
{
this.psychoJS.gui.closeDialog();
}
})
this._stream = null;
this._recorder = null;
if (this._autoLog)
{
@ -70,14 +51,69 @@ export class Camera extends PsychObject
}
/**
* Query whether or not the camera is ready to record.
* Prompt the user for permission to use the camera on their device.
*
* @name module:visual.Camera#isReady
* @name module:hardware.Camera#authorize
* @function
* @public
* @param {boolean} [showDialog=false] - whether to open a dialog box to inform the
* participant to wait for the camera to be initialised
* @param {string} [dialogMsg] - the dialog message
* @returns {boolean} whether or not the camera is ready to record
*/
isReady()
async authorize(showDialog = false, dialogMsg = undefined)
{
const response = {
origin: "Camera.authorize",
context: "when authorizing access to the device's camera"
};
// open pop-up dialog, if required:
if (showDialog)
{
dialogMsg ??= "Please wait a few moments while the camera initialises. You may need to grant permission to your browser to use the camera.";
this.psychoJS.gui.dialog({
warning: dialogMsg,
showOK: false,
});
}
try
{
// prompt for permission and get a MediaStream:
// TODO use size constraints [https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia]
this._stream = await navigator.mediaDevices.getUserMedia({
video: true
});
}
catch (error)
{
// close the dialog, if need be:
if (showDialog)
{
this.psychoJS.gui.closeDialog();
}
this._status = PsychoJS.Status.ERROR;
throw {...response, error};
}
// close the dialog, if need be:
if (showDialog)
{
this.psychoJS.gui.closeDialog();
}
}
/**
* Query whether the camera is ready to record.
*
* @name module:hardware.Camera#isReady
* @function
* @public
* @returns {boolean} true if the camera is ready to record, false otherwise
*/
get isReady()
{
return (this._recorder !== null);
}
@ -85,7 +121,7 @@ export class Camera extends PsychObject
/**
* Get the underlying video stream.
*
* @name module:visual.Camera#getStream
* @name module:hardware.Camera#getStream
* @function
* @public
* @returns {MediaStream} the video stream
@ -98,7 +134,7 @@ export class Camera extends PsychObject
/**
* Get a video element pointing to the Camera stream.
*
* @name module:visual.Camera#getVideo
* @name module:hardware.Camera#getVideo
* @function
* @public
* @returns {HTMLVideoElement} a video element
@ -130,14 +166,36 @@ export class Camera extends PsychObject
}
/**
* Submit a request to start the recording.
* Open the video stream.
*
* @name module:visual.Camera#start
* @name module:hardware.Camera#open
* @function
* @public
* @return {Promise} promise fulfilled when the recording actually started
*/
start()
open()
{
if (this._stream === null)
{
throw {
origin: "Camera.open",
context: "when opening the camera's video stream",
error: "access to the camera has not been authorized, or no camera could be found"
};
}
// prepare the recording:
this._prepareRecording();
}
/**
* Submit a request to start the recording.
*
* @name module:hardware.Camera#record
* @function
* @public
* @return {Promise} promise fulfilled when the recording actually starts
*/
record()
{
// if the camera is currently paused, a call to start resumes it
// with a new recording:
@ -146,7 +204,6 @@ export class Camera extends PsychObject
return this.resume({clear: true});
}
if (this._status !== PsychoJS.Status.STARTED)
{
this._psychoJS.logger.debug("request to start video recording");
@ -175,7 +232,7 @@ export class Camera extends PsychObject
this._status = PsychoJS.Status.ERROR;
throw {
origin: "Camera.start",
origin: "Camera.record",
context: "when starting the video recording for camera: " + this._name,
error
};
@ -188,16 +245,14 @@ export class Camera extends PsychObject
/**
* Submit a request to stop the recording.
*
* @name module:visual.Camera#stop
* @name module:hardware.Camera#stop
* @function
* @public
* @param {Object} options
* @param {string} [options.filename] the name of the file to which the video recording
* will be saved
* @return {Promise} promise fulfilled when the recording actually stopped, and the recorded
* data was made available
*/
stop({filename} = {})
stop()
{
if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED)
{
@ -209,12 +264,7 @@ export class Camera extends PsychObject
video.pause();
}
this._stopOptions = {
filename
};
// note: calling the stop method of the MediaRecorder will first raise
// a dataavailable event, and then a stop event
// note: calling the MediaRecorder.stop will first raise a dataavailable event, and then a stop event
// ref: https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/stop
this._recorder.stop();
@ -232,7 +282,7 @@ export class Camera extends PsychObject
/**
* Submit a request to pause the recording.
*
* @name module:visual.Camera#pause
* @name module:hardware.Camera#pause
* @function
* @public
* @return {Promise} promise fulfilled when the recording actually paused
@ -281,7 +331,7 @@ export class Camera extends PsychObject
*
* <p>resume has no effect if the recording was not previously paused.</p>
*
* @name module:visual.Camera#resume
* @name module:hardware.Camera#resume
* @function
* @param {Object} options
* @param {boolean} [options.clear= false] whether or not to empty the video buffer before
@ -336,7 +386,7 @@ export class Camera extends PsychObject
/**
* Submit a request to flush the recording.
*
* @name module:visual.Camera#flush
* @name module:hardware.Camera#flush
* @function
* @public
* @return {Promise} promise fulfilled when the data has actually been made available
@ -362,74 +412,10 @@ export class Camera extends PsychObject
}
}
/**
* Offer the audio recording to the participant as a video file to download.
*
* @name module:visual.Camera#download
* @function
* @public
* @param {string} filename - the filename of the video file
*/
download(filename = "video.webm")
{
const videoBlob = new Blob(this._videoBuffer);
const anchor = document.createElement("a");
anchor.href = window.URL.createObjectURL(videoBlob);
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
}
/**
* Upload the video recording to the pavlovia server.
*
* @name module:visual.Camera#upload
* @function
* @public
* @param @param {Object} options
* @param {string} options.tag an optional tag for the video file
* @param {boolean} [options.waitForCompletion= false] whether or not to wait for completion
* before returning
* @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the participant to wait for the data to be uploaded to the server
* @param {string} [options.dialogMsg=""] - default message informing the participant to wait for the data to be uploaded to the server
*/
async upload({tag, waitForCompletion = false, showDialog = false, dialogMsg = ""} = {})
{
// default tag: the name of this Camera object
if (typeof tag === "undefined")
{
tag = this._name;
}
// add a format-dependent video extension to the tag:
tag += util.extensionFromMimeType(this._format);
// if the video recording cannot be uploaded, e.g. the experiment is running locally, or
// if it is piloting mode, then we offer the video recording as a file for download:
if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER ||
this._psychoJS.config.experiment.status !== "RUNNING" ||
this._psychoJS._serverMsg.has("__pilotToken"))
{
return this.download(tag);
}
// upload the blob:
const videoBlob = new Blob(this._videoBuffer);
return this._psychoJS.serverManager.uploadAudioVideo({
mediaBlob: videoBlob,
tag,
waitForCompletion,
showDialog,
dialogMsg});
}
/**
* Get the current video recording as a VideoClip in the given format.
*
* @name module:visual.Camera#getRecording
* @name module:hardware.Camera#getRecording
* @function
* @public
* @param {string} tag an optional tag for the video clip
@ -446,12 +432,82 @@ export class Camera extends PsychObject
// TODO
}
/**
* Upload the video recording to the pavlovia server.
*
* @name module:hardware.Camera#_upload
* @function
* @protected
* @param {string} tag an optional tag for the video file
* @param {boolean} [waitForCompletion= false] whether to wait for completion
* before returning
* @param {boolean} [showDialog=false] - whether to open a dialog box to inform the participant to wait for the data to be uploaded to the server
* @param {string} [dialogMsg=""] - default message informing the participant to wait for the data to be uploaded to the server
*/
save({tag, waitForCompletion = false, showDialog = false, dialogMsg = ""} = {})
{
this._psychoJS.logger.info("[PsychoJS] Save video recording.");
// default tag: the name of this Camera object
if (typeof tag === "undefined")
{
tag = this._name;
}
// add a format-dependent video extension to the tag:
tag += util.extensionFromMimeType(this._format);
// if the video recording cannot be uploaded, e.g. the experiment is running locally, or
// if it is piloting mode, then we offer the video recording as a file for download:
if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER ||
this._psychoJS.config.experiment.status !== "RUNNING" ||
this._psychoJS._serverMsg.has("__pilotToken"))
{
const videoBlob = new Blob(this._videoBuffer);
const anchor = document.createElement("a");
anchor.href = window.URL.createObjectURL(videoBlob);
anchor.download = tag;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
return;
}
// upload the blob:
const videoBlob = new Blob(this._videoBuffer);
return this._psychoJS.serverManager.uploadAudioVideo({
mediaBlob: videoBlob,
tag,
waitForCompletion,
showDialog,
dialogMsg});
}
/**
* Close the camera stream.
*
* @name module:hardware.Camera#close
* @function
* @public
* @returns {Promise<void>} promise fulfilled when the stream has stopped and is now closed
*/
async close()
{
await this.stop();
this._videos = [];
this._stream = null;
this._recorder = null;
}
/**
* Callback for changes to the recording settings.
*
* <p>Changes to the settings require the recording to stop and be re-started.</p>
*
* @name module:visual.Camera#_onChange
* @name module:hardware.Camera#_onChange
* @function
* @protected
*/
@ -470,28 +526,21 @@ export class Camera extends PsychObject
/**
* Prepare the recording.
*
* @name module:visual.Camera#_prepareRecording
* @name module:hardware.Camera#_prepareRecording
* @function
* @protected
*/
async _prepareRecording()
_prepareRecording()
{
// empty the video buffer:
this._videoBuffer = [];
this._recorder = null;
this._videos = [];
// create a new stream with ideal dimensions:
// TODO use size constraints
this._stream = await navigator.mediaDevices.getUserMedia({
video: true
});
// check the actual width and height:
this._streamSettings = this._stream.getVideoTracks()[0].getSettings();
this._psychoJS.logger.debug(`camera stream settings: ${JSON.stringify(this._streamSettings)}`);
// check that the specified format is supported, use default if it is not:
let options;
if (typeof this._format === "string" && MediaRecorder.isTypeSupported(this._format))
@ -503,11 +552,9 @@ export class Camera extends PsychObject
this._psychoJS.logger.warn(`The specified video format, ${this._format}, is not supported by this browser, using the default format instead`);
}
// create a video recorder:
this._recorder = new MediaRecorder(this._stream, options);
// setup the callbacks:
const self = this;
@ -521,7 +568,7 @@ export class Camera extends PsychObject
self._status = PsychoJS.Status.STARTED;
self._psychoJS.logger.debug("video recording started");
// resolve the Microphone.start promise:
// resolve the Camera.start promise:
if (self._startCallback)
{
self._startCallback(self._psychoJS.monotonicClock.getTime());
@ -534,7 +581,7 @@ export class Camera extends PsychObject
self._status = PsychoJS.Status.PAUSED;
self._psychoJS.logger.debug("video recording paused");
// resolve the Microphone.pause promise:
// resolve the Camera.pause promise:
if (self._pauseCallback)
{
self._pauseCallback(self._psychoJS.monotonicClock.getTime());
@ -547,7 +594,7 @@ export class Camera extends PsychObject
self._status = PsychoJS.Status.STARTED;
self._psychoJS.logger.debug("video recording resumed");
// resolve the Microphone.resume promise:
// resolve the Camera.resume promise:
if (self._resumeCallback)
{
self._resumeCallback(self._psychoJS.monotonicClock.getTime());
@ -576,19 +623,11 @@ export class Camera extends PsychObject
self._psychoJS.logger.debug("video recording stopped");
self._status = PsychoJS.Status.NOT_STARTED;
// resolve the Microphone.stop promise:
// resolve the Camera.stop promise:
if (self._stopCallback)
{
self._stopCallback(self._psychoJS.monotonicClock.getTime());
}
// treat stop options if there are any:
// download to a file, immediately offered to the participant:
if (typeof self._stopOptions.filename === "string")
{
self.download(self._stopOptions.filename);
}
};
// called upon recording errors:
@ -598,7 +637,6 @@ export class Camera extends PsychObject
self._psychoJS.logger.error("video recording error: " + JSON.stringify(event));
self._status = PsychoJS.Status.ERROR;
};
}
}

1
src/hardware/index.js Normal file
View File

@ -0,0 +1 @@
export * from "./Camera.js";

View File

@ -3,3 +3,4 @@ export * as core from './core/index.js';
export * as data from './data/index.js';
export * as visual from './visual/index.js';
export * as sound from './sound/index.js';
export * as hardware from './hardware/index.js';

View File

@ -11,7 +11,7 @@ import {PsychoJS} from "../core/PsychoJS.js";
import * as util from "../util/Util.js";
import { to_pixiPoint } from "../util/Pixi.js";
import {Color} from "../util/Color.js";
import {Camera} from "./Camera.js";
import {Camera} from "../hardware/Camera.js";
import {VisualStim} from "./VisualStim.js";
import * as PIXI from "pixi.js-legacy";

View File

@ -13,6 +13,7 @@ import { ColorMixin } from "../util/ColorMixin.js";
import { to_pixiPoint } from "../util/Pixi.js";
import * as util from "../util/Util.js";
import { VisualStim } from "./VisualStim.js";
import {Camera} from "../hardware";
/**
* Image Stimulus.
@ -133,13 +134,27 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
image = this.psychoJS.serverManager.getResource(image);
}
// image should now be an actual HTMLImageElement: we raise an error if it is not
if (!(image instanceof HTMLImageElement))
if (image instanceof Camera)
{
throw "the argument: " + image.toString() + ' is not an image" }';
const video = image.getVideo();
// TODO remove previous one if there is one
// document.body.appendChild(video);
image = video;
}
this.psychoJS.logger.debug("set the image of ImageStim: " + this._name + " as: src= " + image.src + ", size= " + image.width + "x" + image.height);
// image should now be either an HTMLImageElement or an HTMLVideoElement:
if (image instanceof HTMLImageElement)
{
this.psychoJS.logger.debug("set the image of ImageStim: " + this._name + " as: src= " + image.src + ", size= " + image.width + "x" + image.height);
}
else if (image instanceof HTMLVideoElement)
{
this.psychoJS.logger.debug(`set the image of ImageStim: ${this._name} as: src= ${image.src}, size= ${image.videoWidth}x${image.videoHeight}, duration= ${image.duration}s`);
}
else
{
throw "the argument: " + image.toString() + ' is neither an image nor a video" }';
}
}
const existingImage = this.getImage();
@ -263,9 +278,18 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
return;
}
const baseTexture = new PIXI.BaseTexture(this._image);
// deal with both static images and videos:
if (this._image instanceof HTMLImageElement)
{
this._texture = PIXI.Texture.from(this._image);
// const baseTexture = new PIXI.BaseTexture(this._image);
// this._texture = new PIXI.Texture(baseTexture);
}
else if (this._image instanceof HTMLVideoElement)
{
this._texture = PIXI.Texture.from(this._image, { resourceOptions: { autoPlay: true } });
}
this._texture = new PIXI.Texture(baseTexture);
this._pixi = PIXI.Sprite.from(this._texture);
// add a mask if need be:

View File

@ -14,7 +14,7 @@ import { ColorMixin } from "../util/ColorMixin.js";
import { to_pixiPoint } from "../util/Pixi.js";
import * as util from "../util/Util.js";
import { VisualStim } from "./VisualStim.js";
import {Camera} from "./Camera.js";
import {Camera} from "../hardware/Camera.js";
/**

View File

@ -334,13 +334,13 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
"pix",
this._win,
this._units,
);
); console.log(this._name, '>>', textSize);
// take the alignment into account:
const anchor = this._getAnchor();
this._boundingBox = new PIXI.Rectangle(
this._pos[0] - anchor[0] * textSize[0],
this._pos[1] - textSize[1] - anchor[1] * textSize[1],
this._pos[1] - textSize[1] + anchor[1] * textSize[1],
textSize[0],
textSize[1],
);
@ -445,7 +445,7 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
// refine the estimate of the bounding box:
this._boundingBox = new PIXI.Rectangle(
this._pos[0] - anchor[0] * this._size[0],
this._pos[1] - this._size[1] - anchor[1] * this._size[1],
this._pos[1] - this._size[1] + anchor[1] * this._size[1],
this._size[0],
this._size[1],
);

View File

@ -11,5 +11,4 @@ export * from "./TextBox.js";
export * from "./TextInput.js";
export * from "./TextStim.js";
export * from "./VisualStim.js";
export * from "./Camera.js";
export * from "./FaceDetector.js";