Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 169 additions & 3 deletions packages/web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ let compareMode = false;
let svgCache = {};
let raf = 0;
let pdMm = TryOn?.DEFAULT_PD_MM || 64;
let calibrating = false;
let lang = "en";
let tracking = "geometric"; // geometric | mediapipe
let faceMesh = null;
Expand Down Expand Up @@ -140,6 +141,12 @@ const I18N = {
cloudLocal: "🔒 Local Only",
cloudServer: "☁️ Cloud Enabled",
photoConsentLabel: "Allow face photo server upload & cloud features",
calibrateBtn: "📏 Calibrate",
calibrating: "📏 Calibrating…",
wizardText: "Adjust slider until circles align with your pupils, then confirm.",
confirmPd: "✓ Confirm PD",
cancelPd: "✕ Cancel",
calHint: "Adjust slider until circles align with your pupils",
},
vi: {
tagline: "Thử kính & phụ kiện ảo",
Expand Down Expand Up @@ -167,6 +174,12 @@ const I18N = {
cloudLocal: "🔒 Chỉ lưu cục bộ",
cloudServer: "☁️ Đã bật Cloud",
photoConsentLabel: "Cho phép tải ảnh mặt lên server & dùng tính năng đám mây",
calibrateBtn: "📏 Hiệu chỉnh",
calibrating: "📏 Đang hiệu chỉnh…",
wizardText: "Chỉnh slider đến khi vòng tròn khớp với đồng tử của bạn.",
confirmPd: "✓ Xác nhận PD",
cancelPd: "✕ Hủy",
calHint: "Chỉnh slider đến khi vòng tròn khớp đồng tử",
},
};

