(() => { // Lift the SSR boot splash (the body::before/::after overlay in _BLOCKS_CSS) by // tagging . Called on the viewer's first preview-ready / viewer-error, plus a // safety timeout so a slow/blocked CDN never traps the user behind the spinner. window.__kimodoHideSplash = function () { try { document.body.classList.add('kimodo-ready'); } catch (e) {} }; // The Space's own (non-iframe) host, e.g. "name.hf.space". Used to break OUT of the // huggingface.co iframe for OAuth so the session cookie is first-party (iOS/Safari // block third-party cookies in iframes). Empty off-Space (local dev). window.__kimodoSpaceHost = ""; setTimeout(function () { if (window.__kimodoHideSplash) window.__kimodoHideSplash(); }, 15000); let attempts = 0; function boot() { const defaultClothing = {"face":"nerdy_glasses","torso_over":"kimono","legs":"kimono_trousers"}; const DATASET_BASE = ""; // The storyboard composer calls /generate_between + /stitch_path directly on the // kimodo backend. Empty when the Space runs the in-process @GPU model (no HTTP // backend) — Build & Play is then disabled with a note. const KIMODO_BACKEND = ""; const root = document.getElementById('kimodo-drawer-root'); const tabs = document.getElementById('kimodo-left-tabs'); const clothingDrawer = document.getElementById('kimodo-clothing-drawer'); if (!root || !tabs || !clothingDrawer) { if (++attempts < 80) window.setTimeout(boot, 100); return; } if (root.dataset.ready === '1') return; root.dataset.ready = '1'; if (root.parentElement !== document.body) document.body.appendChild(root); // Embedded in the HF Space page? Pin the tab rail to the upper-left (the // centered default lands mid-page when HF grows the iframe to content height). let embedded = false; try { embedded = window.self !== window.top; } catch (e) { embedded = true; } if (embedded) tabs.classList.add('kimodo-embedded'); function inputInside(id) { const el = document.getElementById(id); return el ? el.querySelector('textarea, input') : null; } function previewFrame() { return document.querySelector('#main-preview iframe'); } // --- Generic drawer manager: each left-tab carries data-drawer; only one // drawer is open at a time, and the tab rail slides to the open drawer's edge. const pairs = Array.from(tabs.querySelectorAll('.kimodo-left-tab[data-drawer]')) .map((tab) => ({ tab, drawer: document.getElementById(tab.dataset.drawer) })) .filter((p) => p.drawer); const drawers = pairs.map((p) => p.drawer); // The composer is now a normal left side drawer again (reverted from the bottom bar). const composeBar = document.getElementById('kimodo-compose-drawer'); const sideDrawers = drawers; function openDrawer() { return drawers.find((d) => d.classList.contains('open')) || null; } function openSideDrawer() { return sideDrawers.find((d) => d.classList.contains('open')) || null; } // Tell the viewer how many px the open left drawer covers, so it can shift the // camera to keep the character centered in the still-visible area (0 = none). function sendViewportInset() { const open = openSideDrawer(); const left = open ? Math.round(open.getBoundingClientRect().width) : 0; const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'viewport-inset', left }, '*'); } // --- Drawer resize handle: drag the open drawer's right edge to trade panel width // for 3D-view width. The chosen width is persisted per drawer (localStorage) and the // tab rail + camera inset follow the edge live while dragging. --- const DRAWER_MIN_W = 158; function winW() { return Math.max(window.innerWidth || 0, document.documentElement.clientWidth || 0); } function drawerMaxW() { return Math.max(DRAWER_MIN_W, Math.round(winW() * 0.85)); } function loadDrawerWidths() { try { return JSON.parse(localStorage.getItem('kimodo-drawer-widths') || '{}') || {}; } catch (e) { return {}; } } function saveDrawerWidth(id, px) { try { const m = loadDrawerWidths(); m[id] = Math.round(px); localStorage.setItem('kimodo-drawer-widths', JSON.stringify(m)); } catch (e) {} } let resizerEl = null, resizing = null, resizeRaf = false; function ensureResizer() { if (resizerEl) return resizerEl; resizerEl = document.createElement('div'); resizerEl.id = 'kimodo-drawer-resizer'; resizerEl.title = 'Drag to resize the panel'; resizerEl.innerHTML = ''; document.body.appendChild(resizerEl); resizerEl.addEventListener('pointerdown', startResize); return resizerEl; } function positionResizer() { const r = ensureResizer(); const open = openSideDrawer(); if (!open) { r.style.display = 'none'; return; } const w = Math.round(open.getBoundingClientRect().width); r.style.left = (w - 6) + 'px'; // straddle the drawer's right edge r.style.display = 'block'; } function startResize(e) { const open = openSideDrawer(); if (!open) return; resizing = open; e.preventDefault(); e.stopPropagation(); try { resizerEl.setPointerCapture(e.pointerId); } catch (er) {} document.body.style.userSelect = 'none'; resizerEl.classList.add('dragging'); } function onResizeMove(e) { if (!resizing) return; const px = Math.max(DRAWER_MIN_W, Math.min(Math.round(e.clientX), drawerMaxW())); // !important to beat the narrow-screen 50vw-important media-query rule. resizing.style.setProperty('width', px + 'px', 'important'); positionResizer(); tabs.style.left = px + 'px'; // keep the tab rail glued to the edge while dragging if (!resizeRaf) { resizeRaf = true; requestAnimationFrame(() => { resizeRaf = false; sendViewportInset(); }); } } function endResize() { if (!resizing) return; saveDrawerWidth(resizing.id, Math.round(resizing.getBoundingClientRect().width)); resizing = null; document.body.style.userSelect = ''; if (resizerEl) resizerEl.classList.remove('dragging'); layoutTabs(); sendViewportInset(); } window.addEventListener('pointermove', onResizeMove); window.addEventListener('pointerup', endResize); window.addEventListener('pointercancel', endResize); // Size each side drawer from the ACTUAL measured window width (reliable inside the // HF embed iframe, where vw can resolve to the layout viewport, not the visible // width). Half the width on narrow screens; full fixed width on desktop. Inline // style overrides the stylesheet (and any stale cached CSS). function layoutDrawerWidth() { const w = Math.max(window.innerWidth || 0, document.documentElement.clientWidth || 0); const widths = loadDrawerWidths(); for (const d of sideDrawers) { let px; if (d.id === 'kimodo-compose-drawer') { // "My Karate" drawer: a comfortable width that keeps the stance cards readable // on mobile (~200px) while still leaving most of the screen for the 3D view; a // fixed width on desktop. The user can still drag it thinner/wider (persisted, // down to DRAWER_MIN_W). px = (w && w < 760) ? Math.min(204, Math.round(w * 0.62)) : 230; } else { const full = d.classList.contains('kimodo-drawer--wide') ? 460 : 330; px = (w && w < 760) ? Math.round(w * 0.5) : full; } // A width the user dragged-to wins over the responsive default (clamped so it // still fits if the window since shrank). const saved = widths[d.id]; if (saved) px = Math.max(DRAWER_MIN_W, Math.min(Math.round(saved), drawerMaxW())); // !important so this measured/dragged width beats the narrow-screen // `@media (max-width:700px)` 50vw-important rule (else the panel can't shrink). d.style.setProperty('width', px + 'px', 'important'); } } function layoutTabs() { layoutDrawerWidth(); const open = openSideDrawer(); // bottom bar doesn't push the tab rail tabs.classList.toggle('drawer-open', !!open); tabs.style.left = open ? Math.round(open.getBoundingClientRect().width) + 'px' : '0'; positionResizer(); // park the resize handle at the (new) drawer edge } function setOpen(drawer, open) { if (open) for (const d of drawers) if (d !== drawer) d.classList.remove('open'); drawer.classList.toggle('open', !!open); for (const p of pairs) p.tab.classList.toggle('active', p.drawer.classList.contains('open')); layoutTabs(); // Refresh the action->clip map each time the library opens so new takes show. if (open && drawer.id === 'kimodo-actions-drawer') refreshClips().then(renderActions); // Build/refresh the kata tree when its drawer opens (svg needs a layout size). if (open && drawer.id === 'kimodo-kata-drawer') refreshKataTree(); // Load the stance library when its drawer opens. if (open && drawer.id === 'kimodo-stances-drawer') refreshStances(); // Build the storyboard composer (palette + strip) when its drawer opens. if (open && drawer.id === 'kimodo-compose-drawer') composeOpen(); // Pluggable tabs (tabs/*.tab.js) hook their open behavior off this event, // so a new tab never has to edit setOpen. detail = the opened drawer's id. if (open) window.dispatchEvent(new CustomEvent('kimodo-drawer-open', { detail: drawer.id })); // Shift the viewer camera to keep the character centered beside the open drawer. sendViewportInset(); } for (const p of pairs) { p.tab.addEventListener('click', () => setOpen(p.drawer, !p.drawer.classList.contains('open'))); } document.addEventListener('pointerdown', (event) => { const open = openDrawer(); if (!open) return; if (open.contains(event.target) || tabs.contains(event.target)) return; if (resizerEl && resizerEl.contains(event.target)) return; // dragging the resize handle, not an outside click const viewer = document.getElementById('main-preview'); if (viewer && viewer.contains(event.target)) return; setOpen(open, false); }); window.addEventListener('resize', () => { layoutTabs(); sendViewportInset(); }); window.addEventListener('orientationchange', () => { layoutTabs(); sendViewportInset(); }); layoutDrawerWidth(); // set the responsive drawer width once at startup // --- Clothing drawer behavior (one garment per slot -> state textbox + iframe). --- const state = { ...defaultClothing }; function writeAnonymousClientId() { const input = inputInside('anonymous-client-id'); if (!input) return; let id = ''; try { id = window.localStorage.getItem('kimodoAnonymousClientId') || ''; if (!id) { const bytes = new Uint8Array(16); window.crypto.getRandomValues(bytes); id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); window.localStorage.setItem('kimodoAnonymousClientId', id); } } catch (err) { id = 'session-' + Math.random().toString(36).slice(2) + Date.now().toString(36); } input.value = id; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } function writeState() { const input = inputInside('clothing-state'); if (!input) return; input.value = JSON.stringify(state); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } function sendClothing(slot, value) { const frame = previewFrame(); if (frame && frame.contentWindow) { frame.contentWindow.postMessage({ kind: 'clothing', slot, value: value || '' }, '*'); } } function syncButtons() { clothingDrawer.querySelectorAll('.kimodo-drawer-option').forEach((button) => { const slot = button.dataset.slot; const id = button.dataset.id || ''; const on = (state[slot] || '') === id; button.classList.toggle('on', on); button.textContent = (on && id ? '✓ ' : '') + button.textContent.replace(/^✓\s+/, ''); }); } clothingDrawer.addEventListener('click', (event) => { const button = event.target.closest('.kimodo-drawer-option'); if (!button) return; const slot = button.dataset.slot; const id = button.dataset.id || ''; if (!slot) return; if (id) state[slot] = id; else delete state[slot]; writeState(); syncButtons(); sendClothing(slot, id); }); // --- Actions library: curated move templates (localStorage) -> clips. --- // Actions = the signed-in user's generated clips (anonymous: this browser's // clips, tracked in localStorage). One card per clip, mirroring the kata web // UI's .action-card. Templates/seeds are gone — these are real animations. const actionsList = document.getElementById('kimodo-actions-list'); const KATA_ROOTS_KEY = 'kimodo-kata-roots-v1'; const MY_CLIPS_KEY = 'kimodo-my-clips-v1'; function lsGet(k, d) { try { const v = JSON.parse(localStorage.getItem(k)); return v == null ? d : v; } catch (e) { return d; } } function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} } let kataRoots = new Set(lsGet(KATA_ROOTS_KEY, [])); function saveKataRoots() { lsSet(KATA_ROOTS_KEY, [...kataRoots]); } let myClips = new Set(lsGet(MY_CLIPS_KEY, [])); function saveMyClips() { lsSet(MY_CLIPS_KEY, [...myClips]); } function currentUser() { const el = inputInside('current-user'); return el ? (el.value || '').trim() : ''; } function deriveName(p) { const s = (p || '').replace(/^(a martial artist|a person|the practitioner|the martial artist)\s+/i, '').trim(); const w = s.split(/\s+/).slice(0, 5).join(' '); return (w.charAt(0).toUpperCase() + w.slice(1)).slice(0, 32) || 'Move'; } let actions = []; // [{ id, prompt, seconds, created_by, name }] let editingId = null; // action id whose prompt is being edited let busyId = null; // action id currently regenerating let previewingId = null; // clip id shown in the viewer let pendingGen = false; // a generation was triggered from this browser let adding = false; // the add form is generating let addSeconds = 2.2; // add form's chosen duration let durOpen = new Set(); // action ids whose options section is expanded let scrubbing = false; // a card scrubber is being dragged let aPlayBtn = null, aScrub = null, aLabel = null; // previewing card's live transport refs async function fetchManifest() { if (!DATASET_BASE) return []; try { const r = await fetch(DATASET_BASE + '/manifest.json', { cache: 'no-store' }); if (!r.ok) return []; const recs = ((await r.json()) || {}).records || {}; const items = Object.entries(recs).map(([id, m]) => Object.assign({ id }, m)); items.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); return items; } catch (err) { console.error('manifest fetch failed', err); return []; } } async function refreshClips() { const items = await fetchManifest(); const user = currentUser(); actions = items .filter((m) => { if (m.continues_from && m.continues_from.source_id) return false; // standalone moves only if (user) return m.created_by === user; // signed in: my attributed clips return !m.created_by && myClips.has(m.id); // anon: this browser's clips }) .map((m) => ({ id: m.id, prompt: m.prompt || '', seconds: Number(m.seconds) || 2.2, created_by: m.created_by || '', name: deriveName(m.prompt) })); } function scrubFill(el) { const max = Number(el.max) || 1, pct = (Number(el.value) / max) * 100; el.style.background = 'linear-gradient(90deg, #5ad1ff ' + pct + '%, #34343c ' + pct + '%)'; } function sendTransport(action, frameNum) { const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'transport', action, frame: frameNum }, '*'); } function previewClip(id) { if (!id) return; previewingId = id; const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'load-animation', id }, '*'); renderActions(); } // Drive the hidden logged-in generate path (quota + attribution intact) with a // fresh random seed. Completion arrives via onPreviewReady(). function fireGenerate(prompt, seconds) { const pin = document.querySelector('#regen-prompt textarea, #regen-prompt input'); const sin = document.querySelector('#regen-seconds textarea, #regen-seconds input'); const btn = document.querySelector('#regen-btn'); if (!pin || !sin || !btn) return false; pin.value = prompt; pin.dispatchEvent(new Event('input', { bubbles: true })); sin.value = String(seconds); sin.dispatchEvent(new Event('input', { bubbles: true })); pendingGen = true; setTimeout(() => btn.click(), 40); return true; } function regenAction(act, promptOverride) { if (busyId || adding) return; busyId = act.id; renderActions(); if (!fireGenerate(promptOverride || act.prompt, Number(act.seconds) || 2.2)) { busyId = null; renderActions(); return; } setTimeout(() => { if (busyId === act.id) { busyId = null; renderActions(); } }, 240000); // safety } function addAction(promptText, seconds) { const p = (promptText || '').trim(); if (!p || adding || busyId) return; adding = true; renderActions(); if (!fireGenerate(p, seconds || addSeconds)) { adding = false; renderActions(); return; } setTimeout(() => { if (adding) { adding = false; renderActions(); } }, 240000); // safety } // The clip currently in the viewer + the frame it's paused/at, for branching. function previewSource() { if (!viewerState || !viewerState.id) return null; return { id: viewerState.id, frame: Math.round(viewerState.frame || 0) }; } // Kata authoring: branch a NEW move that starts from the frame currently shown // in the viewer (any previewed clip), via the constrained generate_continue path. function fireContinue(prompt, seconds, sourceId, frame) { const pin = document.querySelector('#cont-prompt textarea, #cont-prompt input'); const sin = document.querySelector('#cont-seconds textarea, #cont-seconds input'); const src = document.querySelector('#cont-source-id textarea, #cont-source-id input'); const frm = document.querySelector('#cont-source-frame textarea, #cont-source-frame input'); const btn = document.querySelector('#cont-btn'); if (!pin || !sin || !src || !frm || !btn) return false; pin.value = prompt; pin.dispatchEvent(new Event('input', { bubbles: true })); sin.value = String(seconds); sin.dispatchEvent(new Event('input', { bubbles: true })); src.value = sourceId; src.dispatchEvent(new Event('input', { bubbles: true })); frm.value = String(frame); frm.dispatchEvent(new Event('input', { bubbles: true })); pendingGen = true; setTimeout(() => btn.click(), 40); return true; } function continueAction(act) { const s = previewSource(); if (!s || busyId || adding) return; busyId = act.id; renderActions(); if (!fireContinue(act.prompt, Number(act.seconds) || 2.2, s.id, s.frame)) { busyId = null; renderActions(); return; } setTimeout(() => { if (busyId === act.id) { busyId = null; renderActions(); } }, 240000); } function addContinue(promptText, seconds) { const p = (promptText || '').trim(); const s = previewSource(); if (!p || !s || adding || busyId) return; adding = true; renderActions(); if (!fireContinue(p, seconds || addSeconds, s.id, s.frame)) { adding = false; renderActions(); return; } setTimeout(() => { if (adding) { adding = false; renderActions(); } }, 240000); } function deleteAction(act) { const did = document.querySelector('#del-id textarea, #del-id input'); const dbtn = document.querySelector('#del-btn'); if (did && dbtn) { did.value = act.id; did.dispatchEvent(new Event('input', { bubbles: true })); setTimeout(() => dbtn.click(), 30); } actions = actions.filter((a) => a.id !== act.id); // optimistic if (myClips.has(act.id)) { myClips.delete(act.id); saveMyClips(); } if (previewingId === act.id) previewingId = null; renderActions(); setTimeout(() => refreshClips().then(renderActions), 2500); // reconcile with server } function makeKataFromAction(act) { if (kataRoots.has(act.id)) kataRoots.delete(act.id); else kataRoots.add(act.id); saveKataRoots(); renderActions(); // Reflect the new/removed kata root in the tree if that drawer is open. const kd = document.getElementById('kimodo-kata-drawer'); if (kd && kd.classList.contains('open') && typeof refreshKataTree === 'function') refreshKataTree(); } const startEdit = (act) => { editingId = act.id; renderActions(); }; const cancelEdit = () => { editingId = null; renderActions(); }; function commitEdit(act, value) { const v = (value || '').trim(); editingId = null; if (v && v !== act.prompt) { act.prompt = v; act.name = deriveName(v); } // used by the next regenerate renderActions(); } // Build one card, faithfully mirroring kata.js renderDrawer's .action-card. function renderCard(act) { const has = !!act.id; const busy = busyId === act.id; const editing = editingId === act.id; const playing = previewingId === act.id && !busy && !editing; const secs = Number(act.seconds) || 2.2; const card = document.createElement('div'); card.className = 'action-card' + (playing ? ' playing' : ''); const nm = document.createElement('div'); nm.className = 'nm'; nm.innerHTML = act.name + ' ' + secs.toFixed(1) + 's' + (playing ? ' ● previewing' : busy ? ' generating…' : ''); card.appendChild(nm); const durBadge = nm.querySelector('.dur-badge'); if (editing) { const ta = document.createElement('textarea'); ta.className = 'pr-edit'; ta.rows = 3; ta.value = act.prompt; ta.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEdit(act, ta.value); } else if (e.key === 'Escape') cancelEdit(); }; const bar = document.createElement('div'); bar.className = 'edit-bar'; const save = document.createElement('button'); save.textContent = '✓ save'; save.onclick = () => commitEdit(act, ta.value); const cancel = document.createElement('button'); cancel.textContent = 'cancel'; cancel.onclick = cancelEdit; bar.appendChild(save); bar.appendChild(cancel); card.appendChild(ta); card.appendChild(bar); setTimeout(() => { ta.focus(); ta.select(); }, 0); return card; } // delete X — top-right corner const x = document.createElement('button'); x.className = 'del-x'; x.textContent = '✕'; x.title = 'delete animation'; x.disabled = busy; x.onclick = () => deleteAction(act); card.appendChild(x); // prompt + inline edit const pr = document.createElement('div'); pr.className = 'pr'; const txt = document.createElement('span'); txt.className = 'pr-txt'; txt.textContent = act.prompt; const ed = document.createElement('button'); ed.className = 'pr-edit-btn'; ed.textContent = '✎'; ed.title = 'edit prompt'; ed.disabled = busy; ed.onclick = () => startEdit(act); pr.appendChild(txt); pr.appendChild(ed); card.appendChild(pr); // preview, or — while previewing this card — a live media transport if (playing) { const max = Math.max(0, (viewerState.num_frames || 1) - 1); const pp = document.createElement('button'); pp.className = 'pp'; pp.textContent = viewerState.playing ? '❚❚' : '▶'; pp.onclick = () => sendTransport('toggle'); const lbl = document.createElement('span'); lbl.className = 'frame-lbl'; lbl.textContent = Math.round(viewerState.frame || 0) + '/' + max; const sc = document.createElement('input'); sc.type = 'range'; sc.className = 'play-scrub'; sc.min = 0; sc.max = max; sc.step = 1; sc.value = Math.round(viewerState.frame || 0); sc.oninput = () => { scrubbing = true; const f = Number(sc.value); sendTransport('seek', f); pp.textContent = '▶'; lbl.textContent = f + '/' + max; scrubFill(sc); }; sc.onchange = () => { scrubbing = false; }; scrubFill(sc); const prow = document.createElement('div'); prow.className = 'play-row'; prow.appendChild(pp); prow.appendChild(sc); prow.appendChild(lbl); card.appendChild(prow); aPlayBtn = pp; aScrub = sc; aLabel = lbl; } else { const prev = document.createElement('button'); prev.className = 'prev-btn'; prev.disabled = !has || busy; prev.textContent = busy ? 'generating…' : !has ? 'not generated' : '▶ preview'; prev.onclick = () => previewClip(act.id); card.appendChild(prev); } // collapsible options: regenerate(s) + duration + rotation const open = durOpen.has(act.id); const optRow = document.createElement('div'); optRow.className = 'row'; optRow.style.alignItems = 'center'; const tog = document.createElement('button'); tog.className = 'dur-tog'; tog.style.flex = '1'; tog.textContent = (open ? '▾' : '▸') + ' options · ' + secs.toFixed(1) + 's'; tog.onclick = () => { durOpen.has(act.id) ? durOpen.delete(act.id) : durOpen.add(act.id); renderActions(); }; const mk = document.createElement('button'); mk.className = 'kata-plus'; mk.disabled = !has || busy; mk.textContent = (has && kataRoots.has(act.id)) ? '✓ kata' : '+ kata'; mk.title = has ? 'start a new kata with this move as the first step' : 'generate this action first'; mk.onclick = () => makeKataFromAction(act); optRow.appendChild(tog); optRow.appendChild(mk); card.appendChild(optRow); if (open) { const rrow = document.createElement('div'); rrow.className = 'regen-row'; const reg = document.createElement('button'); reg.className = 'rg'; reg.disabled = busy; reg.innerHTML = busy ? '' : '↻ regenerate'; reg.title = 'regenerate from scratch (rest pose)'; reg.onclick = () => regenAction(act); const src = previewSource(); const regf = document.createElement('button'); regf.className = 'rg'; regf.disabled = busy || !src; regf.textContent = '↳ from preview frame'; regf.title = src ? ('start this move from frame ' + src.frame + ' of the previewed clip') : 'preview a clip and pause on a frame first'; regf.onclick = () => continueAction(act); rrow.appendChild(reg); rrow.appendChild(regf); card.appendChild(rrow); const dur = document.createElement('div'); dur.className = 'dur'; const dtag = document.createElement('span'); dtag.className = 'dur-tag'; dtag.textContent = '⏱'; const drange = document.createElement('input'); drange.type = 'range'; drange.min = '0.5'; drange.max = '10'; drange.step = '0.5'; drange.value = String(secs); drange.disabled = busy; const dlbl = document.createElement('span'); dlbl.className = 'dur-lbl'; dlbl.textContent = secs.toFixed(1) + 's'; drange.oninput = () => { act.seconds = Number(drange.value); const t = act.seconds.toFixed(1) + 's'; dlbl.textContent = t; if (durBadge) durBadge.textContent = t; tog.textContent = '▾ options · ' + t; }; dur.appendChild(dtag); dur.appendChild(drange); dur.appendChild(dlbl); card.appendChild(dur); // rotation row — disabled until Phase 4 (rotate_clip) const rotRow = document.createElement('div'); rotRow.className = 'rot-row'; const rtag = document.createElement('span'); rtag.className = 'dur-tag'; rtag.textContent = '⟲'; const rrange = document.createElement('input'); rrange.type = 'range'; rrange.min = '-180'; rrange.max = '180'; rrange.step = '1'; rrange.value = '0'; rrange.disabled = true; rrange.title = 'rotation comes with kata authoring (Phase 4)'; const rlbl = document.createElement('span'); rlbl.className = 'dur-lbl'; rlbl.textContent = '0°'; const rsave = document.createElement('button'); rsave.className = 'rot-save'; rsave.textContent = 'save'; rsave.disabled = true; rotRow.appendChild(rtag); rotRow.appendChild(rrange); rotRow.appendChild(rlbl); rotRow.appendChild(rsave); card.appendChild(rotRow); } return card; } function renderActions() { if (!actionsList) return; actionsList.textContent = ''; // Add form: prompt + (Add & generate / from preview frame) + duration. const form = document.createElement('div'); form.className = 'add-form'; const addTa = document.createElement('textarea'); addTa.className = 'add-input'; addTa.rows = 2; addTa.placeholder = 'describe a new move, e.g. "a martial artist throws an elbow strike"'; addTa.disabled = adding; const submitAdd = () => { const v = addTa.value.trim(); if (v && !adding) addAction(v, addSeconds); }; const submitAddFrame = () => { const v = addTa.value.trim(); if (v && !adding && previewSource()) addContinue(v, addSeconds); }; addTa.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitAdd(); } }; const arow = document.createElement('div'); arow.className = 'add-row'; const add = document.createElement('button'); add.className = 'add-action'; add.disabled = adding; add.innerHTML = adding ? ' generating…' : '+ Add & generate'; add.onclick = submitAdd; const psrc = previewSource(); const addf = document.createElement('button'); addf.className = 'add-action'; addf.disabled = adding || !psrc; addf.textContent = '↳ from preview frame'; addf.title = psrc ? ('add a move that starts from frame ' + psrc.frame + ' of the previewed clip') : 'preview a clip and pause on a frame first'; addf.onclick = submitAddFrame; arow.appendChild(add); arow.appendChild(addf); const adur = document.createElement('div'); adur.className = 'dur'; const atag = document.createElement('span'); atag.className = 'dur-tag'; atag.textContent = '⏱'; const arange = document.createElement('input'); arange.type = 'range'; arange.min = '0.5'; arange.max = '10'; arange.step = '0.5'; arange.value = String(addSeconds); arange.disabled = adding; const albl = document.createElement('span'); albl.className = 'dur-lbl'; albl.textContent = addSeconds.toFixed(1) + 's'; arange.oninput = () => { addSeconds = Number(arange.value); albl.textContent = addSeconds.toFixed(1) + 's'; }; adur.appendChild(atag); adur.appendChild(arange); adur.appendChild(albl); form.appendChild(addTa); form.appendChild(arow); form.appendChild(adur); actionsList.appendChild(form); aPlayBtn = aScrub = aLabel = null; // refreshed if a previewing card renders if (!DATASET_BASE) { const n = document.createElement('div'); n.className = 'actions-empty'; n.textContent = 'Saved clips need the dataset store.'; actionsList.appendChild(n); } else if (!actions.length) { const n = document.createElement('div'); n.className = 'actions-empty'; n.textContent = currentUser() ? 'No animations yet — generate one above.' : 'Sign in to see your animations, or generate one above.'; actionsList.appendChild(n); } for (const act of actions) actionsList.appendChild(renderCard(act)); } // Live playback state from the viewer keeps the previewing card's scrubber synced. function onFrame(st) { viewerState = st || viewerState; if (previewingId && st && st.id === previewingId && aScrub) { const max = Math.max(0, (st.num_frames || 1) - 1); aScrub.max = max; if (!scrubbing) aScrub.value = st.frame; if (aLabel) aLabel.textContent = st.frame + '/' + max; if (aPlayBtn) aPlayBtn.textContent = st.playing ? '❚❚' : '▶'; scrubFill(aScrub); } } let viewerState = { id: null, frame: 0, playing: false, num_frames: 1 }; function onPreviewReady(id) { if (id) { previewingId = id; if (pendingGen) { // a generation from this browser just finished if (!currentUser()) { myClips.add(id); saveMyClips(); } pendingGen = false; } } busyId = null; adding = false; refreshClips().then(renderActions); } // In-place clip switch (viewer's own picker / actions Preview): no manifest // refetch, no clothing touch -- just keep the card highlight + branch source in sync. function onClipChanged(id) { if (id) { previewingId = id; viewerState = { id, frame: 0, playing: false, num_frames: viewerState.num_frames }; renderActions(); } } // Main Generate button also counts as "my" generation (anon tracking). const _gen = document.querySelector('#generate-btn'); if (_gen) _gen.addEventListener('click', () => { pendingGen = true; }); // First map + paint so the drawer is ready before its first open. refreshClips().then(renderActions); // --- Kata move tree: ported from kata.js. Builds a timeline-tree from the // manifest's continues_from edges + the localStorage kata-roots that +kata // sets, renders it with d3, and plays a path by stitching clips client-side. --- const treeEl = document.getElementById('kimodo-tree-svg'); const kataSelectEl = document.getElementById('kimodo-kata-select'); const kataStatusEl = document.getElementById('kimodo-kata-status'); function setKataStatus(t) { if (kataStatusEl) kataStatusEl.textContent = t; } const SCALE_Y = 1.35, COL_GAP = 185, TOP = 40, SPINE_END_SLOP = 4; let mode = 'path'; // 'path' = play whole path (yellow), 'move' = view one (blue) let CTX = null; // { byId, childrenOf, parentOf, roots } let currentRoot = null, selectedId = null, activeId = null; let pathSet = new Set(), playSegs = null, POS = null; let ksvg, kgAll, kgBar, kgLink, kgPlay, kPlayLine, kPlayHead, kZoom; function buildTree(anims) { const byId = new Map(anims.map((a) => [a.id, a])); const childrenOf = new Map(), parentOf = new Map(), inTree = new Set(); for (const a of anims) { const src = a.continues_from && a.continues_from.source_id; if (src && byId.has(src)) { inTree.add(a.id); inTree.add(src); parentOf.set(a.id, src); if (!childrenOf.has(src)) childrenOf.set(src, []); childrenOf.get(src).push(a.id); } } const roots = [...inTree].filter((id) => !parentOf.has(id)); for (const id of kataRoots) if (byId.has(id) && !parentOf.has(id) && !roots.includes(id)) roots.push(id); return { byId, childrenOf, parentOf, roots }; } function pathToRoot(id) { const p = []; let c = id; while (c) { p.unshift(c); c = CTX.parentOf.get(c); } return p; } const numFrames = (id) => Number(CTX.byId.get(id) && CTX.byId.get(id).num_frames) || 60; const branchFrameOf = (id) => { const cf = CTX.byId.get(id) && CTX.byId.get(id).continues_from; const f = cf ? cf.frame : null; return f == null ? 0 : Number(f); }; const childrenSorted = (id) => (CTX.childrenOf.get(id) || []).slice().sort((a, b) => branchFrameOf(a) - branchFrameOf(b)); const spineChild = (id) => { const ch = CTX.childrenOf.get(id) || [], endFrom = numFrames(id) - 1 - SPINE_END_SLOP; const enders = ch.filter((c) => branchFrameOf(c) >= endFrom); return enders.length ? enders.reduce((m, c) => (branchFrameOf(c) > branchFrameOf(m) ? c : m), enders[0]) : null; }; function descendantsOf(id) { const out = new Set(), stack = [id]; while (stack.length) { const x = stack.pop(); if (out.has(x)) continue; out.add(x); for (const c of (CTX.childrenOf.get(x) || [])) stack.push(c); } return out; } const fullLabel = (id) => ((CTX.byId.get(id) && CTX.byId.get(id).prompt) || id).replace(/^(a martial artist|a person|the practitioner|the martial artist)\s+/i, '').trim(); const nodeLabel = (id) => { const t = fullLabel(id); return t.length > 22 ? t.slice(0, 21) + '…' : t; }; function computeLayout() { POS = new Map(); let nextCol = 0; const place = (id, col, yStart) => { POS.set(id, { x: col * COL_GAP, y: yStart, nf: numFrames(id) }); const sp = spineChild(id); for (const c of childrenSorted(id)) { const cy = yStart + branchFrameOf(c) * SCALE_Y; if (c === sp) place(c, col, cy); else { nextCol += 1; place(c, nextCol, cy); } } }; if (currentRoot && CTX.byId.has(currentRoot)) place(currentRoot, 0, TOP); } function setupGraph() { treeEl.innerHTML = ''; ksvg = d3.select(treeEl); kgAll = ksvg.append('g'); kgBar = kgAll.append('g'); kgLink = kgAll.append('g'); kgPlay = kgAll.append('g'); const kgNode = kgAll.append('g'); kgAll._node = kgNode; kPlayLine = kgPlay.append('line').attr('stroke', '#fff3a0').attr('stroke-width', 5).attr('stroke-linecap', 'round').style('display', 'none'); kPlayHead = kgPlay.append('circle').attr('r', 4).attr('fill', '#ffffff').style('display', 'none'); kZoom = d3.zoom().scaleExtent([0.15, 2.5]).on('zoom', (e) => kgAll.attr('transform', e.transform)); ksvg.call(kZoom); } const modeColor = () => (mode === 'path' ? '#ffe14a' : '#4ea8ff'); function nodeColor(id) { if (id === activeId) return modeColor(); if (pathSet.has(id)) return '#e0a24a'; if (id === selectedId) return modeColor(); if ((CTX.childrenOf.get(id) || []).length) return '#5fb98c'; return '#8a8a93'; } const nodeRadius = (id) => (id === activeId ? 9 : id === selectedId ? 7 : 5); const barColor = (id) => (id === activeId ? modeColor() : pathSet.has(id) ? '#e0a24a' : id === selectedId ? modeColor() : '#3f3f48'); function refreshNodeStyles() { if (kgAll && kgAll._node) kgAll._node.selectAll('g.n circle').attr('fill', (d) => nodeColor(d.id)).attr('r', (d) => nodeRadius(d.id)); if (kgBar) kgBar.selectAll('line.bar').attr('stroke', (d) => barColor(d.id)).attr('stroke-width', (d) => (d.id === activeId ? 5 : 3)); if (kgLink) kgLink.selectAll('path.conn').attr('stroke', (d) => (pathSet.has(d.id) ? '#e0a24a' : '#4a4a52')); } function updateGraph() { const kgNode = kgAll._node; const nodes = [...POS.keys()].map((id) => Object.assign({ id, label: nodeLabel(id) }, POS.get(id))); kgBar.selectAll('line.bar').data(nodes, (d) => d.id).join('line').attr('class', 'bar') .attr('x1', (d) => d.x).attr('y1', (d) => d.y).attr('x2', (d) => d.x).attr('y2', (d) => d.y + d.nf * SCALE_Y) .attr('stroke-linecap', 'round').attr('stroke', (d) => barColor(d.id)).attr('stroke-width', (d) => (d.id === activeId ? 5 : 3)); const conns = []; for (const id of POS.keys()) { const p = CTX.parentOf.get(id); if (p && POS.has(p)) { const pp = POS.get(p), cc = POS.get(id); conns.push({ id, x1: pp.x, y: pp.y + branchFrameOf(id) * SCALE_Y, x2: cc.x }); } } kgLink.selectAll('path.conn').data(conns, (d) => d.id).join('path').attr('class', 'conn').attr('fill', 'none') .attr('stroke-width', 1.5).attr('stroke', (d) => (pathSet.has(d.id) ? '#e0a24a' : '#4a4a52')) .attr('d', (d) => 'M' + d.x1 + ',' + d.y + ' L' + d.x2 + ',' + d.y); const node = kgNode.selectAll('g.n').data(nodes, (d) => d.id); const nEnter = node.enter().append('g').attr('class', 'n').style('cursor', 'pointer').on('click', (e, d) => onNodeClick(d.id)); nEnter.append('circle'); nEnter.append('title'); nEnter.append('text').attr('dy', '0.32em').attr('x', 9).attr('font-size', '12px').attr('fill', '#dcdce0') .attr('paint-order', 'stroke').attr('stroke', '#1c1c1f').attr('stroke-width', 3); const nAll = nEnter.merge(node); nAll.attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')'); nAll.select('circle').attr('fill', (d) => nodeColor(d.id)).attr('r', (d) => nodeRadius(d.id)); nAll.select('text').text((d) => d.label); nAll.select('title').text((d) => fullLabel(d.id)); node.exit().remove(); } function buildGraph() { setupGraph(); computeLayout(); updateGraph(); const w = treeEl.clientWidth || 460; ksvg.call(kZoom.transform, d3.zoomIdentity.translate(w / 2, 24).scale(0.7)); } function onNodeClick(id) { if (id === selectedId) mode = (mode === 'path' ? 'move' : 'path'); else mode = 'path'; select(id); } // --- client-side stitch (port of run_motion_api.py stitch_path) --- function poseHeading(P0) { const d2 = P0[2][2] - P0[1][2], d0 = P0[2][0] - P0[1][0]; return Math.atan2(d2, -d0); } async function fetchRecord(id) { // A clip can live in the dataset (community / forked katas, the Space's in-process // builds) OR on the remote backend (locally-generated composer moves). Try the // dataset first (cacheable, the common case), then fall back to the remote — so a // MIXED spine (a forked community kata with a freshly-added LOCAL move) resolves // every clip and stitches client-side instead of failing to play. try { const r = await fetch(DATASET_BASE + '/animations/' + encodeURIComponent(id) + '.json', { cache: 'force-cache' }); if (r.ok) return await r.json(); } catch (e) {} if (typeof KIMODO_BACKEND !== 'undefined' && KIMODO_BACKEND) { const rr = await fetch(KIMODO_BACKEND + '/animations/' + encodeURIComponent(id)); if (rr.ok) return await rr.json(); } throw new Error('record not found: ' + id); } async function stitchPath(ids) { const recs = []; for (const id of ids) recs.push(await fetchRecord(id)); const outG = [], outR = [], outP = []; const targetFrames = []; // stitched-output frame index of each move's end (its target pose) let alpha = 0, Tx = 0, Tz = 0; const n = recs.length; for (let idx = 0; idx < n; idx++) { const r = recs[idx]; let G = (r.global_quats_xyzw || []).map((fr) => fr.map((q) => q.slice())); let R = (r.root_positions || []).map((p) => p.slice()); let P = (r.posed_joints || []).map((fr) => fr.map((j) => j.slice())); if (idx > 0) { const beta = alpha - poseHeading(P[0]) + (Number(r.heading_offset) || 0) * Math.PI / 180; const c = Math.cos(beta), s = Math.sin(beta), px = R[0][0], pz = R[0][2]; for (const p of R) { const x0 = p[0] - px, z0 = p[2] - pz; p[0] = x0 * c + z0 * s + Tx; p[2] = -x0 * s + z0 * c + Tz; } for (const fr of P) for (const j of fr) { const x0 = j[0] - px, z0 = j[2] - pz; j[0] = x0 * c + z0 * s + Tx; j[2] = -x0 * s + z0 * c + Tz; } const qy = Math.sin(beta / 2), qw = Math.cos(beta / 2); for (const fr of G) for (const q of fr) { const gx = q[0], gy = q[1], gz = q[2], gw = q[3]; q[0] = qw * gx + qy * gz; q[1] = qw * gy + qy * gw; q[2] = qw * gz - qy * gx; q[3] = qw * gw - qy * gy; } } const lo = idx === 0 ? 0 : 1; let hi = R.length; if (idx < n - 1) { const cf = recs[idx + 1].continues_from; let bf = cf ? cf.frame : null; if (bf != null) { bf = bf < 0 ? R.length + bf : bf; if (bf >= 0 && bf < R.length) hi = bf + 1; } } const startIdx = outP.length; for (let f = lo; f < hi; f++) { outG.push(G[f]); outR.push(R[f]); outP.push(P[f]); } // The "target" pose of a move is its most EXPRESSIVE frame, NOT the last frame // (usually a neutral landing/recovery). Drama = farthest joint from the pelvis // (reach / cartwheel inversion / punch extension) + a bonus when a foot is // ABOVE the pelvis (high kicks, somersaults, aerials). let bestIdx = outP.length - 1, bestDrama = -1; for (let si = startIdx; si < outP.length; si++) { const fr = outP[si], px = fr[0][0], py = fr[0][1], pz = fr[0][2]; let maxd = 0; for (let j = 1; j < fr.length; j++) { const dx = fr[j][0]-px, dy = fr[j][1]-py, dz = fr[j][2]-pz, d = Math.sqrt(dx*dx+dy*dy+dz*dz); if (d > maxd) maxd = d; } const footAbove = Math.max(fr[10][1], fr[11][1]) - py; // joints 10/11 = feet const drama = maxd + Math.max(0, footAbove); if (drama > bestDrama) { bestDrama = drama; bestIdx = si; } } targetFrames.push(bestIdx); const k = hi - 1; alpha = poseHeading(P[k]); Tx = R[k][0]; Tz = R[k][2]; } const s0 = recs[0]; return { num_frames: outP.length, fps: Number(s0.fps) || 30, num_joints: outP.length ? outP[0].length : 22, bone_names: s0.bone_names || [], parents: s0.parents || [], posed_joints: outP, global_quats_xyzw: outG, root_positions: outR, target_frames: targetFrames, // red target skeletons drawn at each (only for multi-move katas) // Attribution: a kata is credited to whoever authored its opening move. created_by: s0.created_by || '', created_by_name: s0.created_by_name || '', }; } function f32b64(nested) { const flat = []; (function rec(a) { if (typeof a === 'number') flat.push(a); else for (const x of a) rec(x); })(nested); const bytes = new Uint8Array(new Float32Array(flat).buffer); let bin = ''; for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(bin); } function viewerPayload(m, id, prompt, opts) { // m may be a RAW stitched record (posed_joints/global_quats_xyzw/root_positions) OR an // already-encoded preview payload (joint_data_b64/...). When it's already b64 (the // server-stitched in-process build), pass it through verbatim instead of re-encoding // undefined raw arrays — so the live iframe can be reused without a reload. const b64 = !!m.joint_data_b64; const nf = m.num_frames || m.preview_frames; return { id: id || m.id || '', prompt: prompt || m.prompt || '', keepCamera: !!(opts && opts.keepCamera), num_joints: m.num_joints, num_frames: nf, preview_frames: m.preview_frames || nf, preview_fps: m.preview_fps || m.fps, fps: m.fps || m.preview_fps, bone_names: m.bone_names, parents: m.parents, joint_data_b64: b64 ? m.joint_data_b64 : f32b64(m.posed_joints), quat_data_b64: b64 ? (m.quat_data_b64 || null) : f32b64(m.global_quats_xyzw), root_data_b64: b64 ? (m.root_data_b64 || null) : f32b64(m.root_positions), // Red target skeletons at each move's seam (only for stitched katas, >1 move). target_frames: (m.target_frames && m.target_frames.length > 1) ? m.target_frames : null, created_by: m.created_by || '', created_by_name: m.created_by_name || '', }; } function loadIntoViewer(m, id, prompt, opts) { const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'load-payload', payload: viewerPayload(m, id, prompt, opts) }, '*'); } // Tell the viewer to re-center on the next motion load (used when (re)opening a kata so // a fresh editing session frames the character once) AND turn OFF the follow-camera so // playback doesn't lock onto / track the character (only the ⌖ button enables that). function reframeViewer() { composeCamLock = false; const f = previewFrame(); if (!f || !f.contentWindow) return; f.contentWindow.postMessage({ kind: 'camera-reframe' }, '*'); f.contentWindow.postMessage({ kind: 'camera-lock', on: false }, '*'); } async function select(id) { selectedId = id; activeId = null; playSegs = null; pathSet = new Set(mode === 'path' ? pathToRoot(id) : []); refreshNodeStyles(); const path = mode === 'path' ? pathToRoot(id) : [id]; setKataStatus(mode === 'move' ? 'loading move…' : 'stitching ' + path.length + ' moves…'); try { const m = await stitchPath(path); loadIntoViewer(m, path.length === 1 ? id : '', fullLabel(id)); playSegs = []; let f0 = 0; path.forEach((nid, i) => { const nf = numFrames(nid), lo = i === 0 ? 0 : 1; let hi = nf; if (i < path.length - 1) { const cf = CTX.byId.get(path[i + 1]) && CTX.byId.get(path[i + 1]).continues_from; let bf = cf ? cf.frame : null; if (bf != null) { bf = bf < 0 ? nf + bf : bf; if (bf >= 0 && bf < nf) hi = bf + 1; } } const len = Math.max(0, hi - lo); playSegs.push({ id: nid, start: f0, end: f0 + len, lo }); f0 += len; }); setKataStatus(mode === 'move' ? ('move: ' + nodeLabel(id)) : ('kata path · ' + path.length + ' moves · ' + m.num_frames + 'f')); } catch (e) { console.error(e); setKataStatus('error: ' + e.message); } } // Playhead: slide a dot down the active move's bar as the viewer plays. function kataOnFrame(st) { if (!playSegs || !playSegs.length || !POS || !kPlayLine) return; const f = Math.round(st.frame || 0); const seg = playSegs.find((s) => f >= s.start && f < s.end) || playSegs[playSegs.length - 1]; if (!seg) return; if (seg.id !== activeId) { activeId = seg.id; refreshNodeStyles(); } const pos = POS.get(seg.id); if (!pos) return; const clipFrame = (seg.lo || 0) + (f - seg.start); const y = pos.y + clipFrame * SCALE_Y; kPlayLine.attr('x1', pos.x).attr('y1', pos.y).attr('x2', pos.x).attr('y2', y).style('display', ''); kPlayHead.attr('cx', pos.x).attr('cy', y).style('display', ''); } function populateKataSelect(roots) { if (!kataSelectEl) return; kataSelectEl.innerHTML = ''; for (const id of roots) { const o = document.createElement('option'); o.value = id; o.textContent = fullLabel(id).slice(0, 40) + ' (' + descendantsOf(id).size + ' moves)'; kataSelectEl.appendChild(o); } if (currentRoot) kataSelectEl.value = currentRoot; kataSelectEl.onchange = () => { currentRoot = kataSelectEl.value; selectedId = activeId = null; playSegs = null; pathSet = new Set(); buildGraph(); setKataStatus(descendantsOf(currentRoot).size + ' moves. Click a node to play.'); }; } async function refreshKataTree() { if (!treeEl) return; const items = await fetchManifest(); CTX = buildTree(items); const bar = document.querySelector('#kimodo-kata-drawer .kimodo-kata-bar'); if (!CTX.roots.length) { currentRoot = null; if (kPlayLine) { kPlayLine.style('display', 'none'); kPlayHead.style('display', 'none'); } treeEl.innerHTML = ''; if (bar) bar.style.display = 'none'; setKataStatus('No katas yet. Open Actions, generate a move, then press + kata to start one.'); return; } if (bar) bar.style.display = ''; if (!currentRoot || !CTX.byId.has(currentRoot)) currentRoot = CTX.roots[0]; populateKataSelect(CTX.roots); buildGraph(); setKataStatus(descendantsOf(currentRoot).size + ' moves. Click a node to play the path up to it.'); } let STANCES = []; let _stanceFilterWired = false; const SMPLX22_BONES = ['pelvis','left_hip','right_hip','spine1','left_knee','right_knee','spine2', 'left_ankle','right_ankle','spine3','left_foot','right_foot','neck','left_collar','right_collar', 'head','left_shoulder','right_shoulder','left_elbow','right_elbow','left_wrist','right_wrist']; function stanceName(m) { return m.stance_name || (m.prompt || '').replace(/^\[STANCE\]\s*/, '') || m.id; } async function refreshStances() { const status = document.getElementById('kimodo-stances-status'); const list = document.getElementById('kimodo-stances-list'); if (!list) return; const fEl = document.getElementById('kimodo-stance-filter'); if (fEl && !_stanceFilterWired) { fEl.addEventListener('input', renderStances); _stanceFilterWired = true; } if (status) status.textContent = 'loading…'; const items = await fetchManifest(); // The manifest only carries a meta subset (prompt is in it; is_stance/tags are // not), so identify stances by their "[STANCE] …" prompt prefix. STANCES = items.filter((m) => (m.prompt || '').startsWith('[STANCE]')); if (status) status.textContent = STANCES.length ? (STANCES.length + ' stances') : 'no stances yet'; renderStances(); } function renderStances() { const list = document.getElementById('kimodo-stances-list'); const fEl = document.getElementById('kimodo-stance-filter'); if (!list) return; const q = (fEl && fEl.value || '').trim().toLowerCase(); const match = (m) => !q || stanceName(m).toLowerCase().includes(q) || (m.stance_tags || []).join(' ').toLowerCase().includes(q); list.innerHTML = ''; const shown = STANCES.filter(match); if (!shown.length) { list.innerHTML = '
no matches
'; return; } for (const m of shown) { const card = document.createElement('button'); card.type = 'button'; card.className = 'kimodo-drawer-option'; card.style.cssText = 'display:block;width:100%;text-align:left;margin-bottom:6px;padding:7px 9px;line-height:1.3'; const tags = (m.stance_tags || []).map((t) => '#' + t + '').join(' '); card.innerHTML = '
' + stanceName(m) + '
' + '
' + tags + '
'; card.onclick = async () => { try { const rec = await fetchRecord(m.id); // Stance records lack num_joints and have empty bone_names; the viewer // skips clips whose num_joints != 22, so derive them (like stitchPath does). if (!rec.num_joints) rec.num_joints = (rec.posed_joints && rec.posed_joints[0]) ? rec.posed_joints[0].length : 22; if (!rec.bone_names || !rec.bone_names.length) rec.bone_names = SMPLX22_BONES; loadIntoViewer(rec, m.id, stanceName(m)); } catch (e) { console.error('stance preview failed', e); } }; list.appendChild(card); } } const SMPLX22_PARENTS = [-1,0,0,0,1,2,3,4,5,6,7,8,9,9,9,12,13,14,16,17,18,19]; let composeStances = []; // palette: [STANCE] manifest records let composeStrip = []; // [{ id, name, prompt, advance, turn, seconds, _uid }] let composeSelId = null; // palette chip currently previewed let _czFirstPick = null; // CONFIRMED start stance for a NEW kata (awaiting the END) let _czTentative = null; // stance tapped-to-preview but NOT yet confirmed (tap again to confirm) let composeCamLock = false; // center+lock-camera toggle (the player header button) let _czLastHint = ''; // last instruction text shown, to pulse it only on change let _czSlideUid = null; // _uid of a just-added move to slide in from the top let _czGenPulseUid = null; // move just generated → pulse the edit card gray→purple let _czSelPulseUid = null; // generated move just selected to edit → pulse green→purple let composeBusy = false; let _czUid = 0; // monotonic id per sequence card (for enter animation) let _czAnimateUid = null; // the one card to play the "enter" animation on next render let composeView = 'library'; // 'library' (My Katas) | 'create' (composer) let composeScope = 'mine'; // 'mine' | 'community' let composeKatas = []; // [{root, label, count, ids:[spine], recipe, owner}] const _czName = (m) => m.stance_name || (m.prompt || '').replace(/^\[STANCE\]\s*/, '') || m.id; const _czDefaultPrompt = (name) => 'A person moves into ' + String(name || 'a pose').replace(/\s*@\d+$/, '').toLowerCase() + '.'; const _czGlyph = (n) => /kick/i.test(n) ? '🦵' : /punch|fist|strike|block/i.test(n) ? '👊' : /jump|leap|fly|somersault|flip|aerial|cartwheel|roll/i.test(n) ? '🤸' : /bow/i.test(n) ? '🙇' : /fall/i.test(n) ? '🤕' : '🧍'; // Pose thumbnails: a lightweight front-view stick figure drawn from a stance's // frame-0 posed_joints (x = right, y = up). Cached by id; fetched lazily per card. const _poseThumb = {}; function poseThumbSVG(P) { let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9; for (const j of P) { if (j[0] < minX) minX = j[0]; if (j[0] > maxX) maxX = j[0]; if (j[1] < minY) minY = j[1]; if (j[1] > maxY) maxY = j[1]; } const sp = Math.max(maxX - minX, maxY - minY, 0.5), pad = sp * 0.16; const vbX = minX - pad, vbY = -(maxY + pad), vbW = (maxX - minX) + 2 * pad, vbH = (maxY - minY) + 2 * pad; const sw = sp * 0.03, r = sp * 0.024; let bones = ''; for (let i = 0; i < P.length; i++) { const p = SMPLX22_PARENTS[i]; if (p < 0) continue; bones += ''; } let dots = ''; for (const j of P) dots += ''; return '' + '' + bones + '' + '' + dots + ''; } async function ensurePoseThumb(id) { if (_poseThumb[id] != null) return _poseThumb[id]; const rec = await fetchRecord(id); const P = rec && rec.posed_joints && rec.posed_joints[0]; _poseThumb[id] = P ? poseThumbSVG(P) : ''; return _poseThumb[id]; } function fillPoseThumb(el, id) { if (_poseThumb[id]) { el.innerHTML = _poseThumb[id]; return; } el.textContent = '…'; ensurePoseThumb(id).then((svg) => { if (svg) el.innerHTML = svg; else el.textContent = '🧍'; }).catch(() => { el.textContent = '🧍'; }); } // Canonicalise a stance's frame-0 pose: re-root pelvis to XZ origin + rotate about // Y so the hip-line heading is 0 (Y kept, so airborne poses preserve height). Same // convention as the backend _place_pose, so per-move turn/advance behave correctly. function canonKeyframe(P, Q) { const px = P[0][0], pz = P[0][2]; const P0 = P.map((j) => [j[0]-px, j[1], j[2]-pz]); const phi = -Math.atan2(P0[2][2]-P0[1][2], -(P0[2][0]-P0[1][0])); const c = Math.cos(phi), s = Math.sin(phi); const Pc = P0.map((j) => [c*j[0]+s*j[2], j[1], -s*j[0]+c*j[2]]); const qy = Math.sin(phi/2), qw = Math.cos(phi/2); const Qc = Q.map((q) => { const gx=q[0], gy=q[1], gz=q[2], gw=q[3]; return [qw*gx+qy*gz, qw*gy+qy*gw, qw*gz-qy*gx, qw*gw-qy*gy]; }); return { posed: Pc, quats: Qc }; } // Place a canon pose at a ground (x,z) with a Y-rotation (deg) — mirrors the backend // _place_pose, done client-side so the move's 2D placement needs no backend change. function placePose(posed, quats, rotDeg, tx, tz) { const th = (rotDeg || 0) * Math.PI / 180, c = Math.cos(th), s = Math.sin(th); const P = posed.map((j) => [c*j[0]+s*j[2] + (tx||0), j[1], -s*j[0]+c*j[2] + (tz||0)]); const qy = Math.sin(th/2), qw = Math.cos(th/2); const Q = quats.map((q) => { const gx=q[0], gy=q[1], gz=q[2], gw=q[3]; return [qw*gx+qy*gz, qw*gy+qy*gw, qw*gz-qy*gx, qw*gw-qy*gy]; }); return { posed: P, quats: Q }; } async function previewComposeStance(id, name) { try { const rec = await fetchRecord(id); if (!rec.num_joints) rec.num_joints = (rec.posed_joints && rec.posed_joints[0]) ? rec.posed_joints[0].length : 22; if (!rec.bone_names || !rec.bone_names.length) rec.bone_names = SMPLX22_BONES; _czViewerHasPreview = false; // a single browsed stance is loaded, not the kata preview loadIntoViewer(rec, id, name, { keepCamera: true }); // browsing stances must not jog the camera } catch (e) { console.error('compose preview failed', e); } } function setComposeStatus(t) { const s = document.getElementById('kimodo-compose-status'); if (s) s.textContent = t || ''; } // No-op retained so existing call sites stay valid: the composer is a normal side // drawer again (positioned by .kimodo-drawer CSS), not a JS-anchored bottom bar. function positionComposeBar() {} // Stable per-stance number (#1, #2…) for easy identification across the picker // and the sequence cards. let _stanceNum = {}; function stanceBadge(id) { const b = document.createElement('span'); b.className = 'kimodo-cz-thumbnum'; b.textContent = '#' + (_stanceNum[id] || '?'); return b; } async function composeOpen() { const items = await fetchManifest(); composeStances = items.filter((m) => (m.prompt || '').startsWith('[STANCE]')); _stanceNum = {}; composeStances.forEach((m, i) => { _stanceNum[m.id] = i + 1; }); composeKatas = katasFromManifest(items); renderComposePalette(); renderComposeStrip(); setComposeView(composeView); // render whichever view is active (library by default) if (!composeCanBuild()) setComposeStatus('⚠ Build unavailable here (no backend / compose bridge). Preview still works.'); } // --- View switching: library (home) | create | player ---------------------- // The views are a horizontal carousel (library left of create left of player); // the active one is centered, the rest slide off to their side. const _czOrder = { library: 0, create: 1, player: 2 }; let composeCurrentKata = null; // the kata currently being viewed/edited (for the name overlay) let composeEditing = false; // create view is editing an existing kata (vs a fresh "New kata") function setComposeView(view, opts) { opts = opts || {}; composeView = view; if (view !== 'create') closeStancePicker(); // render BEFORE sliding so the incoming view has content during the animation if (view === 'library') renderComposeLibrary(); if (view === 'create') renderComposeStrip(); if (view === 'player') renderComposePlayer(); const wrap = document.getElementById('kimodo-cz-views'); if (opts.noSlide && wrap) wrap.classList.add('cz-noslide'); // switch in place (edit ↔ view) [['library', 'kimodo-cz-library'], ['create', 'kimodo-cz-create'], ['player', 'kimodo-cz-player']].forEach(([name, id]) => { const el = document.getElementById(id); if (!el) return; el.classList.remove('cz-active', 'cz-left', 'cz-right'); el.classList.add(_czOrder[name] === _czOrder[view] ? 'cz-active' : (_czOrder[name] < _czOrder[view] ? 'cz-left' : 'cz-right')); }); if (opts.noSlide && wrap) { void wrap.offsetWidth; setTimeout(() => wrap.classList.remove('cz-noslide'), 30); } const ct = document.getElementById('kimodo-cz-createtitle'); if (ct) ct.textContent = composeEditing ? 'Editing' : 'New kata'; updateComposeViewerName(); updateComposeLaunch(); // launch button only shows in Create } // The selected-kata name lives on the 3D view (top-left) while a kata is open // (playing OR editing). "New kata" (fresh) shows no overlay. function updateComposeViewerName() { const box = document.getElementById('kimodo-cz-vname'); if (!box) return; const showing = (composeView === 'player' || (composeView === 'create' && composeEditing)) && composeCurrentKata; if (showing) { const drawer = document.getElementById('kimodo-compose-drawer'); const w = drawer ? Math.round(drawer.getBoundingClientRect().width) : 230; box.style.left = (w + 16) + 'px'; renderViewerName(composeCurrentKata.label || composeCurrentKata.name || 'Kata'); // Attribution beside the name: "by X" for community katas, "⑂ forked from Y" for forks. const byEl = document.getElementById('kimodo-cz-vname-by'); if (byEl) { const k = composeCurrentKata; const bits = []; if (!k.local && k.contributor) bits.push('by ' + k.contributor); if (k.forked_from) bits.push('⑂ ' + (k.forked_from.contributor_name || 'anonymous')); byEl.textContent = bits.join(' · '); } box.classList.add('show'); } else { box.classList.remove('show'); } } function renderViewerName(name) { const txt = document.getElementById('kimodo-cz-vname-text'); if (txt) txt.textContent = name; } function startRenameKata() { const box = document.getElementById('kimodo-cz-vname'); const k = composeCurrentKata; if (!box || !k) return; const cur = k.label || k.name || 'Kata'; box.innerHTML = ''; const inp = document.createElement('input'); inp.type = 'text'; inp.value = cur; box.appendChild(inp); inp.focus(); inp.select(); const finish = (save) => { const v = (inp.value || '').trim(); if (save && v) { k.label = v; k.name = v; if (composePlayer && composePlayer.k === k) { composePlayer.k.label = v; } if (k.local && k.lid) { const a = loadMyKatas(); const e = a.find((x) => x.lid === k.lid); if (e) { e.name = v; saveMyKatas(a); } } } restoreViewerNameDom(); renderViewerName(k.label || 'Kata'); }; inp.onkeydown = (ev) => { if (ev.key === 'Enter') finish(true); else if (ev.key === 'Escape') finish(false); }; inp.onblur = () => finish(true); } // Rebuild the name box's text + edit-icon DOM after an inline rename. Both the // text and the ✎ start a rename. function restoreViewerNameDom() { const box = document.getElementById('kimodo-cz-vname'); if (!box) return; box.innerHTML = ''; const txt = document.getElementById('kimodo-cz-vname-text'); if (txt) { txt.style.cursor = 'text'; txt.onclick = startRenameKata; } const ed = document.getElementById('kimodo-cz-vname-edit'); if (ed) ed.onclick = startRenameKata; } // Start a fresh kata (+ Create a kata). function newComposeKata() { composeStrip = []; composeSelId = null; composePlayer = null; composeKataEditable = true; _prevScrubRegion = null; reframeViewer(); // fresh kata → re-center the camera once on the first stance load // a fresh local kata, persisted to localStorage as soon as a move is generated composeCurrentKata = { local: true, lid: 'k' + Date.now().toString(36), name: 'Untitled kata', label: 'Untitled kata', recipe: [], ids: [] }; setComposeView('player'); setComposeStatus(''); } // Animate the "+ Add a stance" button sliding down into view (used when entering // edit on an existing kata, so it reads as "controls became editable" in place). function revealAddStance(animate) { const b = document.getElementById('kimodo-cz-addstance'); if (!b) return; b.classList.remove('cz-slidedown'); if (animate) { void b.offsetWidth; b.classList.add('cz-slidedown'); } } function openStancePicker() { const pk = document.getElementById('kimodo-cz-picker'); if (!pk) return; _czFirstPick = null; _czTentative = null; // fresh selection each open renderComposePalette(); pk.style.display = ''; void pk.offsetWidth; pk.classList.add('open'); // slide in from the right const f = document.getElementById('kimodo-cz-pfilter'); if (f) { f.value = ''; } } function closeStancePicker() { const pk = document.getElementById('kimodo-cz-picker'); if (!pk) return; _czFirstPick = null; _czTentative = null; pk.classList.remove('open'); setTimeout(() => { if (!pk.classList.contains('open')) pk.style.display = 'none'; }, 240); } // --- My Katas library ------------------------------------------------------ // Reconstruct kata chains from the manifest (root = no continues_from but has a // continuation), following the end-frame child as the spine. Carries the root's // compose_recipe + compose_owner (stamped at build time) for listing + editing. function katasFromManifest(items) { const byId = {}; items.forEach((m) => { if (m.id) byId[m.id] = m; }); const children = {}; items.forEach((m) => { const src = (m.continues_from || {}).source_id; if (src && byId[src] && m.id) (children[src] = children[src] || []).push(m.id); }); const branchFrame = (cid) => { const f = (byId[cid].continues_from || {}).frame; return f == null ? -1 : Number(f); }; const out = []; for (const m of items) { const rid = m.id; if (!rid || (m.continues_from || {}).source_id || !children[rid]) continue; // only roots that start a chain const path = [rid], seen = { [rid]: 1 }; let cur = rid; while (children[cur]) { const nxt = children[cur].reduce((a, b) => branchFrame(b) > branchFrame(a) ? b : a, children[cur][0]); if (seen[nxt]) break; path.push(nxt); seen[nxt] = 1; cur = nxt; } // count all descendants (not just the spine) + 1 for the root const all = {}; const stack = [rid]; while (stack.length) { const x = stack.pop(); (children[x] || []).forEach((c) => { if (!all[c]) { all[c] = 1; stack.push(c); } }); } // Recipe = the most complete one stamped along the chain (every move stamps the // recipe-so-far; the LAST move holds them all — the root's is truncated to move 1). let recipe = m.compose_recipe || null; for (const sid of path) { const sm = byId[sid]; const r = sm && sm.compose_recipe; if (r && r.length && (!recipe || r.length > recipe.length)) recipe = r; } let label = recipe && recipe.length ? recipeLabel(recipe) : (m.prompt || rid); // Attribution: the root's creator name, else the first spine move that has one // (older roots were built anonymously but their later moves may carry a name). let contributor = m.created_by_name || m.created_by || ''; if (!contributor) { for (const sid of path) { const sm = byId[sid]; if (sm && (sm.created_by_name || sm.created_by)) { contributor = sm.created_by_name || sm.created_by; break; } } } out.push({ root: rid, label: String(label).slice(0, 40), count: Object.keys(all).length + 1, ids: path, recipe, owner: m.compose_owner || '', contributor: contributor || '', contributor_id: m.compose_owner || m.created_by || '', forked_from: m.compose_forked_from || null, created: m.created_at || 0 }); } out.sort((a, b) => b.created - a.created); return out; } function recipeLabel(recipe) { const names = recipe.map((r) => (r.name || '').replace(/\s*@\d+$/, '')).filter(Boolean); if (!names.length) return 'Kata'; return names[0] + (names.length > 1 ? ' → ' + names[names.length - 1] : ''); } function renderComposeLibrary() { const list = document.getElementById('kimodo-cz-katalist'); if (!list) return; document.querySelectorAll('#kimodo-cz-scope .kimodo-cz-segbtn').forEach((b) => b.classList.toggle('active', b.dataset.scope === composeScope)); // Mine = the localStorage entries (recipe-backed, instant); Community = the // server manifest. Mine cards are always recipe-editable; their filmstrips draw // from stance ids, so they don't depend on per-clip record files. let shown; if (composeScope === 'mine') { shown = loadMyKatas().map((e) => ({ lid: e.lid, root: e.root || null, label: e.name, count: (e.recipe || []).length, recipe: e.recipe, ids: e.ids || null, forked_from: e.forked_from || null, published: !!e.published, published_root: e.published_root || null, local: true })); } else { shown = composeKatas; } list.innerHTML = ''; // + Create a kata — always at the top of the list. const createCard = document.createElement('div'); createCard.className = 'kimodo-cz-createcard'; createCard.innerHTML = 'Create a kata'; createCard.onclick = () => newComposeKata(); list.appendChild(createCard); if (!shown.length) { const e = document.createElement('div'); e.className = 'kimodo-cz-libempty'; e.textContent = composeScope === 'mine' ? 'No katas yet — tap + Create a kata above.' : 'No katas in this dataset yet.'; list.appendChild(e); return; } for (const k of shown) { const card = document.createElement('div'); card.className = 'kimodo-cz-katacard'; // Mine = delete (your draft); Community = fork (copy into Mine — never delete others'). if (k.local) { const del = document.createElement('button'); del.type = 'button'; del.className = 'kimodo-cz-katadel'; del.textContent = '✕'; del.title = 'delete kata'; del.onclick = (e) => { e.stopPropagation(); deleteKata(k); }; card.appendChild(del); } else { const fork = document.createElement('button'); fork.type = 'button'; fork.className = 'kimodo-cz-katafork'; fork.textContent = '⑂ Fork'; fork.title = 'Fork into My Katas'; fork.onclick = (e) => { e.stopPropagation(); forkKata(k); }; card.appendChild(fork); } const head = document.createElement('div'); head.className = 'kimodo-cz-katahead'; const nm = document.createElement('div'); nm.className = 'kimodo-cz-kataname'; nm.textContent = k.label; head.appendChild(nm); // Attribution: who made it (Community), and fork lineage if any. const meta = document.createElement('div'); meta.className = 'kimodo-cz-katameta'; if (!k.local) { const by = document.createElement('div'); by.className = 'kimodo-cz-kataby'; by.textContent = 'by ' + (k.contributor || 'anonymous'); meta.appendChild(by); } if (k.forked_from) { const ff = document.createElement('div'); ff.className = 'kimodo-cz-katafrom'; ff.textContent = '⑂ forked from ' + (k.forked_from.contributor_name || 'anonymous'); meta.appendChild(ff); } // filmstrip: the recipe's stance poses (start/end accented), else the spine clips const film = document.createElement('div'); film.className = 'kimodo-cz-film'; const frames = k.recipe && k.recipe.length ? k.recipe.map((r) => r.id) : k.ids; frames.forEach((sid, i) => { const fr = document.createElement('div'); fr.className = 'kimodo-cz-frame' + (i === 0 ? ' start' : (i === frames.length - 1 ? ' end' : '')); fillPoseThumb(fr, sid); film.appendChild(fr); }); card.appendChild(head); if (meta.childNodes.length) card.appendChild(meta); card.appendChild(film); // Local-only: publish a completed kata to the community. On the Space katas // auto-publish (no backend), so the button only appears on local (KIMODO_BACKEND), // and only once the kata has at least one generated move clip. if (k.local && KIMODO_BACKEND && k.ids && k.ids.length) { const pub = document.createElement('button'); pub.type = 'button'; pub.className = 'kimodo-cz-katapub' + (k.published ? ' published' : ''); pub.textContent = k.published ? '✓ Published to community' : '⬆ Publish to community'; pub.disabled = !!k.published; pub.onclick = (e) => { e.stopPropagation(); if (!k.published) publishKata(k, pub); }; card.appendChild(pub); } // Tapping the card = watch the kata (no buttons on the card). Editing lives in // the player header for user-created (recipe-backed) katas. card.onclick = () => playKata(k); list.appendChild(card); } } // editKata is now folded into the unified kata view (a Mine kata opens editable); // kept as a thin alias. function editKata(k) { playKata(k); } // --- Unified kata view state ---------------------------------------------- let composePlayer = null; // scrubber state { numFrames, fps, poseFrac:[..], curFrame, playing } | null let _czViewerHasPreview = false; // true = the viewer holds the kata preview; false = a browsed single stance let composeKataEditable = false; let composeGeneratingIdx = -1; // move index currently generating (for the loading button) let composeJustGenIdx = -1; // move index just generated (for the gray→normal transition) let _prevScrubRegion = null; // last rail span, so the scrubber animates as it grows let _czPopUids = null; // card _uids to play the "juicy add" pop animation on let composeSelMove = -1; // move index selected for 3D placement (overlaid figure) const _czTransport = (action, frame) => { const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'transport', action, frame }, '*'); }; const _czTime = (frame, fps) => { const s = Math.max(0, Math.round((frame || 0) / (fps || 30))); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }; // Fraction [0..1] at which each pose is reached, from the recipe's move durations // (clip num_frames ≈ seconds*fps), else evenly spaced. function posefracFromKata(k) { const rec = k.recipe; if (rec && rec.length > 1) { const segs = rec.slice(1).map((r) => Math.max(0.3, Number(r.seconds) || 2.2)); const tot = segs.reduce((a, b) => a + b, 0) || 1; const out = [0]; let c = 0; for (const s of segs) { c += s; out.push(c / tot); } return out; } const n = ((k.ids && k.ids.length) || 1) + 1; return Array.from({ length: n }, (_, i) => i / Math.max(1, n - 1)); } // The contiguous run of generated (clean) moves from move 1 — the part that has a // playable, stitchable preview. { ids:[clip], secs:[seconds] }. function generatedPrefix() { const ids = [], secs = []; for (let i = 1; i < composeStrip.length; i++) { const c = composeStrip[i]; if (c.clipId && !c.dirty) { ids.push(c.clipId); secs.push(Number(c.seconds) || 2.2); } else break; } return { ids, secs }; } function posefracFromStrip() { const { secs } = generatedPrefix(); const tot = secs.reduce((a, b) => a + b, 0) || 1; let cum = 0; const pf = [0]; for (const s of secs) { cum += s; pf.push(cum / tot); } while (pf.length < composeStrip.length) pf.push(1); return pf; } // Re-stitch a kata's spine clips from wherever they live: the remote backend (local // dev — the composer's clips are saved there, not in the HF dataset), else the // HF-dataset JS stitch (the deployed Space's in-process clips). async function composeStitch(ids, prebuilt) { // The in-process (Space) build hands back a ready server-stitched preview — use it // directly so we neither reload the iframe nor re-fetch the just-saved (CDN-lagging) clips. if (prebuilt) return prebuilt; if (KIMODO_BACKEND) { // Try the remote backend (holds locally-built clips). If it doesn't have these // clips (e.g. a forked COMMUNITY kata, whose clips live in the dataset), fall // back to the client-side dataset stitch so it still plays. try { const r = await fetch(KIMODO_BACKEND + '/stitch_path', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, save: false }) }); if (r.ok) return await r.json(); } catch (e) {} return await stitchPath(ids); } return await stitchPath(ids); } // Open a kata in the unified view. Your own (recipe-backed) katas open editable // with per-move controls + the generated preview; community katas are read-only. async function playKata(k, prebuilt, keepCam) { const editable = !!(k.local && k.recipe && k.recipe.length); composeCurrentKata = k; composeKataEditable = editable; composePlayer = null; _prevScrubRegion = null; if (!keepCam) reframeViewer(); // fresh session → re-center the camera once on the first motion load // Build the working strip: stances + per-move data, with clipIds from the saved spine. if (k.recipe && k.recipe.length) { composeStrip = k.recipe.map((r, idx) => ({ id: r.id, name: r.name || r.id, prompt: r.prompt || '', seconds: r.seconds || 2.2, tx: r.tx || 0, tz: (r.tz != null ? r.tz : (r.advance || 0)), rot: (r.rot != null ? r.rot : (r.turn || 0)), clipId: (idx >= 1 && k.ids) ? (k.ids[idx - 1] || null) : null, dirty: false, expanded: false, _uid: ++_czUid })); } else { composeStrip = (k.ids || []).map((id, i) => ({ id: id, name: 'pose ' + i, prompt: '', seconds: 2.2, tx: 0, tz: 0, rot: 0, clipId: i >= 1 ? k.ids[i - 1] : null, dirty: false, expanded: false, _uid: ++_czUid })); } composeStrip.forEach((c, k2) => { if (k2 >= 1 && c.clipId) _czSnapshot(c); }); // saved state for revert-on-discard composeSelMove = -1; setComposeView('player'); setComposeStatus('loading kata…'); const ids = (k.ids && k.ids.length) ? k.ids : null; if (!ids) { setComposeStatus(editable ? 'Tap a move’s GENERATE to create it.' : 'nothing to play'); composePlayer = null; renderComposePlayer(); return; } try { const m = await composeStitch(ids, prebuilt); m.num_joints = m.num_joints || ((m.posed_joints && m.posed_joints[0]) ? m.posed_joints[0].length : 22); if (!m.bone_names || !m.bone_names.length) m.bone_names = SMPLX22_BONES; if (!m.parents || !m.parents.length) m.parents = SMPLX22_PARENTS; _czViewerHasPreview = true; loadIntoViewer(m, ids.length === 1 ? ids[0] : '', k.label, keepCam ? { keepCamera: true } : undefined); composePlayer = { numFrames: m.num_frames || m.preview_frames || 1, fps: m.fps || m.preview_fps || 30, poseFrac: posefracFromStrip(), curFrame: 0, playing: true, genCount: ids.length, boundaries: _czBoundaries(m), _payload: m }; renderComposePlayer(); updateComposeViewerName(); setTimeout(() => _czTransport('play'), 120); setComposeStatus(''); } catch (e) { console.error(e); setComposeStatus('could not load kata: ' + e.message); renderComposePlayer(); } } // --- Per-move generation (independent: each move pins its two stances) ------- async function generateMove(i) { if (composeBusy) return; const c = composeStrip[i]; if (!c || i < 1) return; composeBusy = true; composeGeneratingIdx = i; renderComposePlayer(); // button → loading state setComposeStatus('generating move ' + i + '…'); try { const recA = await fetchRecord(composeStrip[i - 1].id), recB = await fetchRecord(c.id); const A = canonKeyframe(recA.posed_joints[0], recA.global_quats_xyzw[0]); const B0 = canonKeyframe(recB.posed_joints[0], recB.global_quats_xyzw[0]); const B = placePose(B0.posed, B0.quats, c.rot || 0, c.tx || 0, c.tz || 0); // place the END pose in the move's local frame const move = { prompt: (c.prompt || _czDefaultPrompt(c.name)).trim(), seconds: c.seconds || 2.2, keyframes: [ { posed_joints: A.posed, global_quats_xyzw: A.quats, frac: 0.0, turn_deg: 0, advance: 0 }, { posed_joints: B.posed, global_quats_xyzw: B.quats, frac: 1.0, turn_deg: 0, advance: 0 } ] }; // Space (no remote backend): route this ONE move through the in-process bridge; // completion + clipId assignment arrive via __kimodoOnComposeBuilt. if (!KIMODO_BACKEND) { generateMoveInProcess(i, move); return; } // Local: direct fetch to the remote backend, await the clip id. const r = await fetch(KIMODO_BACKEND + '/generate_between', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ num_steps: 60, post_processing: true }, move)) }); if (!r.ok) throw new Error(r.status + ' ' + (await r.text()).slice(0, 120)); c.clipId = (await r.json()).id; c.dirty = false; c.expanded = false; _czSnapshot(c); // saved state for revert-on-discard saveCurrentKata(); composeBusy = false; composeGeneratingIdx = -1; composeJustGenIdx = i; clearPlaceMode(); // gray → normal on re-render await updateKataPreview(i); // play from this move's start pose, not the kata top _czGenPulseUid = c._uid; // set before the FINAL render so the card pulses gray→purple selectMove(i); // keep the move selected (so it stays open + plays solo) setComposeStatus(''); } catch (e) { composeBusy = false; composeGeneratingIdx = -1; console.error(e); renderComposePlayer(); setComposeStatus('generate failed: ' + e.message); } } // Re-stitch the generated prefix and refresh the scrubbable preview. When `startMove` // is given, playback begins at THAT move's start pose (not frame 0) — so after a // GENERATE you see the kata pick up from the just-built move, not replay from the top. async function updateKataPreview(startMove, prebuilt) { const { ids } = generatedPrefix(); if (!ids.length && !prebuilt) { composePlayer = null; renderComposePlayer(); return; } try { let m; if (prebuilt) { // Server-stitched preview handed back from the in-process (Space) build. Reuse the // LIVE viewer iframe (no reload) and DON'T re-fetch the just-saved clips — the // dataset CDN lags right after a commit, so a client re-stitch would 404. m = prebuilt; // The viewer plays `preview_frames` (the server downsamples to <=120). Align // num_frames to that so the scrubber + seek don't run past the on-screen clip. m.num_frames = m.preview_frames || m.num_frames || 1; } else { m = await composeStitch(ids); m.num_joints = (m.posed_joints && m.posed_joints[0]) ? m.posed_joints[0].length : 22; if (!m.bone_names || !m.bone_names.length) m.bone_names = SMPLX22_BONES; m.parents = SMPLX22_PARENTS; } _czViewerHasPreview = true; loadIntoViewer(m, '', (composeCurrentKata && composeCurrentKata.label) || 'kata', { keepCamera: true }); composePlayer = { numFrames: m.num_frames || 1, fps: m.fps || m.preview_fps || 30, poseFrac: posefracFromStrip(), curFrame: 0, playing: true, genCount: ids.length, boundaries: _czBoundaries(m), _payload: m }; renderComposePlayer(); // Start at the requested move's start frame (pf[startMove-1]) instead of the top. let sf = 0; if (startMove && startMove >= 1) { const pf = composePlayer.poseFrac, fr = pf[Math.min(startMove - 1, pf.length - 1)] || 0; sf = Math.round(fr * ((composePlayer.numFrames || 1) - 1)); } composePlayer.curFrame = sf; setTimeout(() => { if (sf > 0) { _czTransport('seek', sf); } _czTransport('play'); updatePlayerProgress(sf, true); }, 120); } catch (e) { console.error(e); setComposeStatus('preview failed: ' + e.message); } } // Persist the working kata (recipe + generated prefix ids) to localStorage. function saveCurrentKata() { const k = composeCurrentKata; if (!k || !k.local) return; k.recipe = composeRecipe(); const { ids } = generatedPrefix(); k.ids = ids; k.root = ids[0] || null; if (!k.label) k.label = recipeLabel(k.recipe); k.name = k.label; const a = loadMyKatas(); const idx = a.findIndex((x) => x.lid === k.lid); const entry = { lid: k.lid, name: k.label, recipe: k.recipe, ids: k.ids, root: k.root, forked_from: k.forked_from || (idx >= 0 ? a[idx].forked_from : null) || null, ts: (idx >= 0 && a[idx].ts) ? a[idx].ts : Date.now() }; if (idx >= 0) a[idx] = entry; else a.unshift(entry); saveMyKatas(a); } // Fork a community kata into My Katas: copy its recipe + spine (clips are reused so it // plays instantly), keep attribution to the original author, and open it editable. function forkKata(k) { const ids = Array.isArray(k.ids) ? k.ids.slice() : []; let recipe = (k.recipe && k.recipe.length) ? JSON.parse(JSON.stringify(k.recipe)) : []; // The COMPLETE kata is the spine (ids). Older/community katas often stamped only a // truncated recipe (the first move's stances) or none at all — reconstruct the // missing moves from the spine clips so the fork has ALL moves. A recipe needs // ids.length + 1 stances (stance i-1 → i = move i, clip ids[i-1]). if (!recipe.length && ids.length) recipe.push({ id: ids[0], name: 'start', prompt: '', seconds: 2.2, tx: 0, tz: 0, rot: 0 }); while (recipe.length < ids.length + 1) { const i = recipe.length; const cid = ids[i - 1] || ids[ids.length - 1] || ('m' + i); recipe.push({ id: cid, name: 'move ' + i, prompt: '', seconds: 2.2, tx: 0, tz: 0, rot: 0 }); } if (!recipe.length) { setComposeStatus('This kata can’t be forked (no moves found).'); return; } const forked_from = { root: k.root || null, contributor: k.contributor_id || '', contributor_name: k.contributor || '' }; const entry = { lid: 'k' + Date.now().toString(36), name: (k.label || 'Kata') + ' (fork)', recipe, ids, root: (ids && ids[0]) || null, forked_from, ts: Date.now() }; const a = loadMyKatas(); a.unshift(entry); saveMyKatas(a); composeScope = 'mine'; setComposeStatus('Forked “' + (k.label || 'kata') + '” into My Katas (' + ids.length + ' move' + (ids.length === 1 ? '' : 's') + ').'); playKata({ lid: entry.lid, root: entry.root, label: entry.name, count: recipe.length, recipe, ids, forked_from, local: true }); } // Mark a Mine kata as published (so the card flips to "✓ Published" and won't re-publish). function markKataPublished(lid, root) { const a = loadMyKatas(); const e = a.find((x) => x.lid === lid); if (e) { e.published = true; e.published_root = root || null; saveMyKatas(a); } } // LOCAL-ONLY: publish a completed kata to the community dataset. Locally a kata's move // clips live on the remote and the recipe in localStorage, so nothing is in the dataset // until this pushes it. Hands {ids, recipe, name, owner, forked_from} to the hidden // gradio #kata-publish-btn, which fetches the clips from the remote, stamps the chain + // attribution, and saves them so the kata appears in Community. function publishKata(k, btnEl) { const ids = (k.ids || []).filter(Boolean); if (!ids.length) { setComposeStatus('Generate this kata’s moves before publishing.'); return; } const trigger = document.querySelector('#kata-publish-btn button, #kata-publish-btn'); const payloadInput = inputInside('kata-publish-payload'); const resultInput = inputInside('kata-publish-result'); if (!trigger || !payloadInput || !resultInput) { setComposeStatus('Publish backend not found.'); return; } if (btnEl) { btnEl.disabled = true; btnEl.textContent = '⟳ publishing…'; } setComposeStatus('Publishing “' + (k.label || 'kata') + '” to community…'); const anonEl = inputInside('anonymous-client-id'); const anon = anonEl ? (anonEl.value || '') : ''; resultInput.value = ''; payloadInput.value = JSON.stringify({ ids, recipe: k.recipe || null, name: k.label || '', owner: myAnonId(), forked_from: k.forked_from || null, anon_id: anon }); payloadInput.dispatchEvent(new Event('input', { bubbles: true })); const started = Date.now(); const timer = window.setInterval(() => { const val = resultInput.value; if (val) { window.clearInterval(timer); finish(val); } else if (Date.now() - started > 180000) { window.clearInterval(timer); finish('{"ok":false,"error":"timed out"}'); } }, 500); window.setTimeout(() => trigger.click(), 40); function finish(val) { let r = null; try { r = JSON.parse(val); } catch (e) {} if (r && r.ok) { markKataPublished(k.lid, r.root); setComposeStatus('Published “' + (r.name || k.label || 'kata') + '” ✓ — it’s now in Community.'); renderComposeLibrary(); // Refetch the manifest so the freshly-published kata appears under Community // (the cached composeKatas was loaded when the drawer opened). fetchManifest().then((items) => { composeKatas = katasFromManifest(items); renderComposeLibrary(); }).catch(() => {}); } else { if (btnEl) { btnEl.disabled = false; btnEl.textContent = '⬆ Publish to community'; } setComposeStatus('Publish failed: ' + ((r && r.error) || 'unknown')); } } } // Space per-move bridge: hand ONE move (its two pinned stances + the already-built // prefix) to the hidden gradio #compose-btn, which runs a single generate_between on // the @GPU model and returns the kata-so-far. One @GPU call per GENERATE — not a // whole-batch rebuild. Completion arrives via __kimodoOnComposeBuilt. function generateMoveInProcess(i, move) { const ta = document.querySelector('#compose-payload textarea, #compose-payload input'); const btn = document.querySelector('#compose-btn'); if (!ta || !btn) { composeBusy = false; composeGeneratingIdx = -1; renderComposePlayer(); setComposeStatus('generation bridge not found'); return; } // The generated prefix (moves 1..i-1, already built) chains this move and lets the // server stitch the preview up to here. const prefix = []; for (let k = 1; k < i; k++) { const pc = composeStrip[k]; if (pc.clipId && !pc.dirty) prefix.push(pc.clipId); else break; } const payload = { move: Object.assign({ index: i }, move), prefix_ids: prefix }; payload.recipe = composeRecipe(); // stamp the FULL recipe-so-far on EVERY move, so the chain's last move always holds the complete kata (the root's is captured before later moves exist) if (!prefix.length) { payload.owner = myAnonId(); if (composeCurrentKata && composeCurrentKata.forked_from) payload.forked_from = composeCurrentKata.forked_from; } // root move stamps owner + fork lineage ta.value = JSON.stringify(payload); ta.dispatchEvent(new Event('input', { bubbles: true })); composePending = true; setTimeout(() => btn.click(), 50); // safety: clear the loading state if the bridge never calls back. setTimeout(() => { if (composePending) { composePending = false; composeBusy = false; composeGeneratingIdx = -1; renderComposePlayer(); setComposeStatus('still working… (check the viewer)'); } }, 600000); } // --- Timeline geometry: each move's card height = its duration, so the card column // IS the timeline (a longer move = a taller card). The scrubber rail rides alongside // over the generated region as the unified playhead. --- const CZ_PXPS = 80; // timeline pixels per second const CZ_MINCARD = 58; // min compact-card height so the thumb + labels stay readable const CZ_MINREG = 88; // min active-move duration region (fits the from + end stances) function _czDur(c) { return Math.max(0.6, Math.min(4, Number(c && c.seconds) || 2.2)); } // Saved (generated) state of a move, so an edit can be reverted (we edit + save one // move at a time). Snapshot when a move becomes clean; restore on discard. function _czSnapshot(c) { c._saved = { seconds: c.seconds, prompt: c.prompt, tx: c.tx, tz: c.tz, rot: c.rot }; } function _czRevert(c) { if (c._saved) { c.seconds = c._saved.seconds; c.prompt = c._saved.prompt; c.tx = c._saved.tx; c.tz = c._saved.tz; c.rot = c._saved.rot; } c.dirty = false; } // px span of the moves CURRENTLY in the loaded preview (composePlayer.genCount), so // the scrubber survives editing a move (which marks it dirty but keeps the preview). function _czGenPx() { let px = 0; const n = (composePlayer && composePlayer.genCount) || generatedPrefix().ids.length; for (let i = 1; i <= n && i < composeStrip.length; i++) px += _czDur(composeStrip[i]) * CZ_PXPS; return px; } // Single shared duration-drag (one set of window listeners, not one per card per // render — avoids the leak of re-wiring on every re-render). let _czDragState = null; // { card, heightEl, minH, i, durv, sy, sd } function _czDragMove(e) { const st = _czDragState; if (!st) return; const dy = e.clientY - st.sy; if (Math.abs(dy) > 3 && st.card) st.card._dragged = true; const c = composeStrip[st.i]; if (!c) return; let d = st.sd + dy / CZ_PXPS; d = Math.max(0.6, Math.min(4, Math.round(d * 10) / 10)); c.seconds = d; if (st.heightEl) st.heightEl.style.height = Math.max(st.minH || CZ_MINCARD, d * CZ_PXPS) + 'px'; if (st.durv) st.durv.textContent = d.toFixed(1) + 's'; } function _czDragEnd() { const st = _czDragState; if (!st) return; _czDragState = null; const c = composeStrip[st.i]; if (!c) return; if (c.clipId) c.dirty = true; // changed a generated move → needs REFRESH (keep the preview/scrubber) saveCurrentKata(); renderComposePlayer(); // refresh the dirty color + REFRESH button + card height } window.addEventListener('pointermove', _czDragMove); window.addEventListener('pointerup', _czDragEnd); window.addEventListener('pointercancel', _czDragEnd); function wireDurationDrag(grip, card, heightEl, minH, i, durv) { grip.addEventListener('pointerdown', (e) => { if (card) card._dragged = false; _czDragState = { card, heightEl, minH, i, durv, sy: e.clientY, sd: _czDur(composeStrip[i]) }; try { grip.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); e.stopPropagation(); }); } // Unified kata view (#kimodo-cz-player): the timeline of move cards (height = duration) // + the scrubber rail over the generated region, and the active move's controls below. function renderComposePlayer() { const root = document.getElementById('kimodo-cz-player'); if (!root) return; const k = composeCurrentKata; if (!k) { root.innerHTML = ''; return; } const editable = composeKataEditable; const poses = composeStrip; const last = poses.length - 1; const hasPreview = !!(composePlayer && composePlayer.numFrames > 1); root.innerHTML = ''; // header: back + (optional play/pause) const head = document.createElement('div'); head.className = 'kimodo-cz-phead'; const back = document.createElement('button'); back.type = 'button'; back.className = 'kimodo-cz-pback'; back.textContent = '‹'; back.title = 'Back to katas'; back.onclick = () => { clearPlaceMode(); composePlayer = null; composeCurrentKata = null; composeKataEditable = false; _czTransport('pause'); setComposeView('library'); }; const spacer = document.createElement('div'); spacer.style.flex = '1'; head.appendChild(back); head.appendChild(spacer); if (hasPreview) { // Center + lock-on-character (left of play/pause). Toggle: press to center & lock, // press again to free the camera. const lock = document.createElement('button'); lock.type = 'button'; lock.id = 'kimodo-cz-plock'; lock.className = 'kimodo-cz-plock' + (composeCamLock ? ' on' : ''); lock.textContent = '⌖'; lock.title = composeCamLock ? 'Camera locked on character — tap to free' : 'Center & lock the camera on the character'; lock.onclick = () => { composeCamLock = !composeCamLock; const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'camera-lock', on: composeCamLock }, '*'); lock.classList.toggle('on', composeCamLock); lock.title = composeCamLock ? 'Camera locked on character — tap to free' : 'Center & lock the camera on the character'; }; head.appendChild(lock); const pp = document.createElement('button'); pp.type = 'button'; pp.className = 'kimodo-cz-pplay'; pp.id = 'kimodo-cz-pplay'; pp.textContent = composePlayer.playing ? '❚❚' : '▶'; pp.onclick = () => { composePlayer.playing = !composePlayer.playing; _czTransport(composePlayer.playing ? 'play' : 'pause'); pp.textContent = composePlayer.playing ? '❚❚' : '▶'; }; head.appendChild(pp); } // Sticky top bar: back/play + the contextual instruction (anchored at the top // while the timeline scrolls below it). const topbar = document.createElement('div'); topbar.className = 'kimodo-cz-ptop'; topbar.appendChild(head); // One move is editable at a time: an ungenerated (being-built) move, or a dirty // (edited-but-not-refreshed) generated move. While either exists the kata has // unsaved work, so "+ Add a stance" is hidden until it's clean. const hasUngen = poses.some((c, k) => k >= 1 && !c.clipId); const hasDirty = poses.some((c, k) => k >= 1 && c.clipId && c.dirty); const hasUnsaved = hasUngen || hasDirty; // The editor expands on exactly one move: the ungenerated (being-built) one if any // (locked active until generated), else the move you selected to re-edit, else none. let activeIdx = -1; if (editable) { let ungen = -1; for (let i = last; i >= 1; i--) { if (!poses[i].clipId) { ungen = i; break; } } if (ungen >= 1) activeIdx = ungen; else if (composeSelMove >= 1 && composeSelMove <= last) activeIdx = composeSelMove; } if (editable) { let hintHTML = ''; if (poses.length === 0) hintHTML = 'Tap + Add a stance below to begin.'; else if (activeIdx >= 1 && poses[activeIdx] && poses[activeIdx]._uid === _czGenPulseUid) { // Just generated/updated: celebrate + point at the next action (pulses since the text changed). hintHTML = 'Move #' + activeIdx + ' updated. Keep adding stances with + Add a stance or keep editing.'; } else if (activeIdx >= 1) { const btn = poses[activeIdx].clipId ? 'Refresh' : 'Generate'; hintHTML = 'Editing pose on 3d viewer
' + 'drag ring to move • knob to rotate

' + 'changing duration
' + 'drag up/down

' + '' + btn + ' when done'; } else hintHTML = 'Tap a move to edit it, or + add the next stance below.'; if (hintHTML) { const hint = document.createElement('div'); hint.className = 'kimodo-cz-hint' + (hintHTML !== _czLastHint ? ' pulse' : ''); hint.innerHTML = hintHTML; topbar.appendChild(hint); _czLastHint = hintHTML; } } root.appendChild(topbar); // "+ Add a stance" at the END of the timeline — only when the kata is fully clean. const appendAdd = () => { if (!editable || hasUnsaved) return; const add = document.createElement('button'); add.type = 'button'; add.id = 'kimodo-cz-addstance'; add.className = 'kimodo-cz-addbtn'; add.textContent = '+ Add a stance'; add.onclick = openStancePicker; root.appendChild(add); }; if (!poses.length) { appendAdd(); return; } const previewMoves = (composePlayer && composePlayer.genCount) || 0; // moves in the loaded preview const body = document.createElement('div'); body.className = 'kimodo-cz-pbody'; const track = document.createElement('div'); track.className = 'kimodo-cz-ptrack'; track.id = 'kimodo-cz-ptrack'; // played-region fill that sweeps DOWN over the generated moves const fill = document.createElement('div'); fill.className = 'kimodo-cz-pfill'; fill.id = 'kimodo-cz-pfill'; if (hasPreview) track.appendChild(fill); // move cards (START → END going down); each card's HEIGHT encodes its duration for (let i = 1; i <= last; i++) track.appendChild(renderTimelineCard(i, last, editable, activeIdx)); body.appendChild(track); // scrubber rail over the generated region = the unified playhead (reused component). // Based on the loaded preview, so it stays put while you edit a (dirty) move. if (hasPreview && previewMoves >= 1) { const rail = document.createElement('div'); rail.className = 'kimodo-cz-prail'; rail.id = 'kimodo-cz-prail'; const railfill = document.createElement('div'); railfill.className = 'kimodo-cz-prailfill'; railfill.id = 'kimodo-cz-prailfill'; const handle = document.createElement('div'); handle.className = 'kimodo-cz-phandle'; handle.id = 'kimodo-cz-phandle'; handle.textContent = '⇕'; rail.appendChild(railfill); rail.appendChild(handle); body.appendChild(rail); wireScrubRail(rail); } root.appendChild(body); appendAdd(); // + Add a stance at the END of the timeline if (hasPreview) { const time = document.createElement('div'); time.className = 'kimodo-cz-ptime'; time.innerHTML = '0:00' + _czTime(composePlayer.numFrames - 1, composePlayer.fps) + ''; root.appendChild(time); requestAnimationFrame(positionScrubber); // size the rail to the generated region after layout } composeJustGenIdx = -1; _czPopUids = null; _czSlideUid = null; _czGenPulseUid = null; _czSelPulseUid = null; // one-shot animations consumed } // A move on the timeline. The ACTIVE (being-edited) move expands to the editor // (start stance on top, end stance on the bottom = drag handle, then GENERATE + // refine + numbers). Every other move is a compact card whose HEIGHT = its duration. function renderTimelineCard(i, last, editable, activeIdx) { const c = composeStrip[i]; const isEnd = (i === last); const pop = _czPopUids && _czPopUids.has(c._uid); const slide = (c._uid === _czSlideUid); const row = document.createElement('div'); row.className = 'kimodo-cz-pcardrow' + (pop ? ' cz-pop' : '') + (i === activeIdx ? ' cz-activerow' : '') + (slide ? ' cz-slidein' : ''); row.dataset.i = i; if (editable && i === activeIdx) { row.appendChild(renderEditCard(i, last)); return row; } const generated = !!(c.clipId && !c.dirty); row.style.height = Math.max(CZ_MINCARD, _czDur(c) * CZ_PXPS) + 'px'; const card = document.createElement('div'); card.className = 'kimodo-cz-tlcard' + (isEnd ? ' end' : '') + (generated ? ' gen' : ' cz-pending') + (composeJustGenIdx === i ? ' cz-justgen' : ''); const node = document.createElement('span'); node.className = 'kimodo-cz-tlnode ' + (isEnd ? 'endn' : 'midn'); card.appendChild(node); const thumb = document.createElement('div'); thumb.className = 'kimodo-cz-tlsthumb'; fillPoseThumb(thumb, c.id); thumb.appendChild(stanceBadge(c.id)); card.appendChild(thumb); const meta = document.createElement('div'); meta.className = 'kimodo-cz-tlmeta'; const nm = document.createElement('div'); nm.className = 'kimodo-cz-tlname'; nm.textContent = (isEnd ? 'END · ' : '') + (c.name || '').replace(/\s*@\d+$/, ''); const durv = document.createElement('div'); durv.className = 'kimodo-cz-tldur'; durv.textContent = _czDur(c).toFixed(1) + 's'; meta.appendChild(nm); meta.appendChild(durv); card.appendChild(meta); if (generated) { const chk = document.createElement('span'); chk.className = 'kimodo-cz-tlcheck'; chk.textContent = '✓'; card.appendChild(chk); } card.onclick = () => selectMove(i); // tap to edit this move row.appendChild(card); return row; } // The ACTIVE move editor: the move's START stance is pinned to the top of the // duration control and its END stance to the bottom — drag the end down to lengthen // the move (the gap = duration). GENERATE sits right under the end stance, then the // collapsed Refine prompt + the move/rotation numbers. function renderEditCard(i, last) { const c = composeStrip[i]; const start = composeStrip[i - 1]; const isEnd = (i === last); const dirty = !!(c.clipId && c.dirty); // was generated, now edited → needs REFRESH const newMove = !c.clipId; // not generated yet → gray let pulseCls = ''; if (c._uid === _czGenPulseUid) pulseCls = ' cz-genpulse'; // gray → purple (just generated) else if (c._uid === _czSelPulseUid) pulseCls = ' cz-selpulse'; // green → purple (just selected) const card = document.createElement('div'); card.className = 'kimodo-cz-editcard' + (newMove ? ' cz-new' : (dirty ? ' cz-dirty' : '')) + pulseCls; // "Move N" label at the top-left so you know which move you're editing. const moveLab = document.createElement('div'); moveLab.className = 'kimodo-cz-emmovelab'; moveLab.textContent = 'Move ' + i; card.appendChild(moveLab); const startShown = !!c._showStart, endShown = (c._showEnd !== false); // end shown by default, start hidden // duration region: start pose (top) → gap → end pose (bottom). The two thumbs TOGGLE // their viewer skeletons (green start / red end) — no text labels. const region = document.createElement('div'); region.className = 'kimodo-cz-emregion'; region.style.height = Math.max(CZ_MINREG, _czDur(c) * CZ_PXPS) + 'px'; const from = document.createElement('div'); from.className = 'kimodo-cz-emfrom'; const fThumb = document.createElement('span'); fThumb.className = 'kimodo-cz-emthumb start' + (startShown ? ' shown' : ''); fThumb.title = 'show / hide the start pose'; fillPoseThumb(fThumb, start.id); fThumb.appendChild(stanceBadge(start.id)); fThumb.onclick = (e) => { e.stopPropagation(); c._showStart = !startShown; sendPlaceVis(i); renderComposePlayer(); }; from.appendChild(fThumb); const durBig = document.createElement('div'); durBig.className = 'kimodo-cz-emdur'; durBig.textContent = _czDur(c).toFixed(1) + 's'; const end = document.createElement('div'); end.className = 'kimodo-cz-emend'; const eThumb = document.createElement('span'); eThumb.className = 'kimodo-cz-emthumb end' + (endShown ? ' shown' : ''); eThumb.title = 'show / hide the end pose'; fillPoseThumb(eThumb, c.id); eThumb.appendChild(stanceBadge(c.id)); eThumb.onclick = (e) => { e.stopPropagation(); c._showEnd = !endShown; sendPlaceVis(i); renderComposePlayer(); }; end.appendChild(eThumb); region.appendChild(from); region.appendChild(durBig); region.appendChild(end); card.appendChild(region); // Delete (✕) at the TOP-RIGHT of the whole move. if (isEnd) { const x = document.createElement('button'); x.type = 'button'; x.textContent = '✕'; x.className = 'kimodo-cz-x kimodo-cz-emx'; x.title = 'remove this move'; x.onpointerdown = (e) => e.stopPropagation(); x.onclick = (e) => { e.stopPropagation(); composeRemove(i); }; card.appendChild(x); } // GENERATE / REFRESH right below the end stance const gen = document.createElement('button'); gen.type = 'button'; gen.className = 'kimodo-cz-genbtn show' + (c.clipId ? ' refresh' : ''); // Refresh = blue const generating = composeBusy && composeGeneratingIdx === i; if (generating) { gen.classList.add('loading'); gen.disabled = true; gen.innerHTML = ''; } // spinner only, no text else { gen.disabled = composeBusy; gen.textContent = c.clipId ? '↻ Refresh' : 'Generate'; } gen.onclick = (e) => { e.stopPropagation(); generateMove(i); }; card.appendChild(gen); // Refine prompt (collapsed) below GENERATE — the prompt text is never shown inline. const refine = document.createElement('details'); refine.className = 'kimodo-cz-refine'; if (c._refineOpen) refine.open = true; const sm = document.createElement('summary'); sm.textContent = 'Refine prompt'; refine.appendChild(sm); const tp = document.createElement('textarea'); tp.className = 'kimodo-cz-tprompt'; tp.value = c.prompt; tp.placeholder = 'Describe the move into this pose…'; tp.oninput = () => { c.prompt = tp.value; markMoveDirty(i, gen); }; refine.appendChild(tp); refine.addEventListener('toggle', () => { c._refineOpen = refine.open; }); card.appendChild(refine); // Move/rotation numbers below the refine toggle. const place = document.createElement('div'); place.className = 'kimodo-cz-placerow'; place.id = 'kimodo-cz-place-' + i; place.innerHTML = czPlaceText(c); card.appendChild(place); // Resize handle (dots) across the BOTTOM of the whole card — drag it (as if resizing // the whole div) to set the move length; the card grows taller as the move lengthens. const handle = document.createElement('div'); handle.className = 'kimodo-cz-emhandle kimodo-cz-cardhandle'; handle.title = 'drag to set the move length'; const dots = document.createElement('span'); dots.className = 'kimodo-cz-dots'; handle.appendChild(dots); card.appendChild(handle); wireDurationDrag(handle, null, region, CZ_MINREG, i, durBig); // resizes the duration region (whole card grows) return card; } // Compact "x 0.0 · y 0.0 · ↻ 0°" readout for a move's 2D placement (no label, to // save vertical/horizontal space on a thin sidebar). function czPlaceText(c) { return 'x ' + (c.tx || 0).toFixed(1) + ' · y ' + (c.tz || 0).toFixed(1) + ' · ↻ ' + Math.round(c.rot || 0) + '°'; } // Select a move: it becomes the active move (controls panel shows below the timeline) // and its END pose is overlaid in the viewer for 3D placement. Re-render so the // controls follow the selection. function selectMove(i) { // Edit one move at a time: if another (generated) move has unsaved edits, confirm, // then revert it to its saved state before switching. const dj = composeStrip.findIndex((c, k) => k >= 1 && c.clipId && c.dirty); if (dj >= 0 && dj !== i) { if (!window.confirm('Discard unsaved changes to the move you were editing?')) return; _czRevert(composeStrip[dj]); saveCurrentKata(); } composeSelMove = i; const sc = composeStrip[i]; // selecting a clean generated move → pulse green→purple if (sc && sc.clipId && !sc.dirty && _czGenPulseUid !== sc._uid) _czSelPulseUid = sc._uid; if (composePlayer) composePlayer.loopMove = null; renderComposePlayer(); sendPlacePose(i); // overlay this move's END pose so it can be moved/rotated in 3D // For a NEW (ungenerated) move, park the figure at this move's START pose — the prior // stance — not the end, so a generate visibly animates start → end. if (sc && !sc.clipId) { if (composePlayer && composePlayer._payload && composePlayer._payload.num_frames > 1) { // Re-load the generated-prefix preview (browsing stances had replaced the viewer // motion) and park at its LAST frame — this move's start pose, at the correct // world position in the kata (not the origin). Paused, so a GENERATE animates // start → end. Use the PAYLOAD's frame count (composePlayer.numFrames may have // been clobbered by a browsed stance) so move 3+ lands at the right pose. composePlayer.numFrames = composePlayer._payload.num_frames; // repair after browsing composePlayer.playing = false; const lf = composePlayer.numFrames - 1; composePlayer.curFrame = lf; _czViewerHasPreview = true; loadIntoViewer(composePlayer._payload, '', (composeCurrentKata && composeCurrentKata.label) || 'kata', { keepCamera: true }); _czTransport('pause'); // don't let the reloaded preview drift before we seek setTimeout(() => { _czTransport('seek', lf); updatePlayerProgress(lf, false); }, 160); } else { // First move (no generated prefix yet): load the start stance (index i-1) at origin. const ps = composeStrip[i - 1]; if (ps) previewComposeStance(ps.id, _czName(ps)); } } } // Push the move's END stance pose + current placement to the viewer's place mode. // Per-stance world transforms {x,z,heading} from the stitched preview, so a move's // place overlay can render at the PREVIOUS pose's accumulated position (not the origin). function _czBoundaries(m) { const P = m && m.posed_joints, R = m && m.root_positions; const nf = (m && m.num_frames) || (P ? P.length : 0); const pf = posefracFromStrip(); return pf.map((frac) => { const f = Math.max(0, Math.min(nf - 1, Math.round(frac * (nf - 1)))); const head = (P && P[f]) ? poseHeading(P[f]) : 0; return { x: (R && R[f]) ? R[f][0] : 0, z: (R && R[f]) ? R[f][2] : 0, heading: head }; }); } // Toggle the start/end pose skeletons in the viewer (driven by the edit-card thumbs). function sendPlaceVis(i) { const c = composeStrip[i]; if (!c) return; const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'place-vis', start: !!c._showStart, end: c._showEnd !== false }, '*'); } async function sendPlacePose(i) { const c = composeStrip[i]; if (!c) return; try { const rec = await fetchRecord(c.id); const B = canonKeyframe(rec.posed_joints[0], rec.global_quats_xyzw[0]); // The move's START pose (previous stance) — shown as a green skeleton (hidden by default). let startPosed = null; const sc = composeStrip[i - 1]; if (sc) { try { const rs = await fetchRecord(sc.id); startPosed = canonKeyframe(rs.posed_joints[0], rs.global_quats_xyzw[0]).posed; } catch (e) {} } // World transform at the START of move i (= end of move i-1 in the stitched kata), // so the overlay lands where the move actually ends, not at the origin. const base = (composePlayer && composePlayer.boundaries && composePlayer.boundaries[i - 1]) || { x: 0, z: 0, heading: 0 }; const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'place-pose', posed: B.posed, startPosed, parents: SMPLX22_PARENTS, tx: c.tx || 0, tz: c.tz || 0, rot: c.rot || 0, base, startVis: !!c._showStart, endVis: c._showEnd !== false }, '*'); } catch (e) { console.error('place-pose failed', e); } } // Exit 3D placement (clear the overlay + selection). function clearPlaceMode() { composeSelMove = -1; const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'place-clear' }, '*'); } // The viewer reports the overlaid figure moved/rotated → update the move + readout. function composeOnPlaceUpdate(msg) { if (composeSelMove < 0) return; const c = composeStrip[composeSelMove]; if (!c) return; c.tx = msg.tx || 0; c.tz = msg.tz || 0; c.rot = msg.rot || 0; const el = document.getElementById('kimodo-cz-place-' + composeSelMove); if (el) el.innerHTML = czPlaceText(c); // live update, no full re-render if (c.clipId && !c.dirty) { // edited a generated move → dirty (color), keep the preview c.dirty = true; const card = el && el.closest('.kimodo-cz-editcard'); if (card) card.classList.add('cz-dirty'); } } // First edit of a generated move → mark dirty + slide the GENERATE button in + // drop the (now-stale) preview from this move onward. function markMoveDirty(i, genBtn) { const c = composeStrip[i]; if (c.clipId && !c.dirty) { // Mark dirty for the color + REFRESH affordance, but KEEP the existing preview // (and its scrubber) — it only refreshes when the user presses Refresh. Update // inline (no re-render) so an in-progress prompt edit keeps focus. c.dirty = true; if (genBtn) genBtn.classList.add('show'); const card = document.querySelector('#kimodo-cz-ptrack .cz-activerow .kimodo-cz-editcard'); if (card) card.classList.add('cz-dirty'); } } // Size + position the rail to cover ONLY the generated region of the track (the // bottom portion, from the track bottom up to the top of the topmost generated card). function positionScrubber() { const track = document.getElementById('kimodo-cz-ptrack'); const rail = document.getElementById('kimodo-cz-prail'); if (!track || !composePlayer) return; // The rail spans the GENERATED region only, starting just below the START marker. const startRow = track.querySelector('.kimodo-cz-tlstart'); const top = startRow ? (startRow.offsetTop + startRow.offsetHeight) : 0; const genPx = Math.max(8, _czGenPx()); if (rail) { rail.style.top = top + 'px'; rail.style.height = genPx + 'px'; } const fill = document.getElementById('kimodo-cz-pfill'); if (fill) fill.style.top = top + 'px'; updatePlayerProgress(composePlayer.curFrame || 0, composePlayer.playing); } // Live-scrub: dragging the rail pauses + seeks the viewer. The rail spans the // generated region top→bottom, so y maps straight to playback frac. function wireScrubRail(rail) { const seekFromY = (clientY) => { const r = rail.getBoundingClientRect(); const frac = Math.max(0, Math.min(1, (clientY - r.top) / Math.max(1, r.height))); const fr = Math.round(frac * ((composePlayer.numFrames || 1) - 1)); composePlayer.playing = false; _czTransport('seek', fr); updatePlayerProgress(fr, false); const pp = document.getElementById('kimodo-cz-pplay'); if (pp) pp.textContent = '▶'; }; let dragging = false; rail.addEventListener('pointerdown', (e) => { dragging = true; try { rail.setPointerCapture(e.pointerId); } catch (x) {} seekFromY(e.clientY); e.preventDefault(); }); rail.addEventListener('pointermove', (e) => { if (dragging) { seekFromY(e.clientY); e.preventDefault(); } }); const end = () => { dragging = false; }; rail.addEventListener('pointerup', end); rail.addEventListener('pointercancel', end); } // Move the fill + handle + current-card highlight + time to `frame`. Timeline flows // top→bottom (START at top), so the played fraction sweeps DOWNWARD. function updatePlayerProgress(frame, playing) { const p = composePlayer; if (!p) return; p.curFrame = frame; if (playing != null) p.playing = playing; const frac = Math.max(0, Math.min(1, frame / Math.max(1, (p.numFrames - 1)))); const rail = document.getElementById('kimodo-cz-prail'); const fill = document.getElementById('kimodo-cz-pfill'); const railfill = document.getElementById('kimodo-cz-prailfill'); const handle = document.getElementById('kimodo-cz-phandle'); const regH = rail ? rail.clientHeight : 0; // rail == generated region if (fill) fill.style.height = Math.round(frac * regH) + 'px'; // sweeps down from the first move if (rail && handle) { handle.style.top = Math.round(frac * regH) + 'px'; if (railfill) railfill.style.height = Math.round(frac * regH) + 'px'; } // current card = the move being played INTO (first whose reach-frac >= progress) let curI = (p.poseFrac && p.poseFrac.length) ? p.poseFrac.findIndex((f) => f >= frac - 1e-4) : -1; if (curI < 0) curI = (p.poseFrac && p.poseFrac.length) ? p.poseFrac.length - 1 : 0; document.querySelectorAll('#kimodo-cz-ptrack .kimodo-cz-pcardrow').forEach((row) => row.classList.toggle('cur', Number(row.dataset.i) === curI)); const cur = document.getElementById('kimodo-cz-pcur'); if (cur) cur.textContent = _czTime(frame, p.fps); const pp = document.getElementById('kimodo-cz-pplay'); if (pp) pp.textContent = p.playing ? '❚❚' : '▶'; } // Driven by the viewer's per-frame messages while the player view is open. function composeOnFrame(msg) { if (!composePlayer || composeView !== 'player' || !msg) return; // Only trust frame counts while the viewer holds the KATA preview — a browsed single // stance must not overwrite the kata's length (it would mis-place the seek/scrubber). if (!_czViewerHasPreview) return; if (typeof msg.num_frames === 'number' && msg.num_frames > 1) composePlayer.numFrames = msg.num_frames; updatePlayerProgress(Math.round(msg.frame || 0), !!msg.playing); } function deleteKata(k) { if (!window.confirm('Delete this kata?')) return; if (k.local) { // Mine entry — drop the localStorage record (+ server clips if known) saveMyKatas(loadMyKatas().filter((e) => e.lid !== k.lid)); if (k.root) { const did = document.querySelector('#del-id textarea, #del-id input'); const dbtn = document.querySelector('#del-btn'); if (did && dbtn) { did.value = k.root; did.dispatchEvent(new Event('input', { bubbles: true })); dbtn.click(); } } } else { const did = document.querySelector('#del-id textarea, #del-id input'); const dbtn = document.querySelector('#del-btn'); for (const id of (k.ids || [k.root])) { if (did && dbtn) { did.value = id; did.dispatchEvent(new Event('input', { bubbles: true })); dbtn.click(); } } composeKatas = composeKatas.filter((x) => x.root !== k.root); } renderComposeLibrary(); setComposeStatus('deleted “' + k.label + '”'); } function renderComposePalette() { const pal = document.getElementById('kimodo-compose-palette'); if (!pal) return; pal.innerHTML = ''; const fEl = document.getElementById('kimodo-cz-pfilter'); const q = (fEl && fEl.value || '').trim().toLowerCase(); const list = q ? composeStances.filter((m) => _czName(m).toLowerCase().includes(q) || (m.stance_tags || []).join(' ').toLowerCase().includes(q)) : composeStances; // Anchored hint above the (scrolling) grid: two-tap prompt for the first move, // else a plain add-the-next-stance prompt. const startPhase = (composeStrip.length === 0 && !_czFirstPick); // picking START (green) vs END (red) const hintEl = document.getElementById('kimodo-cz-pickhint'); if (hintEl) { const cl = startPhase ? 's' : 'e'; // color only the ✓ and the start/end word hintEl.innerHTML = _czTentative ? ('Tap the stance to confirm') : ('Tap a stance to set ' + (startPhase ? 'a start pose' : 'the end pose')); } if (!composeStances.length) { pal.innerHTML = '
no stances in this dataset
'; return; } if (!list.length) { pal.innerHTML = '
no stances match “' + q.replace(/'; return; } for (const m of list) { const card = document.createElement('div'); const isStartPick = _czFirstPick && m.id === _czFirstPick.id; // confirmed START (during END phase) const isTentative = _czTentative && m.id === _czTentative.id; // tapped to preview, awaiting confirm card.className = 'kimodo-cz-pcard' + (isStartPick ? ' cz-startpick' : (isTentative ? (' cz-tentative ' + (startPhase ? 'tent-start' : 'tent-end')) : (m.id === composeSelId ? ' sel' : ''))); card.title = _czName(m); const thumb = document.createElement('div'); thumb.className = 'kimodo-cz-pthumb'; fillPoseThumb(thumb, m.id); thumb.appendChild(stanceBadge(m.id)); if (isStartPick) { const tg = document.createElement('span'); tg.className = 'kimodo-cz-picktag'; tg.textContent = 'START'; card.appendChild(tg); } if (isTentative) { const ck = document.createElement('span'); ck.className = 'kimodo-cz-pcheck'; ck.textContent = '✓'; card.appendChild(ck); } // big ✓ over the whole card = confirm const nm = document.createElement('div'); nm.className = 'kimodo-cz-pname'; nm.textContent = _czName(m); const add = document.createElement('button'); add.type = 'button'; add.textContent = '+'; add.title = 'add to storyboard'; add.className = 'kimodo-cz-padd'; add.onclick = (e) => { e.stopPropagation(); composeAdd(m); }; // In the picker, tapping a stance selects it (two-tap for the first move). card.onclick = () => composeAdd(m); card.appendChild(thumb); card.appendChild(nm); card.appendChild(add); pal.appendChild(card); } } function _czStanceEntry(m, isStart) { return { id: m.id, name: _czName(m), prompt: isStart ? '' : _czDefaultPrompt(_czName(m)), tx: 0, tz: 0, rot: 0, seconds: 2.2, clipId: null, dirty: true, expanded: true, _uid: ++_czUid }; } // Bring the active (being-edited) move — the newly added one at the bottom — into view. function _czScrollToActive() { setTimeout(() => { const el = document.querySelector('#kimodo-cz-ptrack .cz-activerow') || document.getElementById('kimodo-cz-addstance'); if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 80); } // Restart the picker hint's pulse so a changed instruction grabs attention. function _czPulsePickHint() { const ph = document.getElementById('kimodo-cz-pickhint'); if (!ph) return; ph.classList.remove('pulse'); void ph.offsetWidth; ph.classList.add('pulse'); } // Tap a stance once = preview it (a ✓ marks it); tap the SAME one again = confirm. So // you can browse through the stances before committing. The first move needs TWO // confirmations (START then END); later moves need one. function _czTentativeTap(m) { if (!_czTentative || _czTentative.id !== m.id) { // browse: move the ✓ + preview _czTentative = m; renderComposePalette(); previewComposeStance(m.id, _czName(m)); return false; // not confirmed } return true; // tapped the ✓'d one again → confirm } function composeAdd(m) { if (composeStrip.length === 0) { // FIRST MOVE: confirm a START, then confirm an END. if (!_czFirstPick) { if (!_czTentativeTap(m)) return; _czFirstPick = m; _czTentative = null; // START confirmed renderComposePalette(); _czPulsePickHint(); previewComposeStance(m.id, _czName(m)); return; } // (the END may equal the START — a hold/return move is allowed) if (!_czTentativeTap(m)) return; composeStrip.push(_czStanceEntry(_czFirstPick, true)); // START (index 0) const endEntry = _czStanceEntry(m, false); // END (index 1) = move 1 composeStrip.push(endEntry); _czFirstPick = null; _czTentative = null; closeStancePicker(); _czPopUids = new Set([endEntry._uid]); _czSlideUid = endEntry._uid; // slide the first move in from the top renderComposePlayer(); selectMove(1); _czScrollToActive(); // select move 1 + scroll it into view return; } // Subsequent moves: confirm one stance (the new END), chained from the previous. if (!_czTentativeTap(m)) return; const entry = _czStanceEntry(m, false); composeStrip.push(entry); _czTentative = null; closeStancePicker(); _czPopUids = new Set([entry._uid]); _czSlideUid = entry._uid; // slide the new move in from the top renderComposePlayer(); selectMove(composeStrip.length - 1); _czScrollToActive(); } function composeRemove(i) { if (!window.confirm('Remove this stance from the kata?')) return; const drop = () => { composeStrip.splice(i, 1); // A lone START stance (no move) is meaningless → clear back to empty so the kata // returns to the "tap to add" state. if (composeStrip.length === 1) composeStrip = []; composeSelMove = -1; saveCurrentKata(); updateKataPreview(); renderComposePlayer(); }; const track = document.getElementById('kimodo-cz-ptrack'); const row = track && track.querySelector('.kimodo-cz-pcardrow[data-i="' + i + '"]'); const blk = row && row.nextElementSibling && row.nextElementSibling.classList.contains('kimodo-cz-mblock') ? row.nextElementSibling : null; if (row) { // shrink the card (and its move block) away, then drop [row, blk].forEach((el) => { if (!el) return; el.style.height = el.offsetHeight + 'px'; void el.offsetHeight; el.classList.add('cz-removing'); el.style.height = '0px'; }); setTimeout(drop, 240); } else drop(); } // The old separate "create strip" + global Build button are superseded by the // unified kata view with per-move GENERATE; keep thin shims so any caller works. function renderComposeStrip() { renderComposePlayer(); } function updateComposeLaunch() { const btn = document.getElementById('kimodo-compose-launch'); if (btn) btn.classList.remove('show'); } // Build the canon'd keyframe pair list shared by both paths. async function composeKeyframes() { const kfs = []; for (const c of composeStrip) { const rec = await fetchRecord(c.id); kfs.push(canonKeyframe(rec.posed_joints[0], rec.global_quats_xyzw[0])); } const moves = []; for (let i = 1; i < composeStrip.length; i++) { const c = composeStrip[i], A = kfs[i-1], B0 = kfs[i]; const B = placePose(B0.posed, B0.quats, c.rot || 0, c.tx || 0, c.tz || 0); moves.push({ prompt: (c.prompt || _czDefaultPrompt(c.name)).trim(), seconds: c.seconds || 2.2, keyframes: [ { posed_joints: A.posed, global_quats_xyzw: A.quats, frac: 0.0, turn_deg: 0, advance: 0 }, { posed_joints: B.posed, global_quats_xyzw: B.quats, frac: 1.0, turn_deg: 0, advance: 0 }, ], }); } return moves; } // Compact, editable recipe (stance ids + transition prompts/dials) stored on the // kata root so MY KARATE can list + reopen it. Excludes the heavy pose arrays. function composeRecipe() { return composeStrip.map((c) => ({ id: c.id, name: c.name, prompt: c.prompt || '', seconds: c.seconds || 2.2, tx: c.tx || 0, tz: c.tz || 0, rot: c.rot || 0 })); } function myAnonId() { try { return window.localStorage.getItem('kimodoAnonymousClientId') || ''; } catch (e) { return ''; } } // "Mine" is tracked CLIENT-SIDE in localStorage at build time — robust against // manifest propagation lag, missing compose_owner, and fileless clip records. // Each entry keeps the editable recipe, so the card + filmstrip render from the // STANCE ids (which reliably resolve) without any per-clip fetch. function loadMyKatas() { try { return JSON.parse(window.localStorage.getItem('kimodoMyKatas') || '[]'); } catch (e) { return []; } } function saveMyKatas(a) { try { window.localStorage.setItem('kimodoMyKatas', JSON.stringify(a.slice(0, 80))); } catch (e) {} } function recordMyKata(recipe, ids) { if (!recipe || !recipe.length) return; ids = Array.isArray(ids) ? ids : (ids ? [ids] : null); const a = loadMyKatas(); a.unshift({ lid: 'k' + Date.now().toString(36), name: recipeLabel(recipe), recipe, ids: ids, root: (ids && ids[0]) || null, ts: Date.now() }); saveMyKatas(a); } const composeCanBuild = () => !!(KIMODO_BACKEND || document.querySelector('#compose-btn')); let composePending = false; function composeBuild() { if (composeStrip.length < 2 || composeBusy || !composeCanBuild()) return; return KIMODO_BACKEND ? composeBuildRemote() : composeBuildInProcess(); } // Direct-backend path (local eval): /generate_between + /stitch_path over HTTP, // with post_processing → 0-drift seams. Plays in-place via loadIntoViewer. async function composeBuildRemote() { composeBusy = true; renderComposeStrip(); setComposeStatus('building storyboard…'); try { const moves = await composeKeyframes(); let prev = null; const ids = []; for (let i = 0; i < moves.length; i++) { setComposeStatus('generating move ' + (i+1) + '/' + moves.length + '…'); const body = Object.assign({ num_steps: 60, post_processing: true, continues_from_id: prev }, moves[i]); const r = await fetch(KIMODO_BACKEND + '/generate_between', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!r.ok) throw new Error('move ' + (i+1) + ': ' + r.status + ' ' + (await r.text()).slice(0, 120)); const j = await r.json(); ids.push(j.id); prev = j.id; } setComposeStatus('stitching…'); const sr = await fetch(KIMODO_BACKEND + '/stitch_path', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, save: false }) }); if (!sr.ok) throw new Error('stitch ' + sr.status); const m = await sr.json(); m.num_joints = (m.posed_joints && m.posed_joints[0]) ? m.posed_joints[0].length : 22; if (!m.bone_names || !m.bone_names.length) m.bone_names = SMPLX22_BONES; m.parents = SMPLX22_PARENTS; loadIntoViewer(m, '', 'storyboard · ' + ids.length + ' moves'); composeStrip.forEach((c) => { c.built = true; }); // mark all moves "done" recordMyKata(composeRecipe(), ids); // remote path knows the full spine setComposeStatus('▶ built ' + ids.length + ' move' + (ids.length === 1 ? '' : 's') + ' · ' + m.num_frames + 'f'); setComposeView('library'); // show it under Mine } catch (e) { console.error(e); setComposeStatus('build failed: ' + e.message); } finally { composeBusy = false; renderComposeStrip(); } } // In-process path (deployed Space): hand the move list to the hidden gradio // #compose-btn, which runs generate_between + stitch on the @GPU model and // re-renders the viewer. Completion arrives via the viewer's preview-ready event. async function composeBuildInProcess() { const ta = document.querySelector('#compose-payload textarea, #compose-payload input'); const btn = document.querySelector('#compose-btn'); if (!ta || !btn) { setComposeStatus('compose bridge not found'); return; } composeBusy = true; renderComposeStrip(); setComposeStatus('building ' + (composeStrip.length - 1) + ' moves on the Space (this can take a while)…'); try { const moves = await composeKeyframes(); ta.value = JSON.stringify({ moves, recipe: composeRecipe(), owner: myAnonId() }); ta.dispatchEvent(new Event('input', { bubbles: true })); composePending = true; setTimeout(() => btn.click(), 50); // safety: clear the busy state if no preview-ready arrives. setTimeout(() => { if (composePending) { composePending = false; composeBusy = false; renderComposeStrip(); setComposeStatus('still working… (check the viewer)'); } }, 600000); } catch (e) { composeBusy = false; renderComposeStrip(); setComposeStatus('build failed: ' + e.message); } } // In-process build completion arrives via a gradio .then() bridge carrying the // build meta (incl. kata_ids), so we record the kata with REPLAYABLE spine ids. window.__kimodoOnComposeBuilt = function (meta) { if (!composePending) return; composePending = false; composeBusy = false; composeGeneratingIdx = -1; const ok = meta && meta.kata_root; if (ok) { const ids = meta.kata_ids || [meta.kata_root]; for (let i = 1; i < composeStrip.length && i - 1 < ids.length; i++) { composeStrip[i].clipId = ids[i - 1]; composeStrip[i].dirty = false; composeStrip[i].expanded = false; _czSnapshot(composeStrip[i]); } if (meta.move_index) { composeJustGenIdx = meta.move_index; clearPlaceMode(); } // per-move bridge: highlight the move just built saveCurrentKata(); const builtIdx = meta.move_index || (composeStrip.length - 1); // Refresh via the SAME clean path as re-selecting the kata from the library — which // the user confirmed correctly extends the timeline and ALSO doesn't reload the 3D // model (it postMessages the motion in). We hand it the server-stitched preview so it // neither reloads the iframe nor re-fetches the just-saved (CDN-lagging) clips, and // keepCam=true so the camera doesn't jump on every move. // meta comes from a gr.JSON output, which Gradio (Svelte 5) hands over as a reactive // PROXY. postMessage()'s structured clone can't clone a proxy ("[object Array] could // not be cloned"), so deep-copy the preview payload to a plain object first. let _sp = null; try { _sp = meta.preview_payload ? JSON.parse(JSON.stringify(meta.preview_payload)) : null; } catch (e) { _sp = null; } if (_sp) { playKata(composeCurrentKata, _sp, true).then(() => setComposeStatus('▶ generated [b18 · moves ' + (composePlayer ? composePlayer.genCount : '?') + ' · frames ' + (composePlayer ? composePlayer.numFrames : '?') + ']')); } else { updateKataPreview(builtIdx, null).then(() => { if (builtIdx >= 1 && builtIdx < composeStrip.length) { if (composeStrip[builtIdx]) _czGenPulseUid = composeStrip[builtIdx]._uid; selectMove(builtIdx); } }); setComposeStatus('▶ generated [b18 · fallback]'); } } else { renderComposePlayer(); setComposeStatus((meta && meta.error) ? ('generate failed: ' + meta.error) : 'done'); } }; // Fallback: if the bridge never fires, clear the busy state on viewer reload. window.addEventListener('message', (e) => { if ((e.data || {}).kind === 'kimodo-preview-ready' && composePending) { setTimeout(() => { if (composePending) { composePending = false; composeBusy = false; renderComposeStrip(); } }, 3000); } }); (function wireCompose() { const launch = document.getElementById('kimodo-compose-launch'); if (launch) launch.addEventListener('click', composeBuild); const scope = document.getElementById('kimodo-cz-scope'); if (scope) scope.addEventListener('click', (e) => { const b = e.target.closest('.kimodo-cz-segbtn'); if (b) { composeScope = b.dataset.scope; renderComposeLibrary(); } }); const back = document.getElementById('kimodo-cz-createback'); if (back) back.addEventListener('click', () => { composeCurrentKata = null; composeEditing = false; setComposeView('library'); }); const addst = document.getElementById('kimodo-cz-addstance'); if (addst) addst.addEventListener('click', openStancePicker); const pclose = document.getElementById('kimodo-cz-pickerclose'); if (pclose) pclose.addEventListener('click', closeStancePicker); const vedit = document.getElementById('kimodo-cz-vname-edit'); if (vedit) vedit.addEventListener('click', startRenameKata); const vtxt = document.getElementById('kimodo-cz-vname-text'); if (vtxt) { vtxt.style.cursor = 'text'; vtxt.addEventListener('click', startRenameKata); } const pfilter = document.getElementById('kimodo-cz-pfilter'); if (pfilter) pfilter.addEventListener('input', renderComposePalette); window.addEventListener('resize', updateComposeViewerName); })(); // --- Bottom-right HF account widget (login / account drawer). --- (function () { function acctInfo() { const el = inputInside('current-user-info'); if (!el || !el.value) return { username: '', name: '', avatar: '' }; try { return JSON.parse(el.value) || {}; } catch (e) { return { username: '', name: '', avatar: '' }; } } function triggerHfLogin() { // HF Spaces run inside a huggingface.co iframe. iOS/Safari block THIRD-PARTY cookies // there, so the OAuth session cookie never sticks and login silently fails (HF's own // docs say to run the flow outside the iframe). Gradio also defers its navigation in a // 500ms setTimeout, which iOS blocks as non-user-initiated. So: navigate SYNCHRONOUSLY // within this tap, and when we're framed, break OUT to the Space's own origin so the // whole OAuth flow is first-party. const loggedIn = !!acctInfo().username; const route = (loggedIn ? '/logout' : '/login/huggingface') + '?_target_url=/'; let framed = true; try { framed = window.self !== window.top; } catch (e) { framed = true; } const host = (window.__kimodoSpaceHost || '').replace(/^https?:\/\//, '').replace(/\/+$/, ''); // Only SAFARI/iOS blocks third-party cookies in an iframe (ITP), so only there must we // leave huggingface.co. Other browsers keep the cookie in-frame, so they stay on the // HF site (gradio self-heals to the host if a browser does block them). const ua = navigator.userAgent || ''; const appleWebkit = /iP(hone|ad|od)/.test(ua) || (/Safari/.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|Android|OPR)/.test(ua)); if (framed && host && appleWebkit) { const url = 'https://' + host + route; try { window.top.location.href = url; return; } catch (e) {} // break the tab out of the iframe (first-party OAuth) try { window.open(url, '_blank'); return; } catch (e) {} // fallback: new first-party tab } try { window.location.assign(route); return; } catch (e) {} // in-frame (HF site) / top-level (.hf.space / local) const b = document.querySelector('#kimodo-login a, #kimodo-login button, #kimodo-login'); if (b) b.click(); // last-resort fallback } function updateAcct() { const btn = document.getElementById('kimodo-acct-btn'); const drawer = document.getElementById('kimodo-acct-drawer'); if (!btn) return; const info = acctInfo(); const loggedIn = !!info.username; btn.textContent = loggedIn ? '☰' : '🤗'; // ☰ : 🤗 btn.title = loggedIn ? 'Account' : 'Sign in with Hugging Face'; if (loggedIn && drawer) { const av = document.getElementById('kimodo-acct-avatar'); const nm = document.getElementById('kimodo-acct-name'); const us = document.getElementById('kimodo-acct-user'); if (av) { if (info.avatar) { av.src = info.avatar; av.style.display = ''; } else { av.style.display = 'none'; } } if (nm) nm.textContent = info.name || info.username; if (us) us.textContent = '@' + info.username; } if (!loggedIn && drawer) drawer.classList.remove('open'); } function wireAcct() { const btn = document.getElementById('kimodo-acct-btn'); const drawer = document.getElementById('kimodo-acct-drawer'); if (!btn || btn.dataset.ready === '1') return; btn.dataset.ready = '1'; btn.addEventListener('click', () => { if (acctInfo().username) { if (drawer) drawer.classList.toggle('open'); } else triggerHfLogin(); }); const close = document.getElementById('kimodo-acct-close'); if (close) close.addEventListener('click', () => { if (drawer) drawer.classList.remove('open'); }); const logout = document.getElementById('kimodo-acct-logout'); if (logout) logout.addEventListener('click', triggerHfLogin); // LoginButton is "Sign out" when logged in const tut = document.getElementById('kimodo-acct-tutorial'); if (tut) tut.addEventListener('click', () => { // Re-arm the tutorial (the WHO AM I tab listens and re-pulses) and close the drawer. window.dispatchEvent(new CustomEvent('kimodo-tutorial-restart')); if (drawer) drawer.classList.remove('open'); }); } // #current-user-info is populated by demo.load after hydration — sync a few times. let tries = 0; const timer = setInterval(() => { wireAcct(); updateAcct(); if (++tries > 25) clearInterval(timer); }, 600); wireAcct(); updateAcct(); })(); // --- Pluggable tabs (tabs/*.tab.js) — run in this same scope, after the helpers. --- (function () { let busy = false; let dojoMode = 'splat'; let lastScene = null; let dojoScope = 'mine'; let improvedByAya = false; // current prompt came from tiny-aya → button reads "reroll" let improving = false; // a tiny-aya suggestion call is in flight let _communityCache = null; // canonical + contributed dojos fetched from the dataset let _lastDojo = null; // { scene, cfg } of the just-generated dojo (for "Save to dataset") // Community starters — tapping one opens the create view prefilled, so generating // produces a real flux + splat scene (which then lands under "Mine"). const COMMUNITY_SEED = [ { name: 'Classic dojo', mode: 'splat', settings: { room_size: 6.5, room_height: 3.2 }, prompt: 'A traditional martial arts dojo room, polished wooden floor, training mats, wooden wall panels, warm lantern light, full room visible, high detail, no people, no text.' }, { name: 'Mountain dojo', mode: 'splat', settings: { room_size: 7.5, room_height: 4 }, prompt: 'An outdoor mountain dojo courtyard at sunrise, stone tiles, wooden gates, misty trees, full environment visible, high detail, no people, no text.' }, { name: 'Neon dojo', mode: 'splat', settings: { room_size: 6.5, room_height: 3.2 }, prompt: 'A futuristic neon martial arts training room, glossy floor, holographic wall panels, dramatic rim light, full room visible, no people, no text.' } ]; function dojoEls() { return { library: document.getElementById('kimodo-dojo-library'), create: document.getElementById('kimodo-dojo-create'), scope: document.getElementById('kimodo-dojo-scope'), add: document.getElementById('kimodo-dojo-add'), back: document.getElementById('kimodo-dojo-back'), grid: document.getElementById('kimodo-dojo-grid'), emptyMsg: document.getElementById('kimodo-dojo-empty'), improve: document.getElementById('kimodo-dojo-improve'), save: document.getElementById('kimodo-dojo-save'), erase: document.getElementById('kimodo-dojo-erase'), prompt: document.getElementById('kimodo-dojo-prompt'), mode: document.getElementById('kimodo-dojo-mode'), roomSize: document.getElementById('kimodo-dojo-room-size'), roomSizeValue: document.getElementById('kimodo-dojo-room-size-value'), roomHeight: document.getElementById('kimodo-dojo-room-height'), roomHeightValue: document.getElementById('kimodo-dojo-room-height-value'), skybox: document.getElementById('kimodo-dojo-skybox'), seed: document.getElementById('kimodo-dojo-seed'), steps: document.getElementById('kimodo-dojo-steps'), generate: document.getElementById('kimodo-dojo-generate'), clear: document.getElementById('kimodo-dojo-clear'), status: document.getElementById('kimodo-dojo-status'), stage: document.getElementById('kimodo-dojo-stage'), iso: document.getElementById('kimodo-dojo-iso'), isoImg: document.getElementById('kimodo-dojo-iso-img') }; } function setStatus(text) { const els = dojoEls(); if (els.status) els.status.textContent = text; } function formatSeconds(value) { const seconds = Math.max(0, Math.round(Number(value) || 0)); const minutes = Math.floor(seconds / 60); const rest = seconds % 60; if (minutes <= 0) return rest + 's'; return minutes + 'm ' + String(rest).padStart(2, '0') + 's'; } function stageLabel(stage) { if (/^flux/.test(stage || '')) return 'Flux'; if (/^splat/.test(stage || '')) return 'TripoSplat'; if (/^panorama/.test(stage || '')) return 'Panorama'; if (/^viewer/.test(stage || '')) return 'Viewer'; return 'Dojo'; } function bestProgressText(stage) { if (!stage) return ''; const progress = Array.isArray(stage.progress) ? stage.progress : []; for (let i = progress.length - 1; i >= 0; i -= 1) { if (!progress[i]) continue; const item = progress[i]; const bits = []; if (item.desc) bits.push(item.desc); if (Number.isFinite(Number(item.progress))) bits.push(Math.round(Number(item.progress) * 100) + '%'); if (item.index != null && item.length != null) bits.push(item.index + '/' + item.length + (item.unit ? ' ' + item.unit : '')); if (bits.length) return bits.join(' - '); } if (stage.progress_desc) return stage.progress_desc; return ''; } function progressFraction(stage) { const progress = Array.isArray(stage && stage.progress) ? stage.progress : []; for (let i = progress.length - 1; i >= 0; i -= 1) { const item = progress[i] || {}; const direct = Number(item.progress); if (Number.isFinite(direct) && direct > 0 && direct <= 1) return direct; const index = Number(item.index); const length = Number(item.length); if (Number.isFinite(index) && Number.isFinite(length) && index > 0 && length > 0) return Math.min(1, index / length); } return null; } function setTiming(stage) { const els = dojoEls(); if (!els.time || !stage) return; const parts = []; const fraction = progressFraction(stage); const estimatedRemaining = stage.eta == null && fraction && stage.elapsed ? (Number(stage.elapsed) * (1 - fraction)) / fraction : null; if (stage.elapsed != null) parts.push('elapsed ' + formatSeconds(stage.elapsed)); if (stage.eta != null) parts.push('remaining ' + formatSeconds(stage.eta)); else if (estimatedRemaining != null) parts.push('remaining ~' + formatSeconds(estimatedRemaining)); if (stage.rank != null && stage.rank > 0) parts.push('queue #' + stage.rank); if (stage.queue_size != null && stage.queue_size > 0) parts.push(stage.queue_size + ' waiting'); if (stage.job_status) parts.push(String(stage.job_status).toLowerCase()); els.time.textContent = parts.length ? stageLabel(stage.stage) + ': ' + parts.join(' - ') : ''; if (els.detail) els.detail.textContent = bestProgressText(stage); } function hiddenInput(id) { return inputInside(id); } function writeHidden(id, value) { const input = hiddenInput(id); if (!input) return false; input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); return true; } function readHidden(id) { const input = hiddenInput(id); return input ? input.value || '' : ''; } function roomSettings() { const els = dojoEls(); return { room_size: Math.max(1, Number(els.roomSize && els.roomSize.value) || 6.5), room_height: Math.max(1, Number(els.roomHeight && els.roomHeight.value) || 3.2), use_panorama_skybox: !!(els.skybox && els.skybox.checked) }; } function sendScene(scene) { const frame = previewFrame(); if (!scene) return; const payload = { ...scene, ...roomSettings(), render_mode: dojoMode }; payload.use_panorama_skybox = !!(dojoEls().skybox && dojoEls().skybox.checked); if (!payload.image && payload.panorama_image) payload.image = payload.panorama_image; if (payload.render_mode === 'panorama' && !payload.image && payload.source_image) payload.image = payload.source_image; if (frame && frame.contentWindow) frame.contentWindow.postMessage({ kind: 'dojo-scene', scene: payload, image: payload.image }, '*'); } var CHAR_HEIGHT_M = 1.7; // character ≈ SMPLX_HEIGHT — room size is shown relative to it function updateRoomLabels() { const els = dojoEls(); const settings = roomSettings(); if (els.roomSizeValue) els.roomSizeValue.textContent = settings.room_size.toFixed(1); if (els.roomHeightValue) els.roomHeightValue.textContent = settings.room_height.toFixed(1); const ratio = document.getElementById('kimodo-dojo-room-ratio'); if (ratio) ratio.textContent = (settings.room_size / CHAR_HEIGHT_M).toFixed(1); } function sendRoomSettings() { const frame = previewFrame(); const settings = roomSettings(); if (lastScene) Object.assign(lastScene, settings); if (frame && frame.contentWindow) frame.contentWindow.postMessage({ kind: 'dojo-room-settings', settings }, '*'); } // ---- Generation stage: iso image loads in, then a 3D-conversion scan ---- function stageReset() { const els = dojoEls(); if (els.stage) els.stage.hidden = true; if (els.iso) els.iso.classList.remove('generating', 'has-img', 'converting'); if (els.isoImg) els.isoImg.removeAttribute('src'); if (els.save) els.save.hidden = true; _lastDojo = null; } function stageGenerating() { // iso not ready yet → shimmer placeholder const els = dojoEls(); if (els.stage) els.stage.hidden = false; if (els.iso) { els.iso.classList.add('generating'); els.iso.classList.remove('has-img', 'converting'); } } function stageIsoReady(src) { // iso image arrived → fade/deblur it in const els = dojoEls(); if (!src || !els.iso || !els.isoImg) return; if (els.stage) els.stage.hidden = false; els.iso.classList.remove('generating'); if (els.isoImg.getAttribute('src') !== src) { els.isoImg.onload = () => els.iso.classList.add('has-img'); els.isoImg.src = src; } if (els.isoImg.complete && els.isoImg.naturalWidth) els.iso.classList.add('has-img'); } function stageConverting(on) { // 3D-conversion scan overlay const els = dojoEls(); if (els.iso) els.iso.classList.toggle('converting', !!on); } function setBusy(next) { busy = !!next; const els = dojoEls(); if (els.generate) { els.generate.disabled = busy; els.generate.innerHTML = busy ? ' Generating…' : 'Generate Scene'; } if (els.improve) { els.improve.disabled = busy || improving; if (busy) { els.improve.classList.add('loading'); els.improve.innerHTML = ' generating…'; } else { els.improve.classList.remove('loading'); updateImproveBtn(); } } } function waitForResult(previousValue, onStage) { return new Promise((resolve, reject) => { const started = Date.now(); let lastValue = previousValue || ''; const timer = window.setInterval(() => { const value = readHidden('dojo-result'); if (value && value !== lastValue) { lastValue = value; try { const msg = JSON.parse(value); if (onStage) onStage(msg); if (msg.done || msg.source === 'dit360' || msg.source === 'default' || msg.source === 'none' || msg.error) { window.clearInterval(timer); resolve(value); } } catch (e) { window.clearInterval(timer); reject(e); } } if (Date.now() - started > 600000) { window.clearInterval(timer); reject(new Error('Scene generation timed out.')); } }, 300); }); } function currentConfig() { const els = dojoEls(); return { prompt: (els.prompt && els.prompt.value || '').trim(), seed: Number(els.seed && els.seed.value), steps: Number(els.steps && els.steps.value), mode: dojoMode, ...roomSettings() }; } // ---------- Saved dojos (localStorage) ---------- function loadMyDojos() { try { return JSON.parse(localStorage.getItem('kimodoMyDojos') || '[]'); } catch (e) { return []; } } function saveMyDojos(list) { let arr = (list || []).slice(0, 16); // Scene payloads carry data-URL splats (can be MBs); drop the oldest until it fits. while (arr.length) { try { localStorage.setItem('kimodoMyDojos', JSON.stringify(arr)); return true; } catch (e) { arr = arr.slice(0, arr.length - 1); } } try { localStorage.setItem('kimodoMyDojos', '[]'); } catch (e) {} return false; } function dojoName(prompt) { const head = (prompt || '').replace(/[.,].*$/, '').trim().replace(/^(a|an|the)\s+/i, ''); const name = head.split(/\s+/).slice(0, 4).join(' '); return (name || 'Dojo').slice(0, 30); } // After a dojo is saved to the dataset, add a LIGHT (URL-backed) entry to My dojos — // no heavy data-URL splat in localStorage, durable across reloads. function addMineFromSaved(entry) { const iso = communityUrl(entry.iso); const mineEntry = { id: entry.id, name: entry.name, prompt: entry.prompt, mode: 'splat', settings: entry.settings, thumb: iso, contributor: entry.contributor, saved: true, ts: Date.now(), scene: { ok: true, source: 'klein-triposplat', render_mode: 'splat', image: iso, isometric_image: iso, source_image: iso, splat: communityUrl(entry.splat), room_size: entry.settings && entry.settings.room_size, room_height: entry.settings && entry.settings.room_height } }; const arr = loadMyDojos().filter((x) => x.id !== entry.id); arr.unshift(mineEntry); saveMyDojos(arr); } function dojoAnonId() { const a = inputInside('anonymous-client-id'); return a ? (a.value || '') : ''; } // Publish the just-generated dojo to the dataset (tagged as the contributor) and // add it to My dojos. function saveDojoToDataset() { if (!_lastDojo || !_lastDojo.scene) { setStatus('Generate a dojo first.'); return; } const sc = _lastDojo.scene, cfg = _lastDojo.cfg || {}; const iso = sc.isometric_image || sc.source_image, splat = sc.splat; if (!iso || !splat) { setStatus('Nothing to save (no splat).'); return; } const els = dojoEls(); const trigger = document.querySelector('#dojo-save-btn button, #dojo-save-btn'); if (!trigger || !hiddenInput('dojo-save-payload')) { setStatus('Save backend missing.'); return; } if (els.save) { els.save.disabled = true; els.save.innerHTML = ' saving…'; } setStatus('Saving to the dataset…'); writeHidden('dojo-save-result', ''); writeHidden('dojo-save-payload', JSON.stringify({ name: dojoName(cfg.prompt || (els.prompt && els.prompt.value) || 'Dojo'), prompt: cfg.prompt || '', settings: { room_size: cfg.room_size, room_height: cfg.room_height }, iso: iso, splat: splat, anon_id: dojoAnonId() })); const started = Date.now(); const timer = window.setInterval(() => { const val = readHidden('dojo-save-result'); if (val) { window.clearInterval(timer); finish(val); } else if (Date.now() - started > 150000) { window.clearInterval(timer); finish('{"ok":false,"error":"timed out"}'); } }, 400); window.setTimeout(() => trigger.click(), 40); function finish(val) { if (els.save) { els.save.disabled = false; els.save.innerHTML = '☁ Save to my dojos'; } try { const r = JSON.parse(val); if (r.ok && r.entry) { addMineFromSaved(r.entry); _communityCache = null; // contributed dojo now shows in Community if (els.save) els.save.hidden = true; setStatus('Saved to My dojos ✓ — shared to Community as ' + (r.entry.contributor || 'you') + '.'); } else { setStatus('Save failed: ' + (r.error || 'unknown')); } } catch (e) { setStatus('Save failed.'); } } } // ---------- View + library ---------- function showView(view) { const els = dojoEls(); if (els.library) els.library.hidden = (view !== 'library'); if (els.create) els.create.hidden = (view !== 'create'); if (view === 'library') renderLibrary(); } function syncModeButtons() { const els = dojoEls(); if (!els.mode) return; els.mode.querySelectorAll('.kimodo-cz-segbtn').forEach((b) => b.classList.toggle('active', b.dataset.mode === dojoMode)); } function applySettings(settings) { const els = dojoEls(); if (!settings) return; if (els.roomSize && settings.room_size) els.roomSize.value = settings.room_size; if (els.roomHeight && settings.room_height) els.roomHeight.value = settings.room_height; if (els.skybox) els.skybox.checked = !!settings.use_panorama_skybox; if (els.seed && settings.seed != null) els.seed.value = settings.seed; if (els.steps && settings.steps != null) els.steps.value = settings.steps; updateRoomLabels(); } function openCreate(seed) { showView('create'); stageReset(); const els = dojoEls(); improvedByAya = false; if (seed) { if (els.prompt) { els.prompt.value = seed.prompt || ''; els.prompt.dispatchEvent(new Event('input', { bubbles: true })); } dojoMode = seed.mode || 'splat'; syncModeButtons(); applySettings(seed.settings); setStatus('Loaded "' + (seed.name || 'starter') + '". Tweak and Generate.'); } else { if (els.prompt) { els.prompt.value = ''; } // blank by default — placeholder "a random dojo" setStatus('Describe a dojo, or let tiny-aya decide 🎲'); } updateImproveBtn(); } function mineCard(entry) { const card = document.createElement('div'); card.className = 'kimodo-dojo-card'; card.title = entry.name || 'dojo'; if (entry.thumb) { const img = document.createElement('img'); img.className = 'thumb'; img.src = entry.thumb; img.alt = entry.name || 'dojo'; card.appendChild(img); } else { const ph = document.createElement('div'); ph.className = 'ph'; ph.textContent = '🏯'; card.appendChild(ph); } const del = document.createElement('button'); del.className = 'del'; del.type = 'button'; del.textContent = '✕'; del.title = 'Delete'; del.onclick = (e) => { e.stopPropagation(); if (!window.confirm('Delete this dojo?')) return; saveMyDojos(loadMyDojos().filter((x) => x.id !== entry.id)); renderLibrary(); }; card.appendChild(del); const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = entry.name || 'Dojo'; card.appendChild(nm); card.onclick = () => applySavedDojo(entry); return card; } function communityCard(seed) { const card = document.createElement('div'); card.className = 'kimodo-dojo-card'; card.title = seed.name; const ph = document.createElement('div'); ph.className = 'ph'; ph.textContent = '🏯'; card.appendChild(ph); const tag = document.createElement('span'); tag.className = 'badge'; tag.textContent = 'STARTER'; card.appendChild(tag); const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = seed.name; card.appendChild(nm); card.onclick = () => openCreate(seed); return card; } // Canonical community dojos are hosted in the dataset (viewer/dojos/community.json), // each with a real flux iso thumbnail + a splat that applies instantly. function communityUrl(path) { return (typeof DATASET_BASE !== 'undefined' && DATASET_BASE ? DATASET_BASE : '') + '/' + path; } function loadCommunity() { if (_communityCache) return Promise.resolve(_communityCache); const grab = (f) => fetch(communityUrl(f), { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null); return Promise.all([grab('viewer/dojos/community.json'), grab('viewer/dojos/contributed.json')]).then(([canon, contrib]) => { const list = [].concat(Array.isArray(canon) ? canon : [], Array.isArray(contrib) ? contrib : []); if (list.length) { _communityCache = list; return list; } return null; }); } function communityRealCard(d) { const card = document.createElement('div'); card.className = 'kimodo-dojo-card'; card.title = d.name + (d.contributor ? ' · by ' + d.contributor : ''); const img = document.createElement('img'); img.className = 'thumb'; img.src = communityUrl(d.iso); img.alt = d.name; card.appendChild(img); const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = d.name; card.appendChild(nm); if (d.contributor) { const by = document.createElement('div'); by.className = 'by'; by.textContent = 'by ' + d.contributor; card.appendChild(by); } card.onclick = () => applyCommunityDojo(d); return card; } function applyCommunityDojo(d) { const iso = communityUrl(d.iso); const scene = { ok: true, source: 'klein-triposplat', render_mode: 'splat', image: iso, isometric_image: iso, source_image: iso, splat: communityUrl(d.splat), room_size: d.settings && d.settings.room_size, room_height: d.settings && d.settings.room_height }; dojoMode = 'splat'; syncModeButtons(); applySettings(d.settings); lastScene = scene; sendScene(scene); setStatus('Applying "' + d.name + '"…'); } // Clear the applied dojo scene → back to the empty grid floor. function clearDojo() { const frame = previewFrame(); if (frame && frame.contentWindow) frame.contentWindow.postMessage({ kind: 'dojo-clear' }, '*'); lastScene = null; setStatus('Dojo cleared — back to the grid floor.'); } function clearCard() { const card = document.createElement('div'); card.className = 'kimodo-dojo-card kimodo-dojo-clearcard'; card.title = 'Clear the dojo — back to the grid floor'; const ph = document.createElement('div'); ph.className = 'ph'; ph.textContent = '▦'; card.appendChild(ph); const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = 'No dojo'; card.appendChild(nm); card.onclick = clearDojo; return card; } function renderLibrary() { const els = dojoEls(); if (els.scope) els.scope.querySelectorAll('.kimodo-cz-segbtn').forEach((b) => b.classList.toggle('active', b.dataset.scope === dojoScope)); if (!els.grid) return; els.grid.innerHTML = ''; if (dojoScope === 'mine') { els.grid.appendChild(clearCard()); // first card: clear + back to the grid floor const arr = loadMyDojos(); arr.forEach((e) => els.grid.appendChild(mineCard(e))); if (els.emptyMsg) els.emptyMsg.textContent = arr.length ? '' : 'No dojos yet — tap + Add dojo to create one.'; return; } // Community: canonical hosted dojos (instant apply), else the tap-to-generate starters. if (els.emptyMsg) els.emptyMsg.textContent = 'Loading community dojos…'; loadCommunity().then((list) => { if (dojoScope !== 'community' || !els.grid) return; els.grid.innerHTML = ''; els.grid.appendChild(clearCard()); if (list && list.length) list.forEach((d) => els.grid.appendChild(communityRealCard(d))); else COMMUNITY_SEED.forEach((s) => els.grid.appendChild(communityCard(s))); if (els.emptyMsg) els.emptyMsg.textContent = ''; }); } function applySavedDojo(entry) { const scene = entry.scene; if (!scene) { openCreate(entry); return; } // recipe-only fallback → re-generate dojoMode = scene.render_mode || (scene.splat || scene.splat_url ? 'splat' : 'panorama'); syncModeButtons(); applySettings(entry.settings); lastScene = scene; sendScene(scene); setStatus('Applied "' + (entry.name || 'dojo') + '".'); } // ---------- tiny-aya prompt suggestion ---------- // Improve-button label tracks state: blank prompt → "let tiny-aya decide", // tiny-aya already wrote one → "reroll", otherwise → "improve". function updateImproveBtn() { const els = dojoEls(); if (!els.improve || improving) return; const has = !!(els.prompt && els.prompt.value.trim()); els.improve.textContent = improvedByAya ? 'Reroll with tiny-aya 🎲' : (has ? '✦ Improve with tiny-aya' : 'let tiny-aya decide 🎲'); } function improvePrompt() { const els = dojoEls(); const payloadInput = hiddenInput('dojo-suggest-payload'); const trigger = document.querySelector('#dojo-suggest-btn button, #dojo-suggest-btn'); if (!payloadInput || !trigger) { setStatus('Suggest backend missing.'); return; } const words = (els.prompt && els.prompt.value || '').trim(); improving = true; if (els.improve) { els.improve.disabled = true; els.improve.textContent = '🎲 thinking…'; } setStatus(words ? 'Rerolling your prompt with tiny-aya…' : 'Letting tiny-aya invent a dojo…'); writeHidden('dojo-suggest-result', ''); writeHidden('dojo-suggest-payload', JSON.stringify({ words: words })); const started = Date.now(); const timer = window.setInterval(() => { const val = readHidden('dojo-suggest-result'); if (val) { window.clearInterval(timer); finishImprove(val); } else if (Date.now() - started > 150000) { window.clearInterval(timer); finishImprove('{"ok":false,"error":"timed out"}'); } }, 300); window.setTimeout(() => trigger.click(), 40); function finishImprove(val) { improving = false; if (els.improve) els.improve.disabled = busy; try { const r = JSON.parse(val); if (r.ok && r.prompt) { if (els.prompt) { els.prompt.value = r.prompt; els.prompt.dispatchEvent(new Event('input', { bubbles: true })); } improvedByAya = true; // set AFTER the input event (which resets it) so the button reads "reroll" setStatus('Prompt ready ✦ — tweak it or tap Generate.'); } else { setStatus('tiny-aya failed: ' + (r.error || 'unknown')); } } catch (e) { setStatus('tiny-aya failed.'); } updateImproveBtn(); } } // ---------- Generate ---------- async function runGenerate() { if (busy) return; const cfg = currentConfig(); if (!cfg.prompt) { setStatus('Describe a dojo first, or tap “let tiny-aya decide 🎲”.'); return; } const payloadInput = hiddenInput('dojo-payload'); const trigger = document.querySelector('#dojo-btn button, #dojo-btn'); if (!payloadInput || !trigger) { setStatus('Dojo backend controls are missing.'); return; } setBusy(true); stageReset(); stageGenerating(); setStatus('Generating the dojo image…'); writeHidden('dojo-result', ''); writeHidden('dojo-payload', JSON.stringify(cfg)); try { trigger.click(); const value = await waitForResult('', (stage) => { const st = stage.stage || ''; const iso = stage.isometric_image || stage.source_image; if (st === 'flux-iso-start' || st === 'flux-iso-progress') { stageGenerating(); setStatus(stage.status || 'Generating the dojo image…'); } else if (st === 'flux-iso-done') { stageIsoReady(iso); setStatus(stage.status || 'Image ready — converting to 3D…'); } else if (st === 'splat-start' || st === 'splat-progress') { if (iso) stageIsoReady(iso); stageConverting(true); setStatus(bestProgressText(stage) || stage.status || 'Converting to 3D…'); } else if (st === 'splat-done') { stageConverting(false); setStatus(stage.status || 'Loading the scene into the viewer…'); } else if (st === 'fallback') { stageConverting(false); setStatus(stage.warning || stage.error || 'Fallback scene generated.'); } }); const scene = JSON.parse(value); if (scene && (scene.image || scene.splat || scene.splat_url)) { Object.assign(scene, roomSettings()); lastScene = scene; sendScene(scene); // A real splat scene can be saved to the dataset + My dojos (tap the Save button). if (scene.source === 'klein-triposplat' && scene.splat) { _lastDojo = { scene: scene, cfg: cfg }; const saveBtn = dojoEls().save; if (saveBtn) saveBtn.hidden = false; setStatus('Generated ✓ — tap “☁ Save to my dojos” to keep it.'); } else if (scene.source === 'klein-triposplat' || scene.source === 'dit360') { setStatus(scene.info || 'Scene applied.'); } else { setStatus((scene.warning || 'Using default scene.') + ' Default scene applied.'); } } else { setStatus((scene && scene.error) || 'Scene generation failed.'); } } catch (e) { console.error('dojo scene generation failed', e); setStatus('Scene generation failed: ' + e.message); } finally { setBusy(false); } } function initDojo() { const drawer = document.getElementById('kimodo-dojo-drawer'); const els = dojoEls(); if (!drawer || drawer.dataset.ready === '1') { renderLibrary(); return; } drawer.dataset.ready = '1'; updateRoomLabels(); showView('library'); if (els.scope) els.scope.addEventListener('click', (e) => { const b = e.target.closest('.kimodo-cz-segbtn[data-scope]'); if (!b) return; dojoScope = b.dataset.scope || 'mine'; renderLibrary(); }); if (els.add) els.add.addEventListener('click', () => openCreate(null)); if (els.back) els.back.addEventListener('click', () => showView('library')); if (els.improve) els.improve.addEventListener('click', () => { if (!busy && !improving) improvePrompt(); }); if (els.save) els.save.addEventListener('click', () => { if (!els.save.disabled) saveDojoToDataset(); }); if (els.erase) els.erase.addEventListener('click', () => { if (els.prompt) { els.prompt.value = ''; els.prompt.dispatchEvent(new Event('input', { bubbles: true })); els.prompt.focus(); } setStatus('Prompt cleared — let tiny-aya decide 🎲'); }); // Manual edits drop the "reroll" state (a fresh prompt → "improve"/"decide"). if (els.prompt) els.prompt.addEventListener('input', () => { if (improving) return; improvedByAya = false; updateImproveBtn(); }); if (els.mode) els.mode.addEventListener('click', (e) => { const button = e.target.closest('.kimodo-cz-segbtn[data-mode]'); if (!button || busy) return; dojoMode = button.dataset.mode || 'splat'; syncModeButtons(); setStatus(dojoMode === 'panorama' ? 'Panorama mode selected.' : 'Splat mode selected.'); if (lastScene) sendScene(lastScene); }); ['roomSize', 'roomHeight', 'skybox'].forEach((key) => { const input = els[key]; if (!input) return; input.addEventListener('input', () => { updateRoomLabels(); sendRoomSettings(); }); input.addEventListener('change', () => { updateRoomLabels(); if (lastScene) sendScene(lastScene); }); }); if (els.generate) els.generate.addEventListener('click', runGenerate); } window.addEventListener('kimodo-drawer-open', (e) => { if (e.detail === 'kimodo-dojo-drawer') initDojo(); }); window.addEventListener('message', (event) => { const msg = event.data || {}; if (msg.kind === 'dojo-status' && msg.text) { if (/loaded in viewer/i.test(msg.text)) stageConverting(false); setStatus(msg.text); } }); })(); // WHO AM I tab — info on Karate Wiener, and tutorial STEP 1: the rail tab pulses // (vaporwave) on first start to invite the user in, stops once they open it, and can be // re-armed from the account drawer's "Restart tutorial" (kimodo-tutorial-restart event). (function () { var TKEY = 'kimodoTutorial'; function load() { try { return JSON.parse(localStorage.getItem(TKEY) || '{}') || {}; } catch (e) { return {}; } } function save(o) { try { localStorage.setItem(TKEY, JSON.stringify(o)); } catch (e) {} } function tab() { return document.getElementById('kimodo-whoami-toggle'); } function setPulse(on) { var t = tab(); if (t) t.classList.toggle('kimodo-whoami-pulse', !!on); } function seen() { return !!load().whoamiSeen; } // Pulse the tab until the user has opened it once (step 1 of the tutorial). function applyState() { setPulse(!seen()); } function markSeen() { var o = load(); if (!o.whoamiSeen) { o.whoamiSeen = true; save(o); } setPulse(false); } // Opening the WHO AM I drawer completes step 1. window.addEventListener('kimodo-drawer-open', function (e) { if (e.detail === 'kimodo-whoami-drawer') markSeen(); }); // Restart the tutorial (from the account drawer): re-arm step 1's pulse. window.addEventListener('kimodo-tutorial-restart', function () { var o = load(); o.whoamiSeen = false; save(o); setPulse(true); }); // Don't start the first-run pulse until the intro TITLE overlay has finished. The intro // (#kw-title-overlay) is added just after this script and REMOVED from the DOM when it // ends; it covers the whole screen meanwhile. So: wait until it has appeared and gone, // or — if no intro exists (no audio asset) — proceed after a short grace window. function gateInitialPulse() { var present = function () { return !!document.getElementById('kw-title-overlay'); }; var sawIntro = present(); var iv = setInterval(function () { if (present()) { sawIntro = true; return; } // intro still on screen if (sawIntro) { clearInterval(iv); applyState(); } // appeared then gone = intro done }, 250); // No intro showed up within the grace window => nothing to wait for. setTimeout(function () { if (!sawIntro && !present()) { clearInterval(iv); applyState(); } }, 1300); // Hard safety: never wait forever. setTimeout(function () { clearInterval(iv); applyState(); }, 15000); } gateInitialPulse(); })(); window.addEventListener('message', (event) => { const msg = event.data || {}; if (msg.kind === 'kimodo-preview-ready') { if (window.__kimodoHideSplash) window.__kimodoHideSplash(); // viewer is up — reveal the page // Fresh iframe boot/after-generate. The new iframe already applies the // current clothing from cfg.defaultClothing, so we do NOT resend it here // (resending re-attaches garments and visibly reloads them). onPreviewReady(msg.id); sendViewportInset(); // a freshly-loaded viewer doesn't know the open-drawer offset } else if (msg.kind === 'kimodo-viewer-error') { if (window.__kimodoHideSplash) window.__kimodoHideSplash(); // CDN/module load failed — don't trap the user } else if (msg.kind === 'kimodo-clip-changed') { // In-place clip switch in the same iframe: just move the card highlight. onClipChanged(msg.id); } else if (msg.kind === 'kimodo-frame') { onFrame(msg); // actions card play-row kataOnFrame(msg); // kata tree playhead if (typeof composeOnFrame === 'function') composeOnFrame(msg); // MY KARATE player scrubber } else if (msg.kind === 'place-update') { if (typeof composeOnPlaceUpdate === 'function') composeOnPlaceUpdate(msg); // 3D figure moved/rotated } else if (msg.kind === 'play-kata' && Array.isArray(msg.ids) && msg.ids.length) { // Picker selected a whole kata: stitch its path client-side (stitchPath lives // in the kata module) and play it in the viewer. stitchPath(msg.ids) .then((m) => loadIntoViewer(m, '', (msg.label || 'kata'))) .catch((e) => console.error('play-kata stitch failed', e)); } }); writeState(); writeAnonymousClientId(); syncButtons(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); })();