Spaces:
Running on Zero
Running on Zero
| (() => { | |
| // Lift the SSR boot splash (the body::before/::after overlay in _BLOCKS_CSS) by | |
| // tagging <body>. 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 = '<span class="grip"></span>'; | |
| 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 + ' <span class="dur-badge">' + secs.toFixed(1) + 's</span>' | |
| + (playing ? ' <span class="badge">● previewing</span>' | |
| : busy ? ' <span class="badge gen"><span class="spin">⟳</span> generating…</span>' : ''); | |
| 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 ? '<span class="spin">⟳</span>' : '↻ 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 ? '<span class="spin">⟳</span> 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 = '<div class="kimodo-drawer-sub" style="color:#888">no matches</div>'; 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) => '<span style="color:#8fd3ff">#' + t + '</span>').join(' '); | |
| card.innerHTML = '<div style="font-size:12px;font-weight:600;color:#e6eaef">' + stanceName(m) + '</div>' | |
| + '<div style="font-size:10px;margin-top:2px">' + tags + '</div>'; | |
| 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 += '<line x1="' + P[p][0] + '" y1="' + (-P[p][1]) + '" x2="' + P[i][0] + '" y2="' + (-P[i][1]) + '"/>'; } | |
| let dots = ''; | |
| for (const j of P) dots += '<circle cx="' + j[0] + '" cy="' + (-j[1]) + '" r="' + r + '"/>'; | |
| return '<svg viewBox="' + vbX + ' ' + vbY + ' ' + vbW + ' ' + vbH + '" preserveAspectRatio="xMidYMid meet">' | |
| + '<g stroke="#c79bff" stroke-width="' + sw + '" stroke-linecap="round" fill="none">' + bones + '</g>' | |
| + '<g fill="#e6c7ff">' + dots + '</g></svg>'; | |
| } | |
| 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 = '<span id="kimodo-cz-vname-text"></span><button type="button" id="kimodo-cz-vname-edit" class="kimodo-cz-vname-edit" title="rename">✎</button><span id="kimodo-cz-vname-by" class="kimodo-cz-vname-by"></span>'; | |
| 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 = '<span style="font-size:16px">+</span><span>Create a kata</span>'; | |
| 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 <b style="color:#c79bff">+ Add a stance</b> 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 = '<b style="color:#c79bff">Move #' + activeIdx + '</b> updated. Keep adding stances with <b style="color:#c79bff">+ Add a stance</b> or keep editing.'; | |
| } | |
| else if (activeIdx >= 1) { | |
| const btn = poses[activeIdx].clipId ? 'Refresh' : 'Generate'; | |
| hintHTML = '<span style="color:#9a9aa3">Editing pose on 3d viewer</span><br>' | |
| + 'drag <b style="color:#6fd0a0">ring</b> to move • <b style="color:#e8736a">knob</b> to rotate<br><br>' | |
| + '<span style="color:#9a9aa3">changing duration</span><br>' | |
| + 'drag <b style="color:#c79bff">…</b> up/down<br><br>' | |
| + '<b style="color:#7db8ff">' + btn + '</b> 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 = '<span id="kimodo-cz-pcur">0:00</span><span id="kimodo-cz-ptot">' + _czTime(composePlayer.numFrames - 1, composePlayer.fps) + '</span>'; | |
| 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 = '<span class="kimodo-cz-spin"></span>'; } // 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 '<span class="v">x ' + (c.tx || 0).toFixed(1) + ' · y ' + (c.tz || 0).toFixed(1) + ' · ↻ ' + Math.round(c.rot || 0) + '°</span>'; | |
| } | |
| // 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 <b class="' + cl + '">✓</b> stance to confirm') | |
| : ('Tap a stance to set ' + (startPhase ? 'a <b class="s">start pose</b>' : 'the <b class="e">end pose</b>')); | |
| } | |
| if (!composeStances.length) { pal.innerHTML = '<div class="kimodo-cz-statusline" style="color:#888;padding:8px 4px">no stances in this dataset</div>'; return; } | |
| if (!list.length) { pal.innerHTML = '<div class="kimodo-cz-statusline" style="color:#888;padding:8px 4px">no stances match “' + q.replace(/</g, '<') + '”</div>'; 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 ? '<span class="spin">⟳</span> Generating…' : 'Generate Scene'; | |
| } | |
| if (els.improve) { | |
| els.improve.disabled = busy || improving; | |
| if (busy) { els.improve.classList.add('loading'); els.improve.innerHTML = '<span class="spin">⟳</span> 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 = '<span class="spin">⟳</span> 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(); | |
| })(); | |