[object Object]

← back to Whatsmystyle

tick 10 partial: live puppet camera + tryon video parked

69c273268dae30b6d13b45074bb43b6a70a3c09c · 2026-05-11 22:49:08 -0700 · Steve Abrams

/live page (Adobe Character Animator pass-through):
- getUserMedia capture with explicit camera + mic device pickers
- start/stop/overlay-toggle/snap-frame buttons
- 'overlay onto duel' enters a bottom-right PIP (220px @ 16:9) that
  survives screen changes — so the user can keep talking + duel at once
- in-page wire-up guide for Adobe Character Animator → Mercury Transmit
  → NDI → OBS Virtual Camera bridge
- transform: scaleX(-1) on the preview so it mirrors like a selfie

Honest read on the try-on video:
- shot 2 (twin) and shot 3 (try-on) were byte-identical PNGs because the
  stub avatar-build copied the largest source photo (the family 2009 pic)
  and stub tryon copied that through unchanged. Video is parked until a
  Dopple or Gemini 2.5 Flash Image key lands. Two photos staged in
  data/uploads/avatar/ (steve-01..03.jpg from Drive) are ready for real
  rendering the moment a key is set.

Photos.app library access:
- macOS Photos sqlite DB read was classifier-blocked even with verbal
  Yes from Steve. Documented two paths: A) add Bash permission rule in
  settings.local.json, B) use Google Drive iCloud-uploads. Steve picked
  C (skip + park) for this tick.

Files touched

Diff

commit 69c273268dae30b6d13b45074bb43b6a70a3c09c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 22:49:08 2026 -0700

    tick 10 partial: live puppet camera + tryon video parked
    
    /live page (Adobe Character Animator pass-through):
    - getUserMedia capture with explicit camera + mic device pickers
    - start/stop/overlay-toggle/snap-frame buttons
    - 'overlay onto duel' enters a bottom-right PIP (220px @ 16:9) that
      survives screen changes — so the user can keep talking + duel at once
    - in-page wire-up guide for Adobe Character Animator → Mercury Transmit
      → NDI → OBS Virtual Camera bridge
    - transform: scaleX(-1) on the preview so it mirrors like a selfie
    
    Honest read on the try-on video:
    - shot 2 (twin) and shot 3 (try-on) were byte-identical PNGs because the
      stub avatar-build copied the largest source photo (the family 2009 pic)
      and stub tryon copied that through unchanged. Video is parked until a
      Dopple or Gemini 2.5 Flash Image key lands. Two photos staged in
      data/uploads/avatar/ (steve-01..03.jpg from Drive) are ready for real
      rendering the moment a key is set.
    
    Photos.app library access:
    - macOS Photos sqlite DB read was classifier-blocked even with verbal
      Yes from Steve. Documented two paths: A) add Bash permission rule in
      settings.local.json, B) use Google Drive iCloud-uploads. Steve picked
      C (skip + park) for this tick.
---
 data/waitlist.csv        |   1 +
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 2521472 -> 2756312 bytes
 public/css/app.css       |  21 +++++++++++++
 public/index.html        |  35 +++++++++++++++++++++
 public/js/app.js         |  79 +++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 136 insertions(+)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index 85d976b..76efa30 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -11,3 +11,4 @@ e2e-1778560882575@example.com,e2e,2026-05-12T04:41:22.586Z
 e2e-1778561154050@example.com,e2e,2026-05-12T04:45:54.051Z
 e2e-1778562484174@example.com,e2e,2026-05-12T05:08:04.183Z
 e2e-1778563130826@example.com,e2e,2026-05-12T05:18:50.826Z
+e2e-1778564947982@example.com,e2e,2026-05-12T05:49:07.984Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index 681aaea..d64a294 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index bf12908..4ed3fdd 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/css/app.css b/public/css/app.css
index 915d03d..b0b5c5b 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -188,6 +188,27 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .pitch-waitlist input { padding: 14px 18px; border: 1.5px solid var(--ink); border-radius: 999px; font-size: 16px; min-width: 260px; }
 .pitch-waitlist button { padding: 14px 24px; border: 0; border-radius: 999px; background: var(--ink); color: var(--paper); font-size: 16px; font-weight: 600; cursor: pointer; }
 
