Talkbot / index.html
deploy
feat: switch avatars in place (no full page reload)
65baea7
Raw
History Blame Contribute Delete
25.3 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Digital Human on EDGE · Avatars</title>
<!-- Re-injects COOP/COEP via a service worker so the page is cross-origin
isolated (required for WebGPU + threaded WASM) on static hosts like
Hugging Face Spaces that can't set those response headers. Must load
first, before any module script. -->
<script src="/coi-serviceworker.js"></script>
<!-- If the visitor has already completed the one-time download (flag set
after first successful load), mark the document so the full-page boot
gate is hidden before first paint (no flash on repeat visits). -->
<script>
try {
if (localStorage.getItem("dh_booted_v1") === "1")
document.documentElement.classList.add("booted");
} catch (e) {}
</script>
<style>
:root {
color-scheme: dark;
--bg: #0b0f17;
--panel: #111726;
--panel-2: #0e1320;
--card: #131a2a;
--border: #232c3d;
--border-soft: #1b2333;
--text: #e6edf3;
--muted: #8b97a8;
--muted-2: #5d6779;
--accent: #2f6bff;
--accent-soft: #1b2c52;
--good: #36d399;
--bad: #f85149;
--radius: 14px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
}
button { font-family: inherit; }
/* ---------- App shell: sidebar + main ---------- */
.layout { display: flex; min-height: 100vh; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
width: 30px; height: 30px;
border-radius: 9px;
background: linear-gradient(135deg, #7c5cff, #2f6bff);
display: grid; place-items: center;
font-weight: 800; font-size: 15px; color: #fff;
}
.brand-name { font-size: 15px; font-weight: 700; letter-spacing: 0.01em; }
.contact-card {
display: flex;
align-items: baseline;
gap: 8px;
}
.contact-label {
font-size: 10px; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--muted-2);
}
.contact-email {
font-size: 13px; font-weight: 600; color: var(--text);
text-decoration: none;
}
.contact-email:hover { color: var(--accent); }
/* ---------- Main content ---------- */
.main { flex: 1; min-width: 0; padding: 26px 30px; }
.page-title { font-size: 26px; font-weight: 800; margin: 0 0 4px; }
.page-sub { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
.integration-banner {
display: flex; align-items: center; gap: 12px;
background: linear-gradient(90deg, var(--accent-soft), var(--panel));
border: 1px solid #294270;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 22px;
}
.integration-banner > svg {
width: 20px; height: 20px; color: var(--accent); flex-shrink: 0;
}
.integration-text { font-size: 13px; color: var(--muted); line-height: 1.5; }
.integration-text strong { color: var(--text); font-weight: 700; margin-right: 4px; }
.howto {
background: var(--panel);
border: 1px solid var(--border-soft);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 22px;
}
.howto-title {
font-size: 12px; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase; color: var(--muted-2); margin: 0 0 10px;
}
.howto-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.howto-step { display: flex; align-items: flex-start; gap: 10px; }
.howto-num {
flex-shrink: 0;
width: 22px; height: 22px; border-radius: 50%;
background: var(--accent-soft); border: 1px solid #294270;
color: #fff; font-size: 12px; font-weight: 700;
display: grid; place-items: center;
}
.howto-step p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.45; }
.howto-step p strong { color: var(--text); font-weight: 600; }
.pill {
display: inline-block;
font-size: 11px; font-weight: 700; color: var(--text);
background: #1b2336; border: 1px solid var(--border-soft);
padding: 1px 7px; border-radius: 6px;
white-space: nowrap;
}
.toolbar {
display: flex; align-items: center; gap: 12px;
margin-bottom: 22px;
}
.search {
position: relative; flex: 1; max-width: 360px;
}
.search svg {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
width: 16px; height: 16px; color: var(--muted-2);
}
.search input {
width: 100%;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 10px;
padding: 11px 12px 11px 36px;
font-size: 14px;
}
.search input::placeholder { color: var(--muted-2); }
.toolbar-spacer { flex: 1; }
.view-toggle {
display: flex; background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden;
}
.view-toggle button {
background: none; border: 0; color: var(--muted);
padding: 8px 10px; cursor: pointer; display: grid; place-items: center;
}
.view-toggle button.active { background: var(--accent); color: #fff; }
.view-toggle button svg { width: 18px; height: 18px; }
.btn-primary {
display: inline-flex; align-items: center; gap: 8px;
background: var(--accent); color: #fff; border: 0;
border-radius: 10px; padding: 11px 16px;
font-size: 14px; font-weight: 700; cursor: pointer;
}
.btn-primary svg { width: 16px; height: 16px; }
/* ---------- Avatar gallery ---------- */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px;
}
.gallery.list { grid-template-columns: 1fr; }
.gallery.list .card { flex-direction: row; align-items: center; }
.gallery.list .card-thumb { width: 120px; height: 80px; flex-shrink: 0; aspect-ratio: auto; }
.gallery.list .card-body { flex: 1; }
.card {
background: var(--card);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
display: flex; flex-direction: column;
transition: border-color 0.15s, transform 0.15s;
}
.card:hover { border-color: #2f3d57; transform: translateY(-2px); }
.card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.card-thumb {
position: relative;
width: 100%; aspect-ratio: 4 / 3;
background: #0a0e16;
overflow: hidden;
}
.card-thumb img {
width: 100%; height: 100%; object-fit: cover; object-position: center top;
display: block;
}
.card-thumb .thumb-skeleton {
position: absolute; inset: 0;
background: linear-gradient(100deg, #131a2a 30%, #1b2336 50%, #131a2a 70%);
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } }
.card-check {
position: absolute; top: 10px; right: 10px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--accent); color: #fff;
display: none; place-items: center;
}
.card.selected .card-check { display: grid; }
.card-check svg { width: 13px; height: 13px; }
.card-body {
padding: 12px 14px;
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
}
.card-info { min-width: 0; }
.card-name { font-size: 15px; font-weight: 700; margin-bottom: 5px; }
.status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.status .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
.card-menu {
background: none; border: 0; color: var(--muted-2); cursor: pointer;
padding: 4px; border-radius: 6px; line-height: 0;
}
.card-menu:hover { color: var(--text); background: #1b2336; }
.card-menu svg { width: 18px; height: 18px; }
.empty {
grid-column: 1 / -1;
text-align: center; color: var(--muted-2);
padding: 40px; font-size: 14px;
}
.gallery-loading {
grid-column: 1 / -1;
display: flex; flex-direction: column; align-items: center; gap: 14px;
padding: 48px 20px; color: var(--muted);
}
.gallery-loading .gl-text { font-size: 13px; font-weight: 600; }
.gallery-loading .gl-bar {
width: min(320px, 70%); height: 6px;
background: #131a2a; border: 1px solid var(--border-soft);
border-radius: 999px; overflow: hidden; position: relative;
}
.gallery-loading .gl-bar::after {
content: ""; position: absolute; top: 0; left: 0; height: 100%;
width: 40%; border-radius: 999px; background: var(--accent);
animation: gl-indeterminate 1.1s ease-in-out infinite;
}
@keyframes gl-indeterminate {
0% { left: -40%; }
100% { left: 100%; }
}
.gallery-foot { text-align: center; color: var(--muted-2); font-size: 12px; margin-top: 22px; }
/* ---------- Detail panel ---------- */
.detail {
position: fixed; top: 0; right: 0; bottom: 0;
width: 380px;
background: var(--panel);
border-left: 1px solid var(--border);
overflow: hidden;
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 20;
display: flex; flex-direction: column;
}
.detail.open { transform: translateX(0); }
/* Landscape avatars (wide frame): widen the drawer and show the whole
horizontal video instead of a cropped center slice. */
.detail.landscape { width: min(760px, 64vw); }
.detail.landscape canvas#view { object-fit: contain; object-position: center; }
/* Controls bar pinned to the top of the panel. */
.detail-bar {
flex-shrink: 0;
padding: 16px;
border-bottom: 1px solid var(--border-soft);
background: var(--panel);
display: flex; flex-direction: column; gap: 12px;
}
.detail-bar-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.detail-close {
width: 30px; height: 30px; border-radius: 8px;
background: #1b2336; border: 1px solid var(--border-soft);
color: var(--muted); cursor: pointer; display: grid; place-items: center;
flex-shrink: 0;
}
.detail-close:hover { color: var(--text); }
.detail-close svg { width: 15px; height: 15px; }
.detail-actions { display: flex; flex-wrap: nowrap; gap: 8px; }
.detail-actions > * { flex: 1 1 0; min-width: 0; }
/* Avatar video fills the remaining area edge-to-edge (no letterbox). */
.viewport {
position: relative;
flex: 1; min-height: 0;
width: 100%;
background: #000; overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
canvas#view {
width: 100%; height: 100%; object-fit: cover; object-position: center top;
display: block; background: #000;
}
.fps-overlay {
position: absolute; top: 8px; right: 10px;
padding: 3px 8px; border-radius: 6px;
background: rgba(0, 0, 0, 0.55); color: #fff;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
pointer-events: none; opacity: 0; transition: opacity 0.2s;
}
.fps-overlay.show { opacity: 1; }
.loading {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; padding: 20px;
background: rgba(11, 15, 23, 0.85); backdrop-filter: blur(2px);
text-align: center; transition: opacity 0.25s ease; z-index: 5;
}
.loading[hidden] { display: none; }
.loading.fade { opacity: 0; pointer-events: none; }
.spinner {
width: 34px; height: 34px; border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.18); border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-status { font-size: 13px; font-weight: 600; }
.loading-sub { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; min-height: 13px; }
.loading.err .spinner { animation: none; border-top-color: var(--bad); border-color: var(--bad); }
/* ---------- First-load boot gate (full-page blur + download dialog) ---------- */
.boot-gate {
position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: center; justify-content: center;
padding: 20px;
background: rgba(8, 11, 18, 0.72);
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
transition: opacity 0.35s ease;
}
.boot-gate.hide { opacity: 0; pointer-events: none; }
.boot-gate[hidden] { display: none; }
/* Repeat visits (assets already cached) skip the full-page gate. */
html.booted .boot-gate { display: none; }
.boot-card {
width: min(420px, 92vw);
background: var(--panel);
border: 1px solid var(--border-soft);
border-radius: 16px;
padding: 30px 28px;
text-align: center;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.boot-logo {
width: 46px; height: 46px; margin: 0 auto 16px;
border-radius: 12px;
background: linear-gradient(135deg, #7c5cff, #2f6bff);
display: grid; place-items: center;
font-weight: 800; font-size: 20px; color: #fff;
}
.boot-title { font-size: 18px; font-weight: 800; margin: 0 0 8px; }
.boot-desc { font-size: 12.5px; color: var(--muted); line-height: 1.5; margin: 0 0 18px; }
.boot-status { font-size: 13px; font-weight: 600; color: var(--text); margin: 0 0 10px; min-height: 16px; }
.boot-bar {
width: 100%; height: 8px;
background: #131a2a; border: 1px solid var(--border-soft);
border-radius: 999px; overflow: hidden;
}
.boot-bar-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, #7c5cff, #2f6bff);
border-radius: 999px; transition: width 0.25s ease;
}
.boot-pct {
font-size: 12px; color: var(--muted-2); margin: 10px 0 0;
font-variant-numeric: tabular-nums; min-height: 14px;
}
.boot-gate.err .boot-bar-fill { background: var(--bad); }
.boot-gate.err .boot-title { color: var(--bad); }
.detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.detail-name { font-size: 19px; font-weight: 800; margin: 0 0 6px; }
.btn-edit {
display: inline-flex; align-items: center; gap: 6px;
background: #1b2336; border: 1px solid var(--border-soft); color: var(--text);
border-radius: 9px; padding: 7px 12px; font-size: 13px; font-weight: 600; cursor: pointer;
}
.btn-edit svg { width: 14px; height: 14px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field-label {
font-size: 11px; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--muted-2);
}
.controls { display: flex; gap: 10px; }
.controls button { flex: 1 1 0; }
button.action {
background: var(--accent); color: #fff; border: 0; border-radius: 9px;
padding: 9px 10px; font-size: 13px; font-weight: 700; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
button.action.secondary { background: #1b2336; border: 1px solid var(--border-soft); }
button.action:disabled { opacity: 0.5; cursor: not-allowed; }
button.action svg { width: 15px; height: 15px; flex-shrink: 0; }
.filebtn {
background: #1b2336; border: 1px solid var(--border-soft); color: var(--text);
border-radius: 9px; padding: 9px 10px; font-size: 13px; font-weight: 700;
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.filebtn svg { width: 15px; height: 15px; flex-shrink: 0; }
.progress {
width: 100%; height: 6px; background: #0a0e16;
border: 1px solid var(--border-soft); border-radius: 4px; overflow: hidden;
}
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width 0.1s linear; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding-top: 6px; border-top: 1px solid var(--border-soft); }
.meta-label { font-size: 11px; color: var(--muted-2); margin-bottom: 4px; }
.meta-value { font-size: 13px; font-weight: 600; }
@media (max-width: 900px) {
.detail { width: 100%; }
}
</style>
<script type="module" crossorigin src="/assets/index-CdVGhIO2.js"></script>
</head>
<body>
<div class="layout">
<!-- Main -->
<main class="main">
<header class="topbar">
<div class="brand">
<div class="brand-logo">DH</div>
<div class="brand-name">Digital Human on EDGE</div>
</div>
<div class="contact-card">
<div class="contact-label">Contact</div>
<a class="contact-email" href="mailto:durgesh@infolabsglobal.ai">durgesh@infolabsglobal.ai</a>
</div>
</header>
<h1 class="page-title">Avatars</h1>
<p class="page-sub">Manage and preview your digital human avatars</p>
<div class="integration-banner">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
<div class="integration-text">
<strong>Runs on the edge.</strong>
Inference happens entirely in the browser — <span class="pill">no server deployment</span>
and <span class="pill">zero recurring cost</span>. Avatars plug into
<span class="pill">LiveKit</span> and <span class="pill">Pipecat</span> with support
for both <span class="pill">streaming</span> and <span class="pill">batch</span> inference.
</div>
</div>
<div class="howto">
<h3 class="howto-title">How to use this demo</h3>
<div class="howto-steps">
<div class="howto-step">
<div class="howto-num">1</div>
<p><strong>Pick an avatar</strong> from the gallery below to open its live preview.</p>
</div>
<div class="howto-step">
<div class="howto-num">2</div>
<p>Wait for the one-time setup to finish loading the avatar.</p>
</div>
<div class="howto-step">
<div class="howto-num">3</div>
<p>Click <strong>Audio</strong> and choose any speech file to drive the lip-sync.</p>
</div>
<div class="howto-step">
<div class="howto-num">4</div>
<p>Hit <strong>Play</strong> to watch it speak — or <strong>Download</strong> the result.</p>
</div>
</div>
</div>
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="avatarSearch" type="text" placeholder="Search avatars..." />
</div>
<div class="toolbar-spacer"></div>
<div class="view-toggle" id="viewToggle">
<button data-view="grid" class="active" title="Grid view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</button>
<button data-view="list" title="List view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
</div>
</div>
<div id="gallery" class="gallery"></div>
<div id="galleryFoot" class="gallery-foot"></div>
</main>
<!-- Detail panel (hosts the live lip-sync engine) -->
<aside id="detail" class="detail">
<audio id="player" preload="auto" hidden></audio>
<!-- Controls pinned to the top -->
<div class="detail-bar">
<div class="detail-bar-top">
<div>
<h2 class="detail-name" id="detailName"></h2>
<div class="status"><span class="dot"></span> Active</div>
</div>
<button class="detail-close" id="detailClose" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="detail-actions">
<label id="upload" class="filebtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Audio
<input id="audioFile" type="file" accept="audio/*" hidden />
</label>
<button id="play" class="action" disabled>
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 4 20 12 6 20 6 4"/></svg>
Play
</button>
<button id="stop" class="action secondary" disabled>
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>
Stop
</button>
<button id="download" class="action secondary" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
</div>
<div id="progress" class="progress" hidden>
<div id="progressBar" class="bar"></div>
</div>
</div>
<!-- Avatar video fills the remaining area edge-to-edge -->
<div class="viewport">
<canvas id="view" width="512" height="384"></canvas>
<div id="fps" class="fps-overlay"></div>
<div id="loading" class="loading">
<div class="spinner"></div>
<div id="loadingStatus" class="loading-status">Loading…</div>
<div id="loadingSub" class="loading-sub"></div>
<div id="loadingProgress" class="progress" hidden>
<div id="loadingBar" class="bar"></div>
</div>
</div>
</div>
</aside>
</div>
<!-- First-load gate: blurs the whole app and shows download progress for
the avatar/model assets until the engine is ready. -->
<div id="bootGate" class="boot-gate">
<div class="boot-card">
<div class="boot-logo">DH</div>
<h2 class="boot-title">Preparing your digital human</h2>
<p class="boot-desc">Downloading avatar &amp; model files — this runs once and is cached for next time.</p>
<p class="boot-status" id="bootStatus">Starting up…</p>
<div class="boot-bar"><div class="boot-bar-fill" id="bootBar"></div></div>
<p class="boot-pct" id="bootPct"></p>
</div>
</div>
</body>
</html>