cduss's picture
Pin reachy-mini.js to v1.7.0 tag
e681a82
Raw
History Blame Contribute Delete
13.3 kB
import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.0/js/reachy-mini.js";
const $ = id => document.getElementById(id);
let robot = null;
let hfToken = null;
let frameBuffer = []; // circular buffer of recent frames for sharp-pick
// ─── Init ───
document.addEventListener("DOMContentLoaded", async () => {
let clientId = null;
try {
const cfg = await (await fetch("/api/oauth-config")).json();
clientId = cfg.clientId || null;
} catch (e) { console.warn("OAuth config fetch failed:", e); }
robot = new ReachyMini({ enableMicrophone: false, clientId });
attachRobotListListener();
if (clientId && await robot.authenticate()) {
hfToken = getStoredToken();
showRobotSelect();
}
initEvents();
});
function getStoredToken() {
return sessionStorage.getItem("hf_token");
}
function showRobotSelect() {
$("loginScreen").hidden = true;
$("robotScreen").hidden = false;
$("username").textContent = robot.username;
connectSignaling();
refreshQuota();
}
function showApp(robotInfo) {
$("robotScreen").hidden = true;
$("appScreen").hidden = false;
$("robotName").textContent = robotInfo.meta?.name || robotInfo.id;
}
// ─── Events ───
function initEvents() {
$("loginBtn").addEventListener("click", () => robot.login());
$("logoutBtn").addEventListener("click", () => {
robot.logout();
location.reload();
});
$("disconnectBtn").addEventListener("click", async () => {
await robot.stopSession();
$("appScreen").hidden = true;
$("robotScreen").hidden = false;
});
$("makeBtn").addEventListener("click", runMakeMe);
$("againBtn").addEventListener("click", () => {
$("revealSection").hidden = true;
$("promptInput").focus();
});
for (const chip of document.querySelectorAll(".chip")) {
chip.addEventListener("click", () => {
$("promptInput").value = chip.dataset.prompt;
$("promptInput").focus();
});
}
// Mic button — push-to-talk
const micBtn = $("micBtn");
micBtn.addEventListener("mousedown", startRecording);
micBtn.addEventListener("touchstart", (e) => { e.preventDefault(); startRecording(); });
micBtn.addEventListener("mouseup", stopRecording);
micBtn.addEventListener("mouseleave", stopRecording);
micBtn.addEventListener("touchend", stopRecording);
// Spacebar shortcut
document.addEventListener("keydown", (e) => {
if (e.code === "Space" && !e.repeat && !e.target.matches("input,textarea,button")) {
e.preventDefault();
startRecording();
}
});
document.addEventListener("keyup", (e) => {
if (e.code === "Space" && !e.target.matches("input,textarea")) {
e.preventDefault();
if (mediaRec?.state === "recording") stopRecording();
}
});
// Enter in prompt = Make me
$("promptInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") runMakeMe();
});
}
// ─── Signaling / robot selection ───
async function connectSignaling() {
try { await robot.connect(); }
catch (e) {
$("robotList").innerHTML = '<p class="error">Connection failed: ' + e.message + '</p>';
}
}
function attachRobotListListener() {
robot.addEventListener("robotsChanged", (e) => {
const list = e.detail.robots;
const container = $("robotList");
if (list.length === 0) {
container.innerHTML = '<p class="muted">No robots online</p>';
return;
}
container.innerHTML = "";
for (const r of list) {
const btn = document.createElement("button");
btn.className = "robot-btn";
btn.textContent = r.meta?.name || r.id;
btn.addEventListener("click", () => selectRobot(r));
container.appendChild(btn);
}
});
}
async function selectRobot(r) {
showApp(r);
robot.attachVideo($("videoEl"));
try {
await robot.startSession(r.id);
$("videoEl").play().catch(() => {});
startFrameBuffer();
} catch (e) {
alert("Session failed: " + e.message);
$("appScreen").hidden = true;
$("robotScreen").hidden = false;
}
}
// ─── Frame buffer (circular, for sharp-pick on capture) ───
const BUFFER_SIZE = 10;
let bufferStopFlag = false;
function startFrameBuffer() {
const video = $("videoEl");
frameBuffer = [];
bufferStopFlag = false;
const push = () => {
if (bufferStopFlag) return;
if (video.videoWidth && video.readyState >= 2) {
// Store a low-res canvas for sharpness scoring only
const c = document.createElement("canvas");
const scale = 0.15;
c.width = video.videoWidth * scale;
c.height = video.videoHeight * scale;
c.getContext("2d").drawImage(video, 0, 0, c.width, c.height);
// Also store timestamp for picking "frame at T"
frameBuffer.push({ time: performance.now(), thumb: c });
if (frameBuffer.length > BUFFER_SIZE) frameBuffer.shift();
}
if ("requestVideoFrameCallback" in video) {
video.requestVideoFrameCallback(push);
} else {
requestAnimationFrame(push);
}
};
if ("requestVideoFrameCallback" in video) {
video.requestVideoFrameCallback(push);
} else {
requestAnimationFrame(push);
}
}
function sharpnessScore(canvas) {
// Laplacian variance approximation — higher = sharper
const ctx = canvas.getContext("2d");
const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height);
let sum = 0, sumSq = 0, count = 0;
// 3x3 Laplacian kernel applied to luma, sampled every 2 px for speed
for (let y = 1; y < height - 1; y += 2) {
for (let x = 1; x < width - 1; x += 2) {
const i = (y * width + x) * 4;
const l = 0.3 * data[i] + 0.6 * data[i + 1] + 0.1 * data[i + 2];
const t = (((y - 1) * width + x) * 4);
const b = (((y + 1) * width + x) * 4);
const le = ((y * width + x - 1) * 4);
const ri = ((y * width + x + 1) * 4);
const lT = 0.3 * data[t] + 0.6 * data[t + 1] + 0.1 * data[t + 2];
const lB = 0.3 * data[b] + 0.6 * data[b + 1] + 0.1 * data[b + 2];
const lL = 0.3 * data[le] + 0.6 * data[le + 1] + 0.1 * data[le + 2];
const lR = 0.3 * data[ri] + 0.6 * data[ri + 1] + 0.1 * data[ri + 2];
const lap = 4 * l - lT - lB - lL - lR;
sum += lap; sumSq += lap * lap; count++;
}
}
const mean = sum / count;
return sumSq / count - mean * mean;
}
// ─── Voice prompting ───
let mediaRec = null;
let chunks = [];
let micStream = null;
let recStartTime = 0;
async function startRecording() {
if (mediaRec?.state === "recording") return;
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
} catch (e) {
setStatus("Mic access denied", true);
return;
}
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: (MediaRecorder.isTypeSupported("audio/mp4") ? "audio/mp4" : "");
chunks = [];
mediaRec = new MediaRecorder(micStream, mime ? { mimeType: mime } : {});
mediaRec.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
mediaRec.start();
recStartTime = performance.now();
$("micBtn").classList.add("recording");
$("micBtn").querySelector(".mic-label").textContent = "● REC";
setStatus("Listening...");
// Robot: perk up antennas
if (robot?.state === "streaming") {
robot.setAntennas(30, -30);
robot.setAudioMuted(true); // suppress robot speaker to avoid echo
}
}
async function stopRecording() {
if (!mediaRec || mediaRec.state !== "recording") return;
const duration = performance.now() - recStartTime;
const rec = mediaRec;
mediaRec = null;
await new Promise((resolve) => {
rec.onstop = resolve;
rec.stop();
});
micStream?.getTracks().forEach(t => t.stop());
micStream = null;
$("micBtn").classList.remove("recording");
$("micBtn").querySelector(".mic-label").textContent = "Hold";
// Robot: nod + reset antennas
if (robot?.state === "streaming") {
robot.setAntennas(0, 0);
robot.setAudioMuted(false);
robot.setHeadPose(0, 5, 0);
setTimeout(() => robot.setHeadPose(0, 0, 0), 250);
}
if (duration < 400) {
setStatus("Too short — hold the mic while speaking");
return;
}
const blob = new Blob(chunks, { type: rec.mimeType || "audio/webm" });
setStatus("Transcribing...");
try {
const fd = new FormData();
fd.append("audio", blob, "prompt.webm");
const res = await fetch("/api/transcribe", {
method: "POST",
body: fd,
headers: { "Authorization": `Bearer ${hfToken}` },
});
if (!res.ok) {
const txt = await res.text();
setStatus(`Transcribe failed: ${txt.slice(0, 200)}`, true);
return;
}
const { text } = await res.json();
if (text) {
$("promptInput").value = text;
setStatus(`Got it: "${text}"`);
} else {
setStatus("Didn't catch anything — try again");
}
} catch (e) {
setStatus("Transcribe failed: " + e.message, true);
}
}
// ─── Make Me flow ───
async function runMakeMe() {
const prompt = $("promptInput").value.trim();
if (!prompt) {
setStatus("Type or speak a prompt first");
$("promptInput").focus();
return;
}
if (!robot || robot.state !== "streaming") {
setStatus("Robot not connected");
return;
}
$("makeBtn").disabled = true;
try {
await countdown(3);
const blob = await captureSharpFrame();
const beforeUrl = URL.createObjectURL(blob);
$("beforeImg").src = beforeUrl;
$("afterImg").src = beforeUrl; // placeholder until transform completes
$("downloadLink").hidden = true;
$("revealSection").hidden = false;
// Robot: thinking animation during render
const thinkingTimer = startThinkingLoop();
setStatus("Transforming...");
const fd = new FormData();
fd.append("image", blob, "capture.jpg");
fd.append("prompt", prompt);
const t0 = performance.now();
const res = await fetch("/api/transform", {
method: "POST",
body: fd,
headers: { "Authorization": `Bearer ${hfToken}` },
});
clearInterval(thinkingTimer);
if (!res.ok) {
const txt = await res.text();
setStatus(`Transform failed: ${txt.slice(0, 200)}`, true);
if (res.status === 429) setStatus("Daily limit reached. Come back tomorrow.", true);
return;
}
const afterBlob = await res.blob();
const afterUrl = URL.createObjectURL(afterBlob);
$("afterImg").src = afterUrl;
$("downloadLink").href = afterUrl;
$("downloadLink").hidden = false;
const elapsed = (performance.now() - t0) / 1000;
$("timingLabel").textContent = `Rendered in ${elapsed.toFixed(1)}s`;
setStatus("");
// Robot: "ta-da" nod
robot.setHeadPose(0, -5, 0);
setTimeout(() => robot.setHeadPose(0, 0, 0), 350);
refreshQuota();
} finally {
$("makeBtn").disabled = false;
}
}
async function countdown(seconds) {
const overlay = $("countdownOverlay");
const num = $("countdownNumber");
overlay.hidden = false;
// Robot: center head, perk up for the photo
robot.setHeadPose(0, 0, 0);
robot.setAntennas(20, -20);
for (let i = seconds; i >= 1; i--) {
num.textContent = i;
num.classList.remove("pulse");
void num.offsetWidth;
num.classList.add("pulse");
await sleep(900);
}
overlay.hidden = true;
// Flash
const flash = $("flashOverlay");
flash.classList.add("flash-active");
setTimeout(() => flash.classList.remove("flash-active"), 120);
robot.setAntennas(0, 0);
}
async function captureSharpFrame() {
const video = $("videoEl");
// Wait one more frame tick so the "flash moment" buffer is freshest
await new Promise(r => setTimeout(r, 50));
// Pick sharpest from buffer
let bestThumb = null;
let bestScore = -Infinity;
for (const entry of frameBuffer) {
const s = sharpnessScore(entry.thumb);
if (s > bestScore) { bestScore = s; bestThumb = entry.thumb; }
}
// Draw full-res version of current frame (we can't retro-grab full-res, but sharpest thumb
// was likely adjacent in time to current frame — so current full-res is close enough)
const canvas = $("captureCanvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
return new Promise(r => canvas.toBlob(r, "image/jpeg", 0.92));
}
function startThinkingLoop() {
let yaw = 0;
let dir = 1;
return setInterval(() => {
yaw += dir * 8;
if (yaw > 15 || yaw < -15) dir *= -1;
if (robot?.state === "streaming") robot.setHeadPose(0, 0, yaw);
}, 300);
}
// ─── Quota / status ───
async function refreshQuota() {
try {
const r = await fetch("/api/quota", {
headers: { "Authorization": `Bearer ${hfToken}` },
});
if (!r.ok) return;
const q = await r.json();
const rem = q.transform.remaining;
$("quotaBadge").textContent = `${rem}/${q.transform.limit} transforms left today`;
$("quotaBadge").classList.toggle("warn", rem <= 2);
} catch (_) {}
}
function setStatus(msg, isError = false) {
const el = $("statusLine");
el.textContent = msg;
el.classList.toggle("error", isError);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }