[object Object]

← back to Big Red

snapshot: 4 file(s) changed, +2 new, ~2 modified

95b5ea08bd8c9b701ef88117487a4099afcfc6cd · 2026-05-13 08:57:46 -0700 · Steve

Files touched

Diff

commit 95b5ea08bd8c9b701ef88117487a4099afcfc6cd
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 13 08:57:46 2026 -0700

    snapshot: 4 file(s) changed, +2 new, ~2 modified
---
 .selected-model               |   1 +
 data/price-verifications.json |  17 +++++++
 public/desktop.html           | 106 ++++++++++++++++++++++++++++++++++++++++--
 public/phone.html             |   2 +-
 4 files changed, 122 insertions(+), 4 deletions(-)

diff --git a/.selected-model b/.selected-model
new file mode 100644
index 0000000..6a20541
--- /dev/null
+++ b/.selected-model
@@ -0,0 +1 @@
+hermes3:8b
diff --git a/data/price-verifications.json b/data/price-verifications.json
new file mode 100644
index 0000000..30c143c
--- /dev/null
+++ b/data/price-verifications.json
@@ -0,0 +1,17 @@
+{
+  "DWKK-150018": {
+    "price": 154.35,
+    "source": "shopify-admin-live",
+    "verified_at": "2026-05-12T13:39:06.515Z"
+  },
+  "DWSC-700068": {
+    "price": 425.41,
+    "source": "shopify-admin-live",
+    "verified_at": "2026-05-12T13:39:06.871Z"
+  },
+  "DWSC-700105": {
+    "price": 474.35,
+    "source": "shopify-admin-live",
+    "verified_at": "2026-05-12T13:39:07.177Z"
+  }
+}
\ No newline at end of file
diff --git a/public/desktop.html b/public/desktop.html
index 08105e7..5dc5f4c 100644
--- a/public/desktop.html
+++ b/public/desktop.html
@@ -87,6 +87,11 @@
     transform: scaleY(calc(0.15 + var(--speak, 0) * 1));
     transition: transform 80ms ease-out;
   }
+  /* Photo-avatar mouth overlay uses its own (smaller) viewBox-centered origin */
+  #photoMouthOverlay .mouth-inner {
+    transform-origin: 60px 25px;
+    transform: scaleY(calc(0.15 + var(--speak, 0) * 1.6));
+  }
   /* Subtle idle bob */
   @keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
   .avatar { animation: bob 6s ease-in-out infinite; }
@@ -257,8 +262,19 @@
       <span class="pill" id="pillPhone"><span class="dot" id="dotPhone"></span>phone</span>
     </div>
 
-    <div class="avatar-wrap">
-      <svg class="avatar" viewBox="0 0 500 525" xmlns="http://www.w3.org/2000/svg">
+    <div class="avatar-wrap" style="position:relative">
+      <!-- Photo avatar (shown when /api/avatar exists, hidden otherwise) -->
+      <img id="photoAvatar" alt="Steve" style="display:none;width:100%;max-width:500px;aspect-ratio:1/1.05;object-fit:cover;object-position:center 30%;border-radius:12px;box-shadow:0 0 60px rgba(232,65,71,.35);"/>
+      <!-- Animated mouth overlay (positioned over the photo's mouth area, scales with --speak) -->
+      <div id="photoMouthOverlay" style="display:none;position:absolute;left:50%;top:72%;transform:translate(-50%,-50%);pointer-events:none">
+        <svg width="120" height="50" viewBox="0 0 120 50">
+          <ellipse cx="60" cy="25" rx="42" ry="6" fill="rgba(58,10,12,.0)" stroke="rgba(255,255,255,0)" stroke-width="0"/>
+          <ellipse class="mouth-inner" cx="60" cy="25" rx="38" ry="9" fill="#3a0a0c" opacity=".75"
+                   style="filter:drop-shadow(0 2px 6px rgba(0,0,0,.5))"/>
+        </svg>
+      </div>
+      <!-- Default SVG avatar (hidden when photo avatar is set) -->
+      <svg class="avatar" id="svgAvatar" viewBox="0 0 500 525" xmlns="http://www.w3.org/2000/svg">
         <defs>
           <radialGradient id="skinGrad" cx="50%" cy="40%" r="60%">
             <stop offset="0%" stop-color="#fbe0c8"/>
@@ -390,6 +406,12 @@
           <button class="btn" id="analyzeBtn" disabled>Analyze frame</button>
         </div>
         <div class="analyze-result" id="analyzeResult">No analysis yet.</div>
+
+        <div class="analyze-row" style="margin-top:8px">
+          <button class="btn" id="captureAvatarBtn" disabled style="background:#a02320;flex:1">📸 Make Me the Avatar</button>
+          <button class="btn" id="resetAvatarBtn" style="background:#444;flex:0 0 auto">↺ Reset</button>
+        </div>
+        <div class="analyze-result" id="avatarStatus" style="font-size:11px">Pair phone, then click "Make Me the Avatar" to capture your face.</div>
       </div>
     </div>
   </aside>
@@ -441,6 +463,7 @@
     src.connect(analyser);
     analyser.connect(audioCtx.destination);
     const data = new Uint8Array(analyser.frequencyBinCount);
+    let sineFallbackUsed = 0;
     function tick() {
       analyser.getByteTimeDomainData(data);
       let sum = 0;
@@ -449,7 +472,17 @@
         sum += v * v;
       }
       const rms = Math.sqrt(sum / data.length);
-      setSpeak(rms * 4);
+      // Fallback: if analyser RMS is silent (some browsers/CORS edge cases) but
+      // audio is playing, drive the mouth with a sine wave so it still animates.
+      if (rms < 0.005 && !audioEl.paused && !audioEl.ended && audioEl.currentTime > 0.05) {
+        sineFallbackUsed++;
+        const t = audioEl.currentTime;
+        // Layered sines + smooth random envelope = natural-looking talk shape
+        const env = 0.55 + 0.4 * Math.abs(Math.sin(t * 7.3) * Math.cos(t * 3.1) + Math.sin(t * 11.7) * 0.4);
+        setSpeak(env);
+      } else {
+        setSpeak(rms * 4);
+      }
       speakRaf = requestAnimationFrame(tick);
     }
     if (speakRaf) cancelAnimationFrame(speakRaf);