Expand All @@ -177,7 +190,8 @@ function t(k) {
function applyLang() {
document.getElementById("tagline").textContent = t("tagline");
document.getElementById("lbl-catalog").textContent = t("catalog");
document.getElementById("lbl-pd").childNodes[0].textContent = t("pd") + " ";
const pdLabel = document.getElementById("lbl-pd");
if (pdLabel) pdLabel.innerHTML = t("pd") + ' <span id="pd-val">' + pdMm + "</span>";
document.getElementById("pd-hint").textContent = t("pdHint");
document.getElementById("btn-cam").textContent = t("cam");
document.getElementById("btn-demo").textContent = t("demo");
Expand All @@ -192,6 +206,11 @@ function applyLang() {
document.getElementById("consent-ok").textContent = t("consentOk");
document.getElementById("consent-demo").textContent = t("consentDemo");

const btnCalibrate = document.getElementById("btn-calibrate");
if (btnCalibrate) {
btnCalibrate.textContent = calibrating ? t("calibrating") : t("calibrateBtn");
}

// Update cloud toggle and checkbox translation labels (optional DOM from consent PR)
const btnCloud = document.getElementById("btn-cloud");
if (btnCloud) btnCloud.textContent = photoConsent ? t("cloudServer") : t("cloudLocal");
Expand Down Expand Up @@ -647,6 +666,145 @@ function drawGlasses() {
TryOn.drawGlassesOverlay(ctx, face, selected, selectedB, compareMode, pdMm);
}

/**
* Draw PD calibration overlay with visual measurement guides.
* Shows reference circles at estimated pupil positions and a ruler line.
*/
function drawCalibrationOverlay() {
if (!TryOn) return;
const w = canvas.width, h = canvas.height;
const metrics = TryOn.faceMetricsFromLandmarks(face, w, h);
const pdPx = metrics.pdPx;

const calPxPerMm = pdPx / pdMm;
const halfPdPx = (pdMm * calPxPerMm) / 2;
const midX = metrics.midX;
const midY = metrics.midY;
const angle = metrics.angle;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);

const lx = midX - halfPdPx * cosA;
const ly = midY - halfPdPx * sinA;
const rx = midX + halfPdPx * cosA;
const ry = midY + halfPdPx * sinA;

ctx.save();

// Draw ruler line between calibration points
ctx.strokeStyle = "#f5c518";
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(lx, ly);
ctx.lineTo(rx, ry);
ctx.stroke();
ctx.setLineDash([]);

// Draw calibration reference circles with crosshairs
const circleR = 16;
[ [lx, ly], [rx, ry] ].forEach(([cx, cy]) => {
const grad = ctx.createRadialGradient(cx, cy, 2, cx, cy, circleR + 6);
grad.addColorStop(0, "rgba(91, 140, 255, 0.3)");
grad.addColorStop(1, "rgba(91, 140, 255, 0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, circleR + 6, 0, Math.PI * 2);
ctx.fill();

ctx.strokeStyle = "#5b8cff";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, circleR, 0, Math.PI * 2);
ctx.stroke();

ctx.strokeStyle = "#5b8cff88";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - 6, cy);
ctx.lineTo(cx + 6, cy);
ctx.moveTo(cx, cy - 6);
ctx.lineTo(cx, cy + 6);
ctx.stroke();
});

// PD measurement label
ctx.fillStyle = "#f5c518";
ctx.font = "bold 14px system-ui";
ctx.textAlign = "center";
ctx.fillText(`${pdMm.toFixed(1)} mm`, midX, midY - 32);

ctx.fillStyle = "#8b9bb888";
ctx.font = "11px system-ui";
ctx.fillText(`detected: ${pdPx.toFixed(0)} px`, midX, midY + circleR + 24);

ctx.restore();

const fitScale = selected ? (pdPx / pdMm) * (selected.fit?.width_mm || 140) / (pdPx || 1) : 0;
const fitInfo = selected ? ` · fit: ${(fitScale * 100).toFixed(0)}%` : '';
document.getElementById("wizard-pd-measure").textContent = `PD: ${pdMm.toFixed(1)} mm`;
const fiEl = document.getElementById("wizard-fit-info");
if (fiEl) fiEl.textContent = fitInfo;
}

function loadPd() {
try {
const saved = localStorage.getItem("beear_pd_mm");
if (saved !== null) {
const v = parseFloat(saved);
if (v >= 50 && v <= 80) {
pdMm = v;
document.getElementById("pd").value = v;
document.getElementById("pd-val").textContent = String(v);
}
}
} catch (_) {}
}

function savePd(val) {
try {
localStorage.setItem("beear_pd_mm", String(val));
} catch (_) {}
}

function startCalibration() {
calibrating = true;
document.getElementById("pd-wizard").classList.remove("hidden");
const btnCal = document.getElementById("btn-calibrate");
if (btnCal) {
btnCal.textContent = t("calibrating");
btnCal.disabled = true;
}
document.getElementById("pd-hint").textContent = t("calHint");
updateMeta();
}

function confirmCalibration() {
calibrating = false;
document.getElementById("pd-wizard").classList.add("hidden");
const btnCal = document.getElementById("btn-calibrate");
if (btnCal) {
btnCal.textContent = t("calibrateBtn");
btnCal.disabled = false;
}
document.getElementById("pd-hint").textContent = t("pdHint");
savePd(pdMm);
updateMeta();
}

function cancelCalibration() {
calibrating = false;
document.getElementById("pd-wizard").classList.add("hidden");
const btnCal = document.getElementById("btn-calibrate");
if (btnCal) {
btnCal.textContent = t("calibrateBtn");
btnCal.disabled = false;
}
document.getElementById("pd-hint").textContent = t("pdHint");
loadPd();
updateMeta();
}

function sendToMesh() {
if (!faceMesh || meshBusy || mode !== "camera" || video.readyState < 2) return;
meshBusy = true;
Expand Down Expand Up @@ -686,7 +844,8 @@ function loop() {
ctx.font = "16px system-ui";
ctx.fillText(t("hintIdle"), 40, h / 2);
}
if (mode !== "idle") drawGlasses();
if (mode !== "idle" && calibrating) drawCalibrationOverlay();
else if (mode !== "idle") drawGlasses();
raf = requestAnimationFrame(loop);
}

Expand Down Expand Up @@ -894,8 +1053,14 @@ document.getElementById("filter").onchange = (e) => loadCatalog(e.target.value);
document.getElementById("pd").oninput = (e) => {
pdMm = Number(e.target.value) || 64;
document.getElementById("pd-val").textContent = String(pdMm);
if (!calibrating) {
savePd(pdMm);
}
updateMeta();
};
document.getElementById("btn-calibrate").onclick = startCalibration;
document.getElementById("btn-confirm-pd").onclick = confirmCalibration;
document.getElementById("btn-cancel-pd").onclick = cancelCalibration;

// Update interaction execution pipeline on primary modal acknowledgement
document.getElementById("consent-ok").onclick = () => {
Expand All @@ -910,6 +1075,7 @@ document.getElementById("consent-demo").onclick = startDemo;

setStageLoad("Starting BeeAR demo…");
applyLang();
loadPd();
Promise.all([
loadDemoImages(),
loadCatalog().catch((e) => {
Expand All @@ -930,4 +1096,4 @@ Promise.all([
startDemo();
loop();
setStageLoad(null);
});
});
19 changes: 19 additions & 0 deletions packages/web/assets/studio3d.css
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,25 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
border-top: 1px solid var(--line); padding: 10px 12px; font-size: 12px; color: var(--muted);
min-height: 64px;
}
.gallery {
border-top: 1px solid var(--line); max-height: 160px; overflow: auto;
padding: 8px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
}
.gallery.hidden { display: none; }
.shot { position: relative; }
.shot img { width: 100%; border-radius: 6px; border: 1px solid var(--line); display: block; }
.btn.tiny { position: absolute; top: 2px; right: 2px; padding: 0 6px; font-size: 12px; line-height: 20px; z-index: 1; }
.shot-actions { position: absolute; top: 2px; right: 2px; display: flex; gap: 2px; }
.shot-actions .btn.tiny { position: static; }
.btn.tiny.share { right: 24px; }
.share-toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: #1a2a44; color: #e8eefc; padding: 10px 20px; border-radius: 10px;
font-size: 14px; z-index: 99; border: 1px solid var(--accent2);
animation: toast-in 0.3s ease, toast-out 0.3s ease 1.7s forwards;
}
@keyframes toast-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@keyframes toast-out { from { opacity: 1; } to { opacity: 0; } }
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.rail { border-left: none; border-top: 1px solid var(--line); max-height: 42vh; }
Expand Down
87 changes: 86 additions & 1 deletion packages/web/assets/studio3d.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const btnAuto = document.getElementById("btn-auto");
const btnSnap = document.getElementById("btn-snap");
const filterEl = document.getElementById("filter");
const personSelect = document.getElementById("person-select");
const galleryEl = document.getElementById("gallery-3d");
const btnGallery = document.getElementById("btn-gallery-3d");

const state = {
frames: [],
Expand Down Expand Up @@ -524,11 +526,94 @@ btnAuto.addEventListener("click", () => {
btnAuto.textContent = state.auto ? "Auto-rotate ON" : "Auto-rotate OFF";
});
btnSnap.addEventListener("click", () => {
const data = canvas.toDataURL("image/png");
const item = {
id: Date.now(),
frame: state.selected?.id || "none",
person: state.activePerson?.id || "person",
pdMm: 64,
data,
};
const items = load3dGallery();
items.unshift(item);
save3dGallery(items);
const a = document.createElement("a");
a.download = `beear-3d-${state.activePerson?.id || "person"}-${state.selected?.id || "snap"}.png`;
a.href = canvas.toDataURL("image/png");
a.href = data;
a.click();
render3dGallery();
});
if (btnGallery) {
btnGallery.addEventListener("click", () => {
galleryEl.classList.toggle("hidden");
render3dGallery();
});
}

function gallery3dKey() { return "beear_gallery_3d_v1"; }
function load3dGallery() {
try { return JSON.parse(localStorage.getItem(gallery3dKey()) || "[]"); }
catch { return []; }
}
function save3dGallery(items) {
localStorage.setItem(gallery3dKey(), JSON.stringify(items.slice(0, 24)));
}
function render3dGallery() {
const items = load3dGallery();
if (!items.length) {
galleryEl.innerHTML = '<p class="muted small">No 3D snapshots yet</p>';
return;
}
galleryEl.innerHTML = items
.map(
(it) =>
`<div class="shot"><img src="${it.data}" alt=""/><div class="shot-actions"><button data-id="${it.id}" class="btn tiny share" title="Share">↗</button><button data-id="${it.id}" class="btn tiny del" title="Delete">×</button></div></div>`,
)
.join("");
galleryEl.querySelectorAll(".del").forEach((btn) => {
btn.onclick = () => {
const id = Number(btn.getAttribute("data-id"));
save3dGallery(load3dGallery().filter((x) => x.id !== id));
render3dGallery();
};
});
galleryEl.querySelectorAll(".share").forEach((btn) => {
btn.onclick = async () => {
const id = Number(btn.getAttribute("data-id"));
const items = load3dGallery();
const item = items.find((x) => x.id === id);
if (!item) return;
await share3dSnapshot(item);
};
});
}
async function share3dSnapshot(item) {
const shareData = {
title: "BeeAR 3D Try-On",
text: `BeeAR 3D try-on: ${item.person} wearing ${item.frame}`,
url: item.data,
};
if (navigator.share && window.innerWidth < 900) {
try {
const blob = await (await fetch(item.data)).blob();
const file = new File([blob], `beear-3d-${item.person}-${item.id}.png`, { type: "image/png" });
await navigator.share({ ...shareData, files: [file] });
return;
} catch (e) {
if (e.name === "AbortError") return;
}
}
try {
await navigator.clipboard.writeText(shareData.text + " — " + location.origin);
const feedback = document.createElement("div");
feedback.className = "share-toast";
feedback.textContent = "📋 Share link copied!";
document.body.appendChild(feedback);
setTimeout(() => feedback.remove(), 2000);
} catch (_) {
window.open(item.data, "_blank");
}
}

let t0 = performance.now();
function tick(now) {
Expand Down
Loading
Loading