@nuralogix.ai/anura-web-core-sdk
Version:
Anura Web Core SDK
1,198 lines • 53.8 kB
JavaScript
const objectFit = {
CONTAIN: "contain",
COVER: "cover",
NONE: "none"
};
const constraintCodes = {
DISTANCE: {
OK: "OK",
TOO_CLOSE: "TOO_CLOSE",
TOO_FAR: "TOO_FAR"
},
DIRECTION: {
OK: "OK",
TURN_LEFT: "TURN_LEFT",
TURN_RIGHT: "TURN_RIGHT",
TURN_UP: "TURN_UP",
TURN_DOWN: "TURN_DOWN"
},
ROLL: {
OK: "OK",
TILT_LEFT: "TILT_LEFT",
TILT_RIGHT: "TILT_RIGHT"
},
MOVEMENT: {
OK: "OK",
TOO_MUCH_MOVEMENT: "TOO_MUCH_MOVEMENT"
},
CENTER: {
OK: "OK",
NOT_CENTERED: "NOT_CENTERED"
}
};
class AnuraMask {
static version = "1.0.0";
static name = "ANURA_MASK";
#ns = "http://www.w3.org/2000/svg";
#invalidConstraintColor = "red";
#borderColor = "red";
#starFillColor = "#39cb3a";
#starBorderColor = "#d1d1d1";
#pulseRateColor = "red";
#pulseRateLabelColor = "#ffffff";
#backgroundColor = "#ffffff";
#countDownLabelColor = "#000000";
#faceNotCenteredColor = "#fc6a0f";
#scaleX = 1;
#scaleY = 1;
#diameter = 0.8;
#topMargin = 0.06;
#bottomMargin = 0.02;
#duration = 30;
#offsetX = 0;
#offsetY = 0;
objectFit = objectFit.COVER;
#svg;
#defs;
#background;
#faceBorder;
#ovalBorder;
#borderTicks;
#progressPath;
#countDownCircle;
#countDownLabel;
#countDown;
#pulseRateIcon;
#pulseRateText;
#intermediateResults;
#starRatingGroup;
#loadingCircle;
#silhouettePath;
// Cached geometry/state so the per-frame draw() avoids forced synchronous layout
// (getTotalLength/getBBox) and O(tickCount) attribute writes — these throttle the
// compositor/main thread and were collapsing the camera frame rate during measurement.
#cachedPathLength = 0;
#lastTickColor = "";
#ticksVisible = false;
#lastCountdownText = "";
#lastCountdownOffset = { x: 0, y: 0 };
#positioningArrows;
#rollArrows;
#textPlaceholder;
#isLoading = true;
#thresholds = {
minDistance: 0.17,
maxDistance: 0.3,
yaw: 12,
pitchUp: 5,
pitchDown: 15,
roll: 10
};
#boundingBox = {
topX: 0,
topY: 0,
bottomX: 0,
bottomY: 0
};
#frameInfo = {
faceTrackerHeight: 0,
faceTrackerWidth: 0,
mediaStreamHeight: 0,
mediaStreamWidth: 0
};
#shouldFlipHorizontally = true;
#swapCoordinates = false;
#arrowAnimationsInitialized = false;
constructor(settings) {
if (settings) this.#updateSettings(settings);
this.#svg = document.createElementNS(this.#ns, "svg");
this.#svg.setAttribute("data-testid", "anura_mask_svg");
this.#defs = this.#getSVGDefs();
this.#background = this.#getBackground();
this.#faceBorder = this.#getFaceBorder();
this.#ovalBorder = this.#getOvalBorder();
this.#loadingCircle = this.#getLoadingCircle();
this.#toggleLoadingCircleAnimation(true);
this.#borderTicks = this.#getBorderTicks();
this.#progressPath = this.#getProgressPath();
this.#countDownCircle = this.#getCountDownCircle();
this.#countDownLabel = this.#getCountDownLabel();
this.#countDown = this.#getCountDown();
this.#pulseRateIcon = this.#getPulseRateIcon();
this.#pulseRateText = this.#getPulseRateText();
this.#intermediateResults = this.#getIntermediateResults();
this.#starRatingGroup = this.#getStarRatingGroup();
this.#positioningArrows = {
up: this.#getPositioningArrow("anura_mask_positioning_arrow_up"),
down: this.#getPositioningArrow("anura_mask_positioning_arrow_down"),
left: this.#getPositioningArrow("anura_mask_positioning_arrow_left"),
right: this.#getPositioningArrow("anura_mask_positioning_arrow_right")
};
this.#rollArrows = {
topLeft: this.#getRollArrow("anura_mask_roll_arrow_top_left"),
topRight: this.#getRollArrow("anura_mask_roll_arrow_top_right"),
bottomLeft: this.#getRollArrow("anura_mask_roll_arrow_bottom_left"),
bottomRight: this.#getRollArrow("anura_mask_roll_arrow_bottom_right")
};
this.#silhouettePath = this.#createSilhouettePath();
this.#textPlaceholder = this.#getTextPlaceholder();
this.#appendChilds();
}
#updateSettings(settings) {
if (typeof settings.swapCoordinates === "boolean") {
this.#swapCoordinates = settings.swapCoordinates;
}
if (typeof settings.shouldFlipHorizontally === "boolean") {
this.#shouldFlipHorizontally = settings.shouldFlipHorizontally;
}
if (typeof settings.starFillColor === "string") {
this.#starFillColor = settings.starFillColor;
}
if (typeof settings.starBorderColor === "string") {
this.#starBorderColor = settings.starBorderColor;
}
if (typeof settings.pulseRateColor === "string") {
this.#pulseRateColor = settings.pulseRateColor;
}
if (typeof settings.pulseRateLabelColor === "string") {
this.#pulseRateLabelColor = settings.pulseRateLabelColor;
}
if (typeof settings.backgroundColor === "string") {
this.#backgroundColor = settings.backgroundColor;
}
if (typeof settings.countDownLabelColor === "string") {
this.#countDownLabelColor = settings.countDownLabelColor;
}
if (typeof settings.faceNotCenteredColor === "string") {
this.#faceNotCenteredColor = settings.faceNotCenteredColor;
}
if (typeof settings.diameter === "number" && settings.diameter > 0 && settings.diameter <= 1) {
this.#diameter = settings.diameter;
}
if (typeof settings.topMargin === "number" && settings.topMargin > 0 && settings.topMargin <= 1) {
this.#topMargin = settings.topMargin;
}
if (typeof settings.bottomMargin === "number" && settings.bottomMargin > 0 && settings.bottomMargin <= 1) {
this.#bottomMargin = settings.bottomMargin;
}
}
#appendChilds() {
this.#svg.appendChild(this.#defs);
this.#svg.appendChild(this.#background);
this.#svg.appendChild(this.#faceBorder);
this.#svg.appendChild(this.#ovalBorder);
this.#svg.appendChild(this.#borderTicks);
this.#svg.appendChild(this.#loadingCircle);
this.#svg.appendChild(this.#progressPath);
this.#svg.appendChild(this.#countDown);
this.#svg.appendChild(this.#intermediateResults);
this.#svg.appendChild(this.#starRatingGroup);
this.#svg.appendChild(this.#positioningArrows.up);
this.#svg.appendChild(this.#positioningArrows.down);
this.#svg.appendChild(this.#positioningArrows.left);
this.#svg.appendChild(this.#positioningArrows.right);
this.#svg.appendChild(this.#rollArrows.topLeft);
this.#svg.appendChild(this.#rollArrows.topRight);
this.#svg.appendChild(this.#rollArrows.bottomLeft);
this.#svg.appendChild(this.#rollArrows.bottomRight);
this.#svg.appendChild(this.#silhouettePath);
this.#svg.appendChild(this.#textPlaceholder);
this.#svg.style.position = "absolute";
this.#svg.style.top = "0";
this.#svg.style.left = "0";
this.#svg.style.zIndex = "11";
this.#svg.style.pointerEvents = "none";
}
#getSVGDefs() {
const defs = document.createElementNS(this.#ns, "defs");
const path = document.createElementNS(this.#ns, "path");
path.setAttribute("d", "");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "black");
const mask = document.createElementNS(this.#ns, "mask");
mask.setAttribute("id", "cutout-mask");
const rect = document.createElementNS(this.#ns, "rect");
rect.setAttribute("fill", this.#backgroundColor);
rect.setAttribute("width", "100%");
rect.setAttribute("height", "100%");
const cutOutpath = document.createElementNS(this.#ns, "path");
cutOutpath.setAttribute("fill", "black");
cutOutpath.setAttribute("d", "");
mask.appendChild(rect);
mask.appendChild(cutOutpath);
const style = document.createElementNS(this.#ns, "style");
style.setAttribute("data-animations", "true");
style.textContent = `
/* Animations for chevrons, positioning arrows, and roll groups
now use SMIL <animate>/<animateTransform> for Safari compatibility */
/* === Glow Animation === */
@keyframes pulseGlow {
0%, 100% {
opacity: 0.2;
filter: drop-shadow(0 0 5px rgba(255,255,255,0.2));
}
50% {
opacity: 1;
filter: drop-shadow(0 0 15px rgba(255,255,255,0.8));
}
}
.anim-glow {
animation: pulseGlow 2.5s infinite;
}
/* === Loading circle: dashes flowing around the oval ===
Driven by CSS (not SMIL): Safari does not reliably run a SMIL <animate>
that is inserted into the DOM dynamically \u2014 it evaluates it once and leaves
it frozen. A CSS animation is ticked reliably. The end offset depends on the
oval size, so it is supplied at layout time via the --anura-loading-offset
custom property. */
@keyframes anuraLoadingSpin {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: var(--anura-loading-offset, -1000); }
}
.anura-loading-anim {
animation: anuraLoadingSpin 30s linear infinite;
}
/* === Face-not-centered border: flashing corners ===
CSS rather than SMIL (Safari does not reliably run JS-created SMIL <animate>). */
@keyframes anuraFaceBorderFlash {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.anura-face-border-flash {
animation: anuraFaceBorderFlash 2s linear infinite;
}
/* Tilt Left */
@keyframes tiltLeft {
0%, 100% {
transform: rotate(0deg) translateX(0);
}
40%, 60% {
transform: rotate(-25deg) translateX(-10px);
}
}
/* Tilt Right */
@keyframes tiltRight {
0%, 100% {
transform: rotate(0deg) translateX(0);
}
40%, 60% {
transform: rotate(25deg) translateX(10px);
}
}
.anim-tilt-left {
animation: tiltLeft 1.5s infinite ease-in-out;
transform-origin: center;
}
.anim-tilt-right {
animation: tiltRight 1.5s infinite ease-in-out;
transform-origin: center;
}
/* Move Back */
@keyframes moveBack {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.8); /* smaller, like moving away */
opacity: 0.6;
}
}
.anim-move-back {
animation: moveBack 1.5s infinite ease-in-out;
transform-origin: center;
}
/* Move Closer */
@keyframes moveCloser {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.3); /* larger, like moving closer */
opacity: 1;
}
}
.anim-move-closer {
animation: moveCloser 1.5s infinite ease-in-out;
transform-origin: center;
}
/* Turn Right (hinge center, Y axis) */
@keyframes hingeRight {
0%, 100% {
transform: perspective(600px) rotateY(0deg);
}
50% {
transform: perspective(600px) rotateY(25deg);
}
}
.anim-turn-right {
animation: hingeRight 1.2s infinite ease-in-out;
transform-origin: center center;
}
/* Turn Left (hinge center, Y axis) */
@keyframes hingeLeft {
0%, 100% {
transform: perspective(600px) rotateY(0deg);
}
50% {
transform: perspective(600px) rotateY(-25deg);
}
}
.anim-turn-left {
animation: hingeLeft 1.2s infinite ease-in-out;
transform-origin: center center;
}
/* Turn Up (hinge center, X axis) */
@keyframes hingeUp {
0%, 100% {
transform: perspective(600px) rotateX(0deg);
}
50% {
transform: perspective(600px) rotateX(25deg);
}
}
.anim-turn-up {
animation: hingeUp 1.2s infinite ease-in-out;
transform-origin: center center;
}
/* Turn Down (hinge center, X axis) */
@keyframes hingeDown {
0%, 100% {
transform: perspective(600px) rotateX(0deg);
}
50% {
transform: perspective(600px) rotateX(-25deg);
}
}
.anim-turn-down {
animation: hingeDown 1.2s infinite ease-in-out;
transform-origin: center center;
}
/* Center Your Face (gentle left-right sway) */
@keyframes swayCenter {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-20px);
}
50% {
transform: translateX(20px);
}
75% {
transform: translateX(-10px);
}
}
.anim-center-face {
animation: swayCenter 1.5s infinite ease-in-out;
}
`;
defs.appendChild(path);
defs.appendChild(mask);
defs.appendChild(style);
return defs;
}
#getBackground() {
const background = document.createElementNS(this.#ns, "rect");
background.setAttribute("fill", this.#backgroundColor);
background.setAttribute("width", "100%");
background.setAttribute("height", "100%");
background.setAttribute("mask", "url(#cutout-mask)");
background.setAttribute("data-testid", "anura_mask_background");
return background;
}
#getFaceBorder() {
const strokeWidth = "3";
const faceBorder = document.createElementNS(this.#ns, "g");
faceBorder.setAttribute("opacity", "1");
faceBorder.setAttribute("data-testid", "anura_mask_face_border");
faceBorder.classList.add("anura-face-border-flash");
const topLeftToRight = document.createElementNS(this.#ns, "line");
const topLeftToBottom = document.createElementNS(this.#ns, "line");
const topRightToLeft = document.createElementNS(this.#ns, "line");
const topRightToBottom = document.createElementNS(this.#ns, "line");
const bottomRightToleft = document.createElementNS(this.#ns, "line");
const bottomRightToTop = document.createElementNS(this.#ns, "line");
const bottomLeftToRight = document.createElementNS(this.#ns, "line");
const bottomLeftToTop = document.createElementNS(this.#ns, "line");
[
topLeftToRight,
topLeftToBottom,
topRightToLeft,
topRightToBottom,
bottomRightToleft,
bottomRightToTop,
bottomLeftToRight,
bottomLeftToTop
].forEach((element) => {
element.setAttribute("x1", "0");
element.setAttribute("y1", "0");
element.setAttribute("x2", "0");
element.setAttribute("y2", "0");
element.setAttribute("stroke", this.#faceNotCenteredColor);
element.setAttribute("stroke-width", strokeWidth);
element.setAttribute("stroke-linecap", "round");
faceBorder.appendChild(element);
});
return faceBorder;
}
#createSilhouettePath() {
const path = document.createElementNS(this.#ns, "path");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "lime");
path.setAttribute("stroke-width", "2");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("visibility", "hidden");
return path;
}
#drawSilhouette(annotations) {
const { silhouette } = annotations;
if (!silhouette || silhouette.length === 0) {
this.#silhouettePath.setAttribute("visibility", "hidden");
return;
}
const { faceTrackerWidth } = this.#frameInfo;
let d = "";
for (let i = 0; i < silhouette.length; i++) {
const pt = silhouette[i];
const rawX = this.#shouldFlipHorizontally ? faceTrackerWidth - pt.x : pt.x;
const sx = this.#offsetX + rawX * this.#scaleX;
const sy = this.#offsetY + pt.y * this.#scaleY;
d += i === 0 ? `M${sx.toFixed(2)},${sy.toFixed(2)}` : `L${sx.toFixed(2)},${sy.toFixed(2)}`;
}
d += "Z";
this.#silhouettePath.setAttribute("d", d);
this.#silhouettePath.setAttribute("visibility", "visible");
}
#drawFaceBorder() {
const { topX, topY, bottomX, bottomY } = this.#boundingBox;
const sides = this.#faceBorder.children;
const topLeftToRight = sides[0];
const topLeftToBottom = sides[1];
const topRightToLeft = sides[2];
const topRightToBottom = sides[3];
const bottomRightToleft = sides[4];
const bottomRightToTop = sides[5];
const bottomLeftToRight = sides[6];
const bottomLeftToTop = sides[7];
const length = 20;
topLeftToRight.setAttribute("x1", topX.toFixed(2));
topLeftToRight.setAttribute("y1", topY.toFixed(2));
topLeftToRight.setAttribute("x2", (topX + length).toFixed(2));
topLeftToRight.setAttribute("y2", topY.toFixed(2));
topLeftToBottom.setAttribute("x1", topX.toFixed(2));
topLeftToBottom.setAttribute("y1", topY.toFixed(2));
topLeftToBottom.setAttribute("x2", topX.toFixed(2));
topLeftToBottom.setAttribute("y2", (topY + length).toFixed(2));
topRightToLeft.setAttribute("x1", bottomX.toFixed(2));
topRightToLeft.setAttribute("y1", topY.toFixed(2));
topRightToLeft.setAttribute("x2", (bottomX - length).toFixed(2));
topRightToLeft.setAttribute("y2", topY.toFixed(2));
topRightToBottom.setAttribute("x1", bottomX.toFixed(2));
topRightToBottom.setAttribute("y1", topY.toFixed(2));
topRightToBottom.setAttribute("x2", bottomX.toFixed(2));
topRightToBottom.setAttribute("y2", (topY + length).toFixed(2));
bottomRightToleft.setAttribute("x1", bottomX.toFixed(2));
bottomRightToleft.setAttribute("y1", bottomY.toFixed(2));
bottomRightToleft.setAttribute("x2", (bottomX - length).toFixed(2));
bottomRightToleft.setAttribute("y2", bottomY.toFixed(2));
bottomRightToTop.setAttribute("x1", bottomX.toFixed(2));
bottomRightToTop.setAttribute("y1", bottomY.toFixed(2));
bottomRightToTop.setAttribute("x2", bottomX.toFixed(2));
bottomRightToTop.setAttribute("y2", (bottomY - length).toFixed(2));
bottomLeftToRight.setAttribute("x1", topX.toFixed(2));
bottomLeftToRight.setAttribute("y1", bottomY.toFixed(2));
bottomLeftToRight.setAttribute("x2", (topX + length).toFixed(2));
bottomLeftToRight.setAttribute("y2", bottomY.toFixed(2));
bottomLeftToTop.setAttribute("x1", topX.toFixed(2));
bottomLeftToTop.setAttribute("y1", bottomY.toFixed(2));
bottomLeftToTop.setAttribute("x2", topX.toFixed(2));
bottomLeftToTop.setAttribute("y2", (bottomY - length).toFixed(2));
}
#setScaledBoundingBox(face, rotate90Degrees = false) {
const { faceRect } = face;
const { x, y, width, height } = rotate90Degrees ? this.#rotate90Degrees(face) : faceRect;
const { faceTrackerWidth, faceTrackerHeight } = this.#frameInfo;
const horizontalBoundary = rotate90Degrees ? Math.min(faceTrackerWidth, faceTrackerHeight) : faceTrackerWidth;
const adjustedX = this.#shouldFlipHorizontally ? horizontalBoundary - (x + width) : x;
this.#boundingBox.topX = this.#offsetX + this.#scaleX * adjustedX;
this.#boundingBox.topY = this.#offsetY + this.#scaleY * y;
this.#boundingBox.bottomX = this.#offsetX + (width + adjustedX) * this.#scaleX;
this.#boundingBox.bottomY = this.#offsetY + (y + height) * this.#scaleY;
}
#rotate90Degrees(face) {
const { x, y, width, height } = face.faceRect;
const { faceTrackerWidth, faceTrackerHeight } = this.#frameInfo;
const shortSide = Math.min(faceTrackerWidth, faceTrackerHeight);
return {
x: shortSide - (y + height),
y: x,
width: height,
height: width
};
}
#getTextPlaceholder() {
const foreign = document.createElementNS(this.#ns, "foreignObject");
foreign.setAttribute("data-testid", "anura_mask_text_placeholder");
const div = document.createElement("div");
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
div.style.textAlign = "center";
div.style.color = "rgba(255,255,255,0.9)";
div.style.fontWeight = "bold";
div.style.fontFamily = "sans-serif";
div.style.fontSize = "16px";
div.style.lineHeight = "1.2";
div.style.overflowWrap = "break-word";
foreign.appendChild(div);
return foreign;
}
#getRollArrow(testId) {
const chevronColor = "red";
const chevronSize = "48";
const chevronSpacing = 15;
const wrapper = document.createElementNS(this.#ns, "g");
wrapper.setAttribute("data-testid", testId);
const rotGroup = document.createElementNS(this.#ns, "g");
rotGroup.style.transformOrigin = "0 0";
const innerGroup = document.createElementNS(this.#ns, "g");
for (let i = 0; i < 3; i++) {
const text = document.createElementNS(this.#ns, "text");
text.setAttribute("x", (i * chevronSpacing).toString());
text.setAttribute("y", "0");
text.setAttribute("fill", chevronColor);
text.setAttribute("font-size", chevronSize);
text.setAttribute("dominant-baseline", "middle");
text.setAttribute("text-anchor", "start");
text.textContent = "\u203A";
innerGroup.appendChild(text);
}
rotGroup.appendChild(innerGroup);
wrapper.appendChild(rotGroup);
wrapper.style.display = "none";
return wrapper;
}
#getPositioningArrow(testId) {
const chevronColor = "red";
const chevronSize = "48";
const spacing = 10;
const group = document.createElementNS(this.#ns, "g");
group.setAttribute("display", "none");
group.setAttribute("data-testid", testId);
[0, spacing, spacing * 2].forEach((offset) => {
const t = document.createElementNS(this.#ns, "text");
t.textContent = "\u203A";
t.setAttribute("x", offset.toString());
t.setAttribute("y", "0");
t.setAttribute("fill", chevronColor);
t.setAttribute("font-size", chevronSize);
t.setAttribute("dominant-baseline", "middle");
t.setAttribute("text-anchor", "start");
group.appendChild(t);
});
return group;
}
#getDistanceConstraint(annotations) {
const { relativeFaceSize } = annotations;
let distanceConstraint = constraintCodes.DISTANCE.OK;
if (relativeFaceSize < this.#thresholds.minDistance)
distanceConstraint = constraintCodes.DISTANCE.TOO_FAR;
if (relativeFaceSize > this.#thresholds.maxDistance)
distanceConstraint = constraintCodes.DISTANCE.TOO_CLOSE;
return distanceConstraint;
}
#setDistanceColor(annotations, constraints) {
const { relativeFaceSize } = annotations;
const { distanceConstraint, directionConstraint, rollConstraint, centerConstraint } = constraints;
if (distanceConstraint !== constraintCodes.DISTANCE.OK || directionConstraint !== constraintCodes.DIRECTION.OK || rollConstraint !== constraintCodes.ROLL.OK || centerConstraint !== constraintCodes.CENTER.OK) {
this.#borderColor = this.#invalidConstraintColor;
} else {
(relativeFaceSize - this.#thresholds.minDistance) / (this.#thresholds.maxDistance - this.#thresholds.minDistance);
this.#borderColor = `hsl(${120}, 100%, 50%)`;
}
}
checkConstraints(face, annotations) {
const { direction, movementConstraintExceeded } = annotations;
let { yaw, pitch, roll } = direction;
if (this.#swapCoordinates) {
const FACE_ASPECT_RATIO = 0.8;
const oldYaw = yaw;
const oldPitch = pitch;
const oldRoll = roll;
yaw = -oldPitch * (1 / FACE_ASPECT_RATIO);
const toRad = Math.PI / 180;
const toDeg = 180 / Math.PI;
const noseToEyesOverHeight = Math.tan(oldYaw * toRad) * FACE_ASPECT_RATIO;
const noseToCenterOverHeight = noseToEyesOverHeight - 0.45;
pitch = Math.atan(noseToCenterOverHeight) * toDeg;
roll = -90 - oldRoll;
if (roll < -180) roll += 360;
if (roll > 180) roll -= 360;
}
let rollConstraint = constraintCodes.ROLL.OK;
let directionConstraint = constraintCodes.DIRECTION.OK;
const distanceConstraint = this.#getDistanceConstraint(annotations);
if (yaw > this.#thresholds.yaw) {
directionConstraint = constraintCodes.DIRECTION.TURN_LEFT;
} else if (yaw < -this.#thresholds.yaw) {
directionConstraint = constraintCodes.DIRECTION.TURN_RIGHT;
} else if (pitch > this.#thresholds.pitchUp) {
directionConstraint = constraintCodes.DIRECTION.TURN_UP;
} else if (pitch < -this.#thresholds.pitchDown) {
directionConstraint = constraintCodes.DIRECTION.TURN_DOWN;
}
if (Math.abs(roll) > this.#thresholds.roll) {
if (roll > 0) {
rollConstraint = constraintCodes.ROLL.TILT_LEFT;
} else {
rollConstraint = constraintCodes.ROLL.TILT_RIGHT;
}
}
if (this.#shouldFlipHorizontally) {
if (directionConstraint === constraintCodes.DIRECTION.TURN_LEFT)
directionConstraint = constraintCodes.DIRECTION.TURN_RIGHT;
else if (directionConstraint === constraintCodes.DIRECTION.TURN_RIGHT)
directionConstraint = constraintCodes.DIRECTION.TURN_LEFT;
if (rollConstraint === constraintCodes.ROLL.TILT_LEFT)
rollConstraint = constraintCodes.ROLL.TILT_RIGHT;
else if (rollConstraint === constraintCodes.ROLL.TILT_RIGHT)
rollConstraint = constraintCodes.ROLL.TILT_LEFT;
}
const { isCentered } = this.#isFaceCentered(face, 0.05);
const centerConstraint = isCentered ? constraintCodes.CENTER.OK : constraintCodes.CENTER.NOT_CENTERED;
const movementConstraint = movementConstraintExceeded ? constraintCodes.MOVEMENT.TOO_MUCH_MOVEMENT : constraintCodes.MOVEMENT.OK;
return { distanceConstraint, directionConstraint, rollConstraint, centerConstraint, movementConstraint };
}
#updateArrows(directionConstraint, rollConstraint) {
const activeDir = directionConstraint.replace("TURN_", "").toLowerCase();
for (const [dir, arrow] of Object.entries(this.#positioningArrows)) {
arrow.setAttribute("display", dir === activeDir ? "block" : "none");
}
if (rollConstraint !== constraintCodes.ROLL.OK) {
this.#rollArrows.topLeft.style.display = rollConstraint === constraintCodes.ROLL.TILT_LEFT ? "block" : "none";
this.#rollArrows.bottomRight.style.display = rollConstraint === constraintCodes.ROLL.TILT_LEFT ? "block" : "none";
this.#rollArrows.topRight.style.display = rollConstraint === constraintCodes.ROLL.TILT_RIGHT ? "block" : "none";
this.#rollArrows.bottomLeft.style.display = rollConstraint === constraintCodes.ROLL.TILT_RIGHT ? "block" : "none";
} else {
this.#rollArrows.topLeft.style.display = "none";
this.#rollArrows.bottomRight.style.display = "none";
this.#rollArrows.topRight.style.display = "none";
this.#rollArrows.bottomLeft.style.display = "none";
}
}
#getOvalBorder() {
const ovalBorder = document.createElementNS(this.#ns, "path");
ovalBorder.setAttribute("d", "");
ovalBorder.setAttribute("fill", "white");
ovalBorder.setAttribute("opacity", "0.7");
ovalBorder.setAttribute("stroke-width", "4");
ovalBorder.setAttribute("data-testid", "anura_mask_oval_border");
return ovalBorder;
}
#getBorderTicks() {
const borderTicks = document.createElementNS(this.#ns, "path");
borderTicks.setAttribute("data-testid", "anura_mask_border_ticks");
borderTicks.setAttribute("fill", "none");
borderTicks.setAttribute("stroke", this.#borderColor);
borderTicks.setAttribute("stroke-width", "2");
borderTicks.setAttribute("opacity", "0.8");
borderTicks.style.display = "none";
return borderTicks;
}
#getProgressPath() {
const progressPath = document.createElementNS(this.#ns, "path");
progressPath.setAttribute("fill", "none");
progressPath.setAttribute("stroke", this.#borderColor);
progressPath.setAttribute("stroke-width", "4");
progressPath.setAttribute("stroke-linecap", "round");
progressPath.setAttribute("stroke-linejoin", "round");
progressPath.setAttribute("data-testid", "anura_mask_progress_path");
return progressPath;
}
#getCountDownCircle() {
const circle = document.createElementNS(this.#ns, "circle");
circle.setAttribute("visibility", "hidden");
circle.setAttribute("r", "16");
circle.setAttribute("fill", this.#borderColor);
circle.setAttribute("data-testid", "anura_mask_countdown_circle");
return circle;
}
#getCountDownLabel() {
const label = document.createElementNS(this.#ns, "text");
label.setAttribute("visibility", "hidden");
label.setAttribute("x", "0");
label.setAttribute("y", "0");
label.setAttribute("fill", this.#countDownLabelColor);
label.setAttribute("font-size", "16");
label.setAttribute("font-weight", "bold");
label.setAttribute("font-family", "Arial");
label.setAttribute("data-testid", "anura_mask_countdown_label");
return label;
}
#getLoadingCircle() {
const loadingCircle = document.createElementNS(this.#ns, "path");
loadingCircle.setAttribute("d", "");
loadingCircle.setAttribute("fill", "none");
loadingCircle.setAttribute("stroke", "#87cc99");
loadingCircle.setAttribute("stroke-width", "4");
loadingCircle.setAttribute("data-testid", "anura_mask_loading_circle");
return loadingCircle;
}
#toggleLoadingCircleAnimation(isLoading) {
this.#loadingCircle.setAttribute("visibility", isLoading ? "visible" : "hidden");
this.#loadingCircle.classList.toggle("anura-loading-anim", isLoading);
}
#getCountDown() {
const countDown = document.createElementNS(this.#ns, "g");
countDown.setAttribute("data-testid", "anura_mask_countdown");
countDown.appendChild(this.#countDownCircle);
countDown.appendChild(this.#countDownLabel);
return countDown;
}
#getPulseRateIcon() {
const pulseRateIcon = document.createElementNS(this.#ns, "path");
pulseRateIcon.setAttribute("d", "M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z");
pulseRateIcon.setAttribute("fill", this.#pulseRateColor);
pulseRateIcon.setAttribute("transform", "translate(-12,-10.66)");
pulseRateIcon.setAttribute("data-testid", "anura_mask_pulse_rate_icon");
return pulseRateIcon;
}
#getPulseRateText() {
const pulseRateText = document.createElementNS(this.#ns, "text");
pulseRateText.setAttribute("x", "0");
pulseRateText.setAttribute("y", "2");
pulseRateText.setAttribute("fill", this.#pulseRateLabelColor);
pulseRateText.setAttribute("font-size", "14");
pulseRateText.setAttribute("font-weight", "bold");
pulseRateText.setAttribute("font-family", "Arial");
pulseRateText.setAttribute("text-anchor", "middle");
pulseRateText.setAttribute("dominant-baseline", "middle");
pulseRateText.setAttribute("data-testid", "anura_mask_pulse_rate_text");
return pulseRateText;
}
#getIntermediateResults() {
const intermediateResults = document.createElementNS(this.#ns, "g");
intermediateResults.setAttribute("visibility", "hidden");
intermediateResults.setAttribute("data-testid", "anura_mask_intermediate_results");
const animateGroup = document.createElementNS(this.#ns, "g");
animateGroup.setAttribute("transform", "scale(2.5)");
animateGroup.appendChild(this.#pulseRateIcon);
intermediateResults.appendChild(animateGroup);
intermediateResults.appendChild(this.#pulseRateText);
return intermediateResults;
}
#getStarIcon(id, translate) {
const starIcon = document.createElementNS(this.#ns, "g");
starIcon.setAttribute("transform", `translate(${translate}, 0)`);
const path = document.createElementNS(this.#ns, "path");
path.setAttribute("d", "M11.2691 4.41115C11.5006 3.89177 11.6164 3.63208 11.7776 3.55211C11.9176 3.48263 12.082 3.48263 12.222 3.55211C12.3832 3.63208 12.499 3.89177 12.7305 4.41115L14.5745 8.54808C14.643 8.70162 14.6772 8.77839 14.7302 8.83718C14.777 8.8892 14.8343 8.93081 14.8982 8.95929C14.9705 8.99149 15.0541 9.00031 15.2213 9.01795L19.7256 9.49336C20.2911 9.55304 20.5738 9.58288 20.6997 9.71147C20.809 9.82316 20.8598 9.97956 20.837 10.1342C20.8108 10.3122 20.5996 10.5025 20.1772 10.8832L16.8125 13.9154C16.6877 14.0279 16.6252 14.0842 16.5857 14.1527C16.5507 14.2134 16.5288 14.2807 16.5215 14.3503C16.5132 14.429 16.5306 14.5112 16.5655 14.6757L17.5053 19.1064C17.6233 19.6627 17.6823 19.9408 17.5989 20.1002C17.5264 20.2388 17.3934 20.3354 17.2393 20.3615C17.0619 20.3915 16.8156 20.2495 16.323 19.9654L12.3995 17.7024C12.2539 17.6184 12.1811 17.5765 12.1037 17.56C12.0352 17.5455 11.9644 17.5455 11.8959 17.56C11.8185 17.5765 11.7457 17.6184 11.6001 17.7024L7.67662 19.9654C7.18404 20.2495 6.93775 20.3915 6.76034 20.3615C6.60623 20.3354 6.47319 20.2388 6.40075 20.1002C6.31736 19.9408 6.37635 19.6627 6.49434 19.1064L7.4341 14.6757C7.46898 14.5112 7.48642 14.429 7.47814 14.3503C7.47081 14.2807 7.44894 14.2134 7.41394 14.1527C7.37439 14.0842 7.31195 14.0279 7.18708 13.9154L3.82246 10.8832C3.40005 10.5025 3.18884 10.3122 3.16258 10.1342C3.13978 9.97956 3.19059 9.82316 3.29993 9.71147C3.42581 9.58288 3.70856 9.55304 4.27406 9.49336L8.77835 9.01795C8.94553 9.00031 9.02911 8.99149 9.10139 8.95929C9.16534 8.93081 9.2226 8.8892 9.26946 8.83718C9.32241 8.77839 9.35663 8.70162 9.42508 8.54808L11.2691 4.41115Z");
path.setAttribute("fill", "none");
path.setAttribute("stroke", this.#starBorderColor);
path.setAttribute("stroke-width", "2");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("id", id);
starIcon.appendChild(path);
return starIcon;
}
#getStarRatingGroup() {
const starRatingGroup = document.createElementNS(this.#ns, "g");
starRatingGroup.setAttribute("data-testid", "anura_mask_star_rating");
const starCount = 5;
const starWidth = 24;
const starSpacing = 10;
for (let i = 0; i < starCount; i += 1) {
starRatingGroup.appendChild(this.#getStarIcon(
(i + 1).toString(),
i * (starWidth + starSpacing)
));
}
starRatingGroup.setAttribute("visibility", "hidden");
return starRatingGroup;
}
#isFaceCentered(face, toleranceRatio = 0.1) {
this.#setScaledBoundingBox(face, this.#swapCoordinates);
const { x, y, width, height } = this.#swapCoordinates ? this.#rotate90Degrees(face) : face.faceRect;
const { faceTrackerWidth, faceTrackerHeight } = this.#frameInfo;
const frameWidth = this.#swapCoordinates ? Math.min(faceTrackerWidth, faceTrackerHeight) : faceTrackerWidth;
const frameHeight = this.#swapCoordinates ? Math.max(faceTrackerWidth, faceTrackerHeight) : faceTrackerHeight;
const frameCenterX = frameWidth / 2;
const frameCenterY = frameHeight / 2;
const faceCenterX = x + width / 2;
const faceCenterY = y + height / 2;
const offsetX = faceCenterX - frameCenterX;
const offsetY = faceCenterY - frameCenterY;
const toleranceX = frameWidth * toleranceRatio;
const toleranceY = frameHeight * toleranceRatio;
const isCentered = Math.abs(offsetX) <= toleranceX && Math.abs(offsetY) <= toleranceY;
return { isCentered, offsetX, offsetY };
}
#setSize(width, height) {
this.#svg.setAttribute("viewBox", `0 0 ${Math.trunc(width)} ${Math.trunc(height)}`);
this.#svg.setAttribute("width", `${Math.trunc(width)}px`);
this.#svg.setAttribute("height", `${Math.trunc(height)}px`);
}
getSvg() {
return this.#svg;
}
#setFrameInfo(frameInfo, videoElementSize, rotate90Degrees = false) {
this.#frameInfo = frameInfo;
const { faceTrackerHeight, faceTrackerWidth, mediaStreamHeight, mediaStreamWidth } = this.#frameInfo;
const { width, height, offsetX, offsetY } = videoElementSize;
this.#offsetX = offsetX;
this.#offsetY = offsetY;
if (rotate90Degrees === true) {
const shortSide = Math.min(faceTrackerWidth, faceTrackerHeight);
const longSide = Math.max(faceTrackerWidth, faceTrackerHeight);
if (this.objectFit === objectFit.NONE) {
this.#scaleX = mediaStreamWidth / shortSide;
this.#scaleY = mediaStreamHeight / longSide;
} else {
this.#scaleX = width / shortSide;
this.#scaleY = height / longSide;
}
} else {
if (this.objectFit === objectFit.NONE) {
this.#scaleX = mediaStreamWidth / faceTrackerWidth;
this.#scaleY = mediaStreamHeight / faceTrackerHeight;
} else {
this.#scaleX = width / faceTrackerWidth;
this.#scaleY = height / faceTrackerHeight;
}
}
}
#setCountDown(distance, percentCompleted, color) {
const point = this.#ovalBorder.getPointAtLength(distance);
this.#countDown.setAttribute("transform", `translate(${point.x},${point.y})`);
const text = Math.ceil(this.#duration - percentCompleted * this.#duration / 100).toString();
if (text !== this.#lastCountdownText) {
this.#countDownLabel.textContent = text;
const bbox = this.#countDownLabel.getBBox();
this.#lastCountdownOffset = { x: -bbox.width / 2 - bbox.x, y: -bbox.height / 2 - bbox.y };
this.#countDownLabel.setAttribute("transform", `translate(${this.#lastCountdownOffset.x},${this.#lastCountdownOffset.y})`);
this.#lastCountdownText = text;
}
this.#countDownCircle.setAttribute("fill", color);
this.#countDownCircle.setAttribute("visibility", "visible");
this.#countDownLabel.setAttribute("visibility", "visible");
}
#positionRollArrow(wrapper, arcCx, arcCy, radius, angle, margin, chevronVisualRotation) {
const rotGroup = wrapper.firstElementChild;
const innerGroup = rotGroup.firstElementChild;
wrapper.setAttribute("transform", `translate(${arcCx},${arcCy})`);
const [start, end] = angle;
const angleCenter = (start + end) / 2;
rotGroup.getAnimations().forEach((a) => a.cancel());
rotGroup.animate(
[
{ transform: `rotate(${start}deg)` },
{ transform: `rotate(${end}deg)` }
],
{
duration: 2e3,
iterations: Infinity,
direction: "alternate",
easing: "ease-in-out"
}
);
const bbox = innerGroup.getBBox();
const innerCenterX = bbox.x + bbox.width / 2;
const innerCenterY = bbox.y + bbox.height / 2;
const angleRad = angleCenter * Math.PI / 180;
const localX = (radius + margin) * Math.cos(angleRad);
const localY = (radius + margin) * Math.sin(angleRad);
const tx = localX - innerCenterX;
const ty = localY - innerCenterY;
innerGroup.setAttribute("transform", `translate(${tx}, ${ty}) rotate(${chevronVisualRotation} ${innerCenterX} ${innerCenterY})`);
}
/** Start Web Animations API opacity animations on arrow elements (once after DOM attachment) */
#initArrowAnimations() {
if (this.#arrowAnimationsInitialized) return;
this.#arrowAnimationsInitialized = true;
for (const arrow of Object.values(this.#positioningArrows)) {
arrow.querySelectorAll("text").forEach((t, i) => {
t.animate(
[
{ opacity: 0, offset: 0 },
{ opacity: 0, offset: 0.14 },
{ opacity: 1, offset: 0.2 },
{ opacity: 1, offset: 0.5 },
{ opacity: 0, offset: 0.7 },
{ opacity: 0, offset: 1 }
],
{ duration: 1800, iterations: Infinity, delay: i * 300 }
);
});
}
for (const arrow of Object.values(this.#rollArrows)) {
const rotGroup = arrow.firstElementChild;
const innerGroup = rotGroup.firstElementChild;
innerGroup.querySelectorAll("text").forEach((t, i) => {
t.animate(
[
{ opacity: 0.2, offset: 0 },
{ opacity: 1, offset: 0.25 },
{ opacity: 1, offset: 0.5 },
{ opacity: 0.2, offset: 1 }
],
{ duration: 1800, iterations: Infinity, delay: i * 200, easing: "ease-in-out" }
);
});
}
}
resize(resizeInfo, settings) {
if (settings) this.#updateSettings(settings);
const { mediaElementSize, videoElementSize, frameInfo } = resizeInfo;
const { width, height } = mediaElementSize;
this.#setSize(width, height);
this.#setFrameInfo(frameInfo, videoElementSize, this.#swapCoordinates);
const shapeBottomMargin = height * this.#bottomMargin;
const shapeTopMargin = height * this.#topMargin;
let shapeWidth = width * this.#diameter;
const infoHeight = 35 + 25 + 35;
let shapeHeight = height - shapeBottomMargin - shapeTopMargin - shapeWidth - infoHeight;
if (shapeHeight < 0) {
shapeWidth = shapeWidth + shapeHeight;
shapeHeight = 0;
}
const centerX = width / 2;
const centerY = shapeTopMargin + (shapeWidth + shapeHeight) / 2;
const radius = shapeWidth / 2;
const startX = centerX - radius;
const left = startX;
const right = startX + shapeWidth;
const topArcY = shapeTopMargin + radius;
const bottomArcY = shapeTopMargin + shapeHeight + radius;
const topCenterX = startX + radius;
const intermediateResultsX = centerX;
const intermediateResultsY = bottomArcY + radius + 35;
const starsY = intermediateResultsY + 30;
const starCount = 5;
const starSpacing = 10;
const starWidth = 24;
const totalStarsWidth = starCount * starWidth + (starCount - 1) * starSpacing;
const starsStartX = intermediateResultsX - starWidth / 2 - totalStarsWidth / 2 + starWidth / 2;
this.#starRatingGroup.setAttribute("transform", `translate(${starsStartX}, ${starsY})`);
this.#intermediateResults.setAttribute("transform", `translate(${intermediateResultsX}, ${intermediateResultsY})`);
const pathD = `
M ${topCenterX},${shapeTopMargin}
A ${radius},${radius} 0 0 1 ${right},${topArcY}
L ${right},${bottomArcY}
A ${radius},${radius} 0 0 1 ${left},${bottomArcY}
L ${left},${topArcY}
A ${radius},${radius} 0 0 1 ${topCenterX},${shapeTopMargin}
`;
this.#ovalBorder.setAttribute("d", pathD.trim());
this.#defs.children[0].setAttribute("d", pathD.trim());
this.#defs.children[1].children[1].setAttribute("d", pathD.trim());
const pathLength = this.#ovalBorder.getTotalLength();
this.#cachedPathLength = pathLength;
this.#lastTickColor = "";
this.#lastCountdownText = "";
this.#progressPath.setAttribute("d", pathD.trim());
this.#progressPath.setAttribute("stroke-dasharray", pathLength.toFixed(0));
this.#progressPath.setAttribute("stroke-dashoffset", pathLength.toFixed(0));
const targetDashLength = 30;
const numDashes = Math.max(3, Math.round(pathLength / targetDashLength));
const dashGapTotal = pathLength / numDashes;
const dashLength = dashGapTotal * 0.5;
const gapLength = dashGapTotal * 0.5;
this.#loadingCircle.setAttribute("d", pathD.trim());
this.#loadingCircle.setAttribute("stroke-dasharray", `${dashLength} ${gapLength}`);
this.#loadingCircle.style.setProperty("--anura-loading-offset", `-${pathLength.toFixed(0)}`);
this.#positioningArrows.up.setAttribute("transform", `translate(${centerX},${shapeTopMargin - 6}) rotate(${-90})`);
this.#positioningArrows.down.setAttribute("transform", `translate(${centerX},${bottomArcY + radius + 6}) rotate(${90})`);
this.#positioningArrows.left.setAttribute("transform", `translate(${startX - 3},${centerY}) rotate(${180})`);
this.#positioningArrows.right.setAttribute("transform", `translate(${centerX + radius + 3},${centerY}) rotate(${0})`);
const margin = 30;
this.#positionRollArrow(this.#rollArrows.topLeft, startX + radius, topArcY, radius, [-62.5, -72.5], margin, 200);
this.#positionRollArrow(this.#rollArrows.topRight, startX + radius, topArcY, radius, [-27.5, -17.5], margin, 67);
this.#positionRollArrow(this.#rollArrows.bottomRight, startX + radius, bottomArcY, radius, [22.5, 17.5], margin, -67);
this.#positionRollArrow(this.#rollArrows.bottomLeft, startX + radius, bottomArcY, radius, [62.5, 72.5], margin, -200);
this.#initArrowAnimations();
const padding = 30;
const yOffset = radius / 2;
const chordWidth = 2 * Math.sqrt(Math.max(0, radius * radius - yOffset * yOffset));
const availableWidth = chordWidth - padding * 2;
this.#textPlaceholder.setAttribute("x", `${startX + radius - availableWidth / 2}`);
this.#textPlaceholder.setAttribute("y", `${bottomArcY + yOffset}`);
this.#textPlaceholder.setAttribute("width", `${availableWidth}`);
this.#textPlaceholder.setAttribute("height", "100");
const textDiv = this.#textPlaceholder.firstChild;
textDiv.style.width = availableWidth + "px";
textDiv.style.fontSize = `${Math.max(12, Math.min(24, width * 0.04))}px`;
const tickCount = Math.floor(pathLength / 5);
const tickLength = 2;
let ticksD = "";
for (let i = 0; i < tickCount; i++) {
const position = i / tickCount * pathLength;
const point = this.#ovalBorder.getPointAtLength(position);
const dx = point.x - centerX;
const dy = point.y - centerY;
const length = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / length;
const uy = dy / length;
const sx = (point.x - ux * tickLength).toFixed(1);
const sy = (point.y - uy * tickLength).toFixed(1);
const ex = (point.x + ux * tickLength).toFixed(1);
const ey = (point.y + uy * tickLength).toFixed(1);
ticksD += `M${sx},${sy}L${ex},${ey}`;
}
this.#borderTicks.setAttribute("d", ticksD);
this.#borderTicks.style.display = "none";
this.#ticksVisible = false;
}
setText(text, animation) {
const textDiv = this.#textPlaceholder.firstChild;
textDiv.innerHTML = text;
switch (animation) {
case constraintCodes.ROLL.TILT_RIGHT:
this.#textPlaceholder.setAttribute("class", "anim-tilt-right");
break;
case constraintCodes.ROLL.TILT_LEFT:
this.#textPlaceholder.setAttribute("class", "anim-tilt-left");
break;
case constraintCodes.DISTANCE.TOO_CLOSE:
this.#textPlaceholder.setAttribute("class", "anim-move-back");
break;
case constraintCodes.DISTANCE.TOO_FAR:
this.#textPlaceholder.setAttribute("class", "anim-move-closer");
break;
case constraintCodes.DIRECTION.TURN_LEFT:
this.#textPlaceholder.setAttribute("class", "anim-turn-left");
break;
case constraintCodes.DIRECTION.TURN_RIGHT:
this.#textPlaceholder.setAttribute("class", "anim-turn-right");
break;
case constraintCodes.DIRECTION.TURN_UP:
this.#textPlaceholder.setAttribute("class", "anim-turn-up");
break;
case constraintCodes.DIRECTION.TURN_DOWN:
this.#textPlaceholder.setAttribute("class", "anim-turn-down");
break;
case constraintCodes.CENTER.NOT_CENTERED:
this.#textPlaceholder.setAttribute("class", "anim-center-face");
break;
case "DEFAULT":
this.#textPlaceholder.setAttribute("class", "anim-glow");
break;
default:
this.#textPlaceholder.setAttribute("class", "");
break;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// PERFORMANCE — READ THIS BEFORE EDITING draw() OR BUILDING ANOTHER MASK (e.g. tele).
//
// draw() runs on EVERY camera frame (~30 fps). This SVG overlay sits on top of the
// <video> element, and on iOS Safari the camera frame callback (requestVideoFrameCallback)
// fires at the page's *compositor paint rate*. If a frame's mask work is too expensive, the
// paint rate collapses (~14 fps was observed), which in turn under-samples the camera and
// starves the DFX collector → "insufficient frames" measurement failures.
//
// Two classes of SVG operations are expensive and must be kept out of the per-frame path:
// 1. Forced synchronous layout: getBBox(), getTotalLength(), getPointAtLength(),
// getComputedTextLength(). These flush layout on the main thread. Cache their results
// when the geometry is static (the oval never changes between layouts — see
// #cachedPathLength), and only recompute on actual change (e.g. the countdown label's
// getBBox only when the displayed number changes).
// 2. High element counts / per-element mutation: compositing/re-rasterizing many SVG
// elements every frame is costly. The progress tick track was ~pathLength/5 individual
// <line> elements — collapsing them into a SINGLE <path> (see #getBorderTicks) was the
// fix that restored 30 fps. Prefer one path with many sub-segments over many elements,
// and avoid setAttribute('d', …) (re-parses the path) every frame — set static shapes once.
//
// Also avoid SMIL <animate> inserted dynamically: Safari does not tick it reliably; use a
// CSS animation instead (see .anura-loading-anim).
// ─────────────────────────────────────────────────────────────────────────────
draw(drawables, constraints) {
if (this.#isLoading) return;
const { annotations, starRating, percentCompleted } = drawables;
if (constraints) {
const { directionConstraint, rollConstraint, centerConstraint } = constraints;
this.#setDistanceColor(annotations, constraints);
if (centerConstraint === constraintCodes.CENTER.NOT_CENTERED) {
this.#faceBorder.setAttribute("visibility", "visible");
this.#drawFaceBorder();
} else {
this.#faceBorder.setAttribute("visibility", "hidden");
}
this.#updateArrows(directionConstraint, rollConstraint);
} else {
this.#borderColor = `hsl(${120}, 100%, 50%)`;
this.#faceBorder.setAttribute("visibility", "hidden");
this.#updateArrows(constraintCodes.DIRECTION.OK, constraintCodes.ROLL.OK);
}
const color = percentCompleted > 0 ? `hsl(${120}, 100%, 50%)` : this.#borderColor;
this.#progressPath.setAttribute("stroke", color);
if (color !== this.#lastTickColor) {
this.#borderTicks.setAttribute("stroke", color);
this.#lastTickColor = color;
}
this.#ovalBorder.setAttribute("stroke", this.#borderColor);
this.#progressPath.setAttribute("stroke-width", this.#borderColor === this.#invalidConstraintColor ? "6" : "4");
this.#ovalBorder.setAttribute("stroke-width", this.#borderColor === this.#invalidConstraintColor ? "6" : "4");
if (this.#ticksVisible !== percentCompleted > 0) {
this.#ticksVisible = percentCompleted > 0;
this.#borderTicks.style.display = this.#ticksVisible ? "block" : "none";
}
if (percentCompleted > 0) {
this.#ovalBorder.setAttribute("stroke", "none");
const pathLength = this.#cachedPathLength;
const distance = percentCompleted / 100 * pathLength;
this.#progressPath.setAttribute("stroke-dashoffset", (pathLength - distance).toString());
this.#setCountDown(distance, percentCompleted, color);
}
for (let i = 0; i < 5; i += 1) {
this.#starRatingGroup.children[i].children[0].setAttribute("fill", starRating > i ? this.#starFillColor : "none");
this.#starRatingGroup.children[i].children[0].setAttribute("stroke", starRating > i ? this.#starFillColor : this.#starBorderColor);
}
}
setMaskVisibility(isVisible) {
this.#svg.style.display = isVisible ? "block" : "none";
}
setLoadingState(isLoading) {
this.#isLoading = isLoading;
this.#ovalBorder.setAttribute("fill", this.#isLoading ? "white" : "none");
this.#ovalBorder.setAttribute("opacity", this.#isLoading ? "0.7" : "1.0");
this.#starRatingGroup.setAttribute("visibility", this.#isLoading ? "hidden" : "visible");
this.#progressPath.setAttribute("visibility", this.#isLoading ? "hidden" : "visible");
this.#toggleLoadingCircleAnimation(this.#isLoading);
this.#borderTicks.style.display = "none";
this.#ticksVisible = false;
if (this.#isLoading) {
this.#faceBorder.setAttribute("visibility", "hidden");
this.#countDownCircle.setAttribute("fill", this.#borderColor);
this.#countDownCircle.setAttribute("visibility", "hidden");
this.#countDownLabel.setAttribute("visibility", "hidden");
this.#intermediateResults.setAttribute("visibility", "hidden");
this.#pulseRateText.textContent = "";
}
}
setIntermediateResults(points) {
if (!this.#isLoading) {
let pulseRate = points["HR_BPM"] ? Math.trunc(parseFloat(points["HR_BPM"].value)).toString() : "";
this.#pulseRateText.textContent = pulseRate;
this.#intermediateResults.setAttribute("visibility", pulseRate ? "visible" : "hidden");
}
}
}
export { AnuraMask, constraintCodes, objectFit };