@@ -616,6 +649,7 @@
       phoneVideo.srcObject = phoneStream;
       videoEmpty.style.display = 'none';
       analyzeBtn.disabled = false;
+      if (typeof captureAvatarBtn !== 'undefined' && captureAvatarBtn) captureAvatarBtn.disabled = false;
       dotPhone.classList.add('ok');
     });
     pc.addEventListener('connectionstatechange', () => {
@@ -632,9 +666,75 @@
     phoneVideo.srcObject = null;
     videoEmpty.style.display = 'flex';
     analyzeBtn.disabled = true;
+    if (typeof captureAvatarBtn !== 'undefined' && captureAvatarBtn) captureAvatarBtn.disabled = true;
     dotPhone.classList.remove('ok');
   }
 
+  // -------- avatar capture (use phone-cam frame as Steve's face) --------
+  const photoAvatar = $('photoAvatar');
+  const photoMouthOverlay = $('photoMouthOverlay');
+  const svgAvatar = $('svgAvatar');
+  const captureAvatarBtn = $('captureAvatarBtn');
+  const resetAvatarBtn = $('resetAvatarBtn');
+  const avatarStatus = $('avatarStatus');
+
+  function showPhotoAvatar(url) {
+    photoAvatar.src = url;
+    photoAvatar.style.display = '';
+    photoMouthOverlay.style.display = '';
+    svgAvatar.style.display = 'none';
+  }
+  function showSvgAvatar() {
+    photoAvatar.style.display = 'none';
+    photoMouthOverlay.style.display = 'none';
+    svgAvatar.style.display = '';
+  }
+
+  // Load any existing avatar on boot
+  fetch('/api/avatar').then(r => r.json()).then(j => {
+    if (j.exists) { showPhotoAvatar(j.url); avatarStatus.textContent = 'Photo avatar active. Click Reset to go back to red-haired Steve.'; }
+  }).catch(() => {});
+
+  // Enable the capture button only when phone is paired and streaming
+  function refreshAvatarBtn() {
+    captureAvatarBtn.disabled = !phoneStream;
+  }
+
+  captureAvatarBtn.addEventListener('click', async () => {
+    if (!phoneStream) { avatarStatus.textContent = 'Pair your phone first.'; return; }
+    const v = phoneVideo;
+    captureCanvas.width = v.videoWidth || 1280;
+    captureCanvas.height = v.videoHeight || 720;
+    const ctx = captureCanvas.getContext('2d');
+    ctx.drawImage(v, 0, 0, captureCanvas.width, captureCanvas.height);
+    const dataUrl = captureCanvas.toDataURL('image/jpeg', 0.92);
+    captureAvatarBtn.disabled = true;
+    avatarStatus.textContent = 'Saving your face as the avatar…';
+    try {
+      const r = await fetch('/api/avatar/capture', {
+        method: 'POST', headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({ dataUrl })
+      });
+      const j = await r.json();
+      if (j.ok) {
+        showPhotoAvatar(j.url);
+        avatarStatus.textContent = 'Avatar set. Mouth will animate when speaking.';
+      } else {
+        avatarStatus.textContent = 'Failed: ' + (j.error || 'unknown');
+      }
+    } catch (e) {
+      avatarStatus.textContent = 'Error: ' + e.message;
+    } finally {
+      captureAvatarBtn.disabled = !phoneStream;
+    }
+  });
+
+  resetAvatarBtn.addEventListener('click', async () => {
+    await fetch('/api/avatar', { method: 'DELETE' });
+    showSvgAvatar();
+    avatarStatus.textContent = 'Reset — using default red-haired SVG.';
+  });
+
   // -------- frame analysis --------
   analyzeBtn.addEventListener('click', async () => {
     if (!phoneStream) return;
diff --git a/public/phone.html b/public/phone.html
index b994118..d0a8b54 100644
--- a/public/phone.html
+++ b/public/phone.html
@@ -98,7 +98,7 @@
     try { await getStream(); }
     catch (e) {
       errEl.style.display = 'block';
-      errEl.textContent = 'Camera error: ' + e.message + '. Make sure you are on http://' + location.host + ' (not https), and grant camera access.';
+      errEl.textContent = 'Camera error: ' + e.message + '. iOS Safari requires HTTPS — open the https:// cloudflared URL on your phone, not http://192.168.x.x. Then grant camera access.';
       return;
     }
     setStatus('connecting', false);

← 6688ef1 feat(big-red/tick3): SKU resolver fallback into sister catal  ·  back to Big Red  ·  big-red: lower-left launcher + woman-avatar fallback + retai 2335ec4 →