Prismai / activity_web /backend /static /camera-recorder.js
Satyam S
QR self-enrollment, multi-photo attendance, reject-and-rematch reinforcement, and enrollment reliability fixes
4f0aa08
Raw
History Blame Contribute Delete
7.5 kB
// Shared camera-recording widget, used by both the teacher dashboard's enroll
// tab and the student self-enrollment page. Records a short webm clip via
// MediaRecorder and hands the Blob back to the caller — it never touches a
// file <input>, the caller just asks for the current Blob on submit.
//
// Walks the person through a short guided sequence (look center, turn left,
// turn right, ...) while recording, and overlays a face-position guide, so
// the resulting clip has the pose variety the enrollment pipeline wants
// (recall ANCHOR_CONSISTENCY_THRESHOLD tolerates moderate turns, and the
// degraded/distance copies it generates work better from a clear, centered,
// well-lit face). The sequence is just a default — pass `script` to override.
//
// Usage:
// const recorder = CameraRecorder.create(containerEl);
// ...
// const blob = recorder.getBlob(); // null if nothing recorded yet
// recorder.reset(); // clear after a successful submit
const CameraRecorder = (() => {
const DEFAULT_SCRIPT = [
{ text: "Hold your phone at arm's length, so your whole face fits inside the oval", seconds: 5 },
{ text: "Look straight at the camera", seconds: 10 },
{ text: "Slowly turn your head to the left", seconds: 8 },
{ text: "Slowly turn your head to the right", seconds: 8 },
{ text: "Back to center. Almost done", seconds: 6 },
];
function speak(text) {
if (!("speechSynthesis" in window)) return;
try {
window.speechSynthesis.cancel(); // don't queue/overlap with a prior step
const utter = new SpeechSynthesisUtterance(text);
utter.rate = 0.95;
window.speechSynthesis.speak(utter);
} catch (err) {
console.warn("Speech synthesis unavailable:", err);
}
}
function create(container, options = {}) {
const script = options.script && options.script.length ? options.script : DEFAULT_SCRIPT;
container.innerHTML = `
<div class="camera-recorder">
<div class="camera-video-wrap">
<video class="camera-preview" autoplay muted playsinline></video>
<div class="camera-face-guide" hidden></div>
<div class="camera-instruction" hidden></div>
</div>
<p class="camera-hint">Center your face in the oval, in good light, at arm's length from the camera.</p>
<div class="camera-controls">
<button type="button" class="camera-btn" data-action="start-camera">Start camera</button>
<button type="button" class="camera-btn" data-action="record" disabled>Start recording</button>
<button type="button" class="camera-btn" data-action="retake" hidden>Retake</button>
</div>
<div class="camera-status">Camera off.</div>
</div>
`;
const videoEl = container.querySelector(".camera-preview");
const faceGuideEl = container.querySelector(".camera-face-guide");
const instructionEl = container.querySelector(".camera-instruction");
const statusEl = container.querySelector(".camera-status");
const startBtn = container.querySelector('[data-action="start-camera"]');
const recordBtn = container.querySelector('[data-action="record"]');
const retakeBtn = container.querySelector('[data-action="retake"]');
let stream = null;
let mediaRecorder = null;
let chunks = [];
let recordedBlob = null;
let recording = false;
let scriptTimer = null;
async function startCamera() {
try {
// Explicit resolution request — without this, some phones default to
// a low capture resolution (observed as low as 480x640), which combined
// with a face filling most of the frame gives the detector very little
// to work with. "ideal" degrades gracefully on cameras that can't hit it.
stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
});
} catch (err) {
statusEl.textContent = "Couldn't access the camera: " + err.message;
return;
}
videoEl.srcObject = stream;
faceGuideEl.hidden = false;
startBtn.disabled = true;
recordBtn.disabled = false;
statusEl.textContent = "Camera on. Center your face in the oval, then start recording.";
}
function runGuidedScript() {
let stepIndex = 0;
const showStep = () => {
if (!recording || stepIndex >= script.length) {
instructionEl.hidden = true;
if (recording) stopRecording();
return;
}
const step = script[stepIndex];
instructionEl.hidden = false;
instructionEl.textContent = `${step.text} (${stepIndex + 1}/${script.length})`;
speak(step.text);
stepIndex++;
scriptTimer = setTimeout(showStep, step.seconds * 1000);
};
showStep();
}
function startRecording() {
if (!stream) return;
chunks = [];
const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp8")
? "video/webm;codecs=vp8"
: "video/webm";
mediaRecorder = new MediaRecorder(stream, { mimeType });
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
mediaRecorder.onstop = () => {
recordedBlob = new Blob(chunks, { type: mimeType });
videoEl.srcObject = null;
videoEl.src = URL.createObjectURL(recordedBlob);
videoEl.muted = false;
videoEl.controls = true;
videoEl.play().catch(() => {});
stream.getTracks().forEach((t) => t.stop());
stream = null;
recording = false;
faceGuideEl.hidden = true;
instructionEl.hidden = true;
recordBtn.hidden = true;
retakeBtn.hidden = false;
statusEl.textContent = "Recorded. Review it, or retake.";
};
mediaRecorder.start();
recording = true;
recordBtn.textContent = "Stop recording";
statusEl.textContent = "Recording…";
runGuidedScript();
}
function stopRecording() {
if (scriptTimer) { clearTimeout(scriptTimer); scriptTimer = null; }
if ("speechSynthesis" in window) window.speechSynthesis.cancel();
if (mediaRecorder && recording) mediaRecorder.stop();
}
function retake() {
if (scriptTimer) { clearTimeout(scriptTimer); scriptTimer = null; }
if ("speechSynthesis" in window) window.speechSynthesis.cancel();
recordedBlob = null;
videoEl.controls = false;
videoEl.muted = true;
videoEl.removeAttribute("src");
videoEl.load();
faceGuideEl.hidden = true;
instructionEl.hidden = true;
recordBtn.hidden = false;
recordBtn.textContent = "Start recording";
retakeBtn.hidden = true;
startBtn.disabled = false;
recordBtn.disabled = true;
statusEl.textContent = "Camera off.";
}
startBtn.addEventListener("click", startCamera);
recordBtn.addEventListener("click", () => (recording ? stopRecording() : startRecording()));
retakeBtn.addEventListener("click", retake);
return {
getBlob: () => recordedBlob,
reset: retake,
stopStream: () => {
if (scriptTimer) { clearTimeout(scriptTimer); scriptTimer = null; }
if ("speechSynthesis" in window) window.speechSynthesis.cancel();
if (stream) stream.getTracks().forEach((t) => t.stop());
},
};
}
return { create, DEFAULT_SCRIPT };
})();