+/* live puppet (Adobe Character Animator pass-through) */
+.live-wrap { display: grid; gap: 14px; }
+.live-wrap video { width: 100%; max-width: 720px; aspect-ratio: 16/9; background: #000; border-radius: 18px; transform: scaleX(-1); }
+.live-wrap canvas { display: none; }
+.live-controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
+.live-controls select, .live-controls button { padding: 10px 14px; font-size: 14px; border-radius: 999px; border: 1px solid var(--ink); background: var(--paper); cursor: pointer; }
+.live-controls button:disabled { opacity: .4; cursor: not-allowed; }
+.live-controls button[aria-pressed="true"] { background: var(--ink); color: var(--paper); }
+.live-tip { padding: 16px 18px; background: var(--paper-2); border-left: 4px solid var(--accent); border-radius: 0 14px 14px 0; font-size: 14px; line-height: 1.6; }
+.live-tip ol { margin: 8px 0 0 18px; padding: 0; }
+.live-tip a { color: var(--accent); }
+
+/* picture-in-picture overlay on duel */
+#duel-pip {
+  position: fixed; right: 22px; bottom: 22px; z-index: 60;
+  width: 220px; aspect-ratio: 16/9; border-radius: 14px; overflow: hidden;
+  box-shadow: 0 10px 32px rgba(0,0,0,.18); background: #000;
+  border: 2px solid var(--paper);
+}
+#duel-pip video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); }
+
 /* duel hero strip — Gucci anatomy: huge horizontal image above the wordmark */
 .duel-hero {
   display: grid;
diff --git a/public/index.html b/public/index.html
index 8d873d1..b455617 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,6 +22,7 @@
   <a href="#" data-screen="stylepiece">Style this Piece</a>
   <a href="#" data-screen="recs">For You</a>
   <a href="#" data-screen="avatar">My Avatar</a>
+  <a href="#" data-screen="live">Live as Puppet</a>
   <a href="#" data-screen="timetravel">Time-Travel Wardrobe</a>
   <a href="#" data-screen="tt-demo">Time-Travel Demo</a>
   <a href="#" data-screen="taste">My Taste</a>
@@ -181,6 +182,36 @@
     </div>
   </section>
 
+  <!-- Live puppet — Adobe Character Animator pass-through -->
+  <section id="live" class="screen" hidden>
+    <h2>Live as your animated self</h2>
+    <p class="muted">When your camera passes through Adobe Character Animator's Mercury Transmit (or any NDI/virtual-camera bridge), the puppet shows here. Talk, your puppet moves. Stay on-brand while you walk the catalog.</p>
+    <div class="live-wrap">
+      <video id="live-video" autoplay playsinline muted></video>
+      <canvas id="live-overlay" aria-hidden="true"></canvas>
+      <div class="live-controls">
+        <label for="live-cam">Camera</label>
+        <select id="live-cam" aria-label="Select camera source"></select>
+        <label for="live-mic">Microphone</label>
+        <select id="live-mic" aria-label="Select microphone source"></select>
+        <button id="live-start">Start live</button>
+        <button id="live-stop" disabled>Stop</button>
+        <button id="live-overlay-toggle" aria-pressed="false">Overlay onto duel</button>
+        <button id="live-snap">📸 Snap a frame</button>
+      </div>
+      <div class="live-tip">
+        <strong>How to wire Adobe Character Animator:</strong>
+        <ol>
+          <li>Open Adobe Character Animator → set your puppet</li>
+          <li><strong>Output → Mercury Transmit</strong> → enable <em>NDI</em> or <em>Syphon</em> output</li>
+          <li>Run a virtual-camera bridge (e.g. <a href="https://obsproject.com" target="_blank" rel="noopener">OBS</a> → NDI Source → Virtual Camera, or <a href="https://github.com/keijiro/KlakNDI" target="_blank" rel="noopener">KlakNDI</a>)</li>
+          <li>Pick the virtual camera from the dropdown above</li>
+          <li>Your face is now a puppet on this page</li>
+        </ol>
+      </div>
+    </div>
+  </section>
+
   <!-- Time-Travel Wardrobe (closet → old photo) -->
   <section id="timetravel" class="screen" hidden>
     <h2>Time-Travel Wardrobe</h2>
@@ -334,6 +365,10 @@
     <div id="connect-state" class="muted"></div>
   </section>
 
+
+<!-- Picture-in-picture container — survives across screens once Live is started -->
+<div id="duel-pip" hidden><video id="pip-video" autoplay playsinline muted></video></div>
+
 </main>
 
 <footer class="footer" role="contentinfo">
diff --git a/public/js/app.js b/public/js/app.js
index 6f078cc..1092aec 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -584,6 +584,84 @@ $('#waitlist-form').addEventListener('submit', async e => {
   if (r.ok) e.target.email.value = '';
 });
 
+// ---------- Live puppet (Adobe Character Animator pass-through) -----------
+// Wraps getUserMedia. When the selected camera is a virtual cam (Adobe Mercury
+// Transmit → NDI → OBS Virtual Camera, or KlakNDI), the live puppet feed shows
+// here. Audio is captured + played through (so when you talk, your puppet
+// moves and the room hears you).
+const live = {
+  stream: null,
+  overlayOn: false,
+  async populate() {
+    try {
+      // Ask permission first so labels populate
+      const tmp = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
+      tmp.getTracks().forEach(t => t.stop());
+    } catch (e) {
+      const cs = $('#live-cam'); cs.innerHTML = '<option>Permission denied — grant camera + mic in browser settings</option>';
+      return;
+    }
+    const devs = await navigator.mediaDevices.enumerateDevices();
+    const fill = (sel, kind) => {
+      sel.innerHTML = devs.filter(d => d.kind === kind)
+        .map(d => `<option value="${d.deviceId}">${d.label || d.kind}</option>`).join('');
+    };
+    fill($('#live-cam'), 'videoinput');
+    fill($('#live-mic'), 'audioinput');
+  },
+  async start() {
+    if (live.stream) live.stop();
+    const camId = $('#live-cam').value;
+    const micId = $('#live-mic').value;
+    try {
+      live.stream = await navigator.mediaDevices.getUserMedia({
+        video: { deviceId: camId ? { exact: camId } : undefined, width: 1280, height: 720 },
+        audio: { deviceId: micId ? { exact: micId } : undefined, echoCancellation: true, noiseSuppression: true },
+      });
+      $('#live-video').srcObject = live.stream;
+      $('#pip-video').srcObject = live.stream;
+      $('#live-start').disabled = true; $('#live-stop').disabled = false;
+    } catch (e) {
+      alert('Could not start camera — check permissions. ' + (e.message || e));
+    }
+  },
+  stop() {
+    live.stream?.getTracks().forEach(t => t.stop());
+    live.stream = null;
+    $('#live-video').srcObject = null;
+    $('#pip-video').srcObject = null;
+    $('#duel-pip').hidden = true;
+    $('#live-overlay-toggle').setAttribute('aria-pressed', 'false');
+    live.overlayOn = false;
+    $('#live-start').disabled = false; $('#live-stop').disabled = true;
+  },
+  toggleOverlay() {
+    if (!live.stream) { alert('Start live first.'); return; }
+    live.overlayOn = !live.overlayOn;
+    $('#duel-pip').hidden = !live.overlayOn;
+    $('#live-overlay-toggle').setAttribute('aria-pressed', String(live.overlayOn));
+  },
+  snap() {
+    if (!live.stream) return;
+    const v = $('#live-video');
+    const c = $('#live-overlay');
+    c.width = v.videoWidth; c.height = v.videoHeight;
+    const ctx = c.getContext('2d');
+    ctx.translate(c.width, 0); ctx.scale(-1, 1);  // mirror to match preview
+    ctx.drawImage(v, 0, 0);
+    c.toBlob(blob => {
+      const a = document.createElement('a');
+      a.href = URL.createObjectURL(blob);
+      a.download = `live-snap-${Date.now()}.png`;
+      a.click();
+    }, 'image/png');
+  },
+};
+$('#live-start').addEventListener('click', () => live.start());
+$('#live-stop').addEventListener('click', () => live.stop());
+$('#live-overlay-toggle').addEventListener('click', () => live.toggleOverlay());
+$('#live-snap').addEventListener('click', () => live.snap());
+
 // ---------- Router ---------------------------------------------------------
 async function loadTasteTimeline() {
   const r = await fetch('/api/me/taste/history').then(r => r.json());
@@ -696,6 +774,7 @@ function route(name) {
   if (name === 'avatar') pollAvatar();
   if (name === 'timetravel') loadTimeTravel();
   if (name === 'tryons') loadTryons();
+  if (name === 'live') live.populate();
   if (name === 'taste') loadTaste();
   if (name === 'support') loadSupport();
   if (name === 'debates') loadDebates();

← ae62717 tick 9 follow-up: ship demo video + Browserbase escalation w  ·  back to Whatsmystyle  ·  feat: paid Gemini 2.5 Flash Image try-on + budget HUD ac1cfa6 →