← 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
M data/waitlist.csvM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM public/css/app.cssM public/index.htmlM public/js/app.js
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 →