(() => {
// Lift the SSR boot splash (the body::before/::after overlay in _BLOCKS_CSS) by
// tagging
. Called on the viewer's first preview-ready / viewer-error, plus a
// safety timeout so a slow/blocked CDN never traps the user behind the spinner.
window.__kimodoHideSplash = function () { try { document.body.classList.add('kimodo-ready'); } catch (e) {} };
// The Space's own (non-iframe) host, e.g. "name.hf.space". Used to break OUT of the
// huggingface.co iframe for OAuth so the session cookie is first-party (iOS/Safari
// block third-party cookies in iframes). Empty off-Space (local dev).
window.__kimodoSpaceHost = "";
setTimeout(function () { if (window.__kimodoHideSplash) window.__kimodoHideSplash(); }, 15000);
let attempts = 0;
function boot() {
const defaultClothing = {"face":"nerdy_glasses","torso_over":"kimono","legs":"kimono_trousers"};
const DATASET_BASE = "";
// The storyboard composer calls /generate_between + /stitch_path directly on the
// kimodo backend. Empty when the Space runs the in-process @GPU model (no HTTP
// backend) — Build & Play is then disabled with a note.
const KIMODO_BACKEND = "";
const root = document.getElementById('kimodo-drawer-root');
const tabs = document.getElementById('kimodo-left-tabs');
const clothingDrawer = document.getElementById('kimodo-clothing-drawer');
if (!root || !tabs || !clothingDrawer) {
if (++attempts < 80) window.setTimeout(boot, 100);
return;
}
if (root.dataset.ready === '1') return;
root.dataset.ready = '1';
if (root.parentElement !== document.body) document.body.appendChild(root);
// Embedded in the HF Space page? Pin the tab rail to the upper-left (the
// centered default lands mid-page when HF grows the iframe to content height).
let embedded = false;
try { embedded = window.self !== window.top; } catch (e) { embedded = true; }
if (embedded) tabs.classList.add('kimodo-embedded');
function inputInside(id) {
const el = document.getElementById(id);
return el ? el.querySelector('textarea, input') : null;
}
function previewFrame() {
return document.querySelector('#main-preview iframe');
}
// --- Generic drawer manager: each left-tab carries data-drawer; only one
// drawer is open at a time, and the tab rail slides to the open drawer's edge.
const pairs = Array.from(tabs.querySelectorAll('.kimodo-left-tab[data-drawer]'))
.map((tab) => ({ tab, drawer: document.getElementById(tab.dataset.drawer) }))
.filter((p) => p.drawer);
const drawers = pairs.map((p) => p.drawer);
// The composer is now a normal left side drawer again (reverted from the bottom bar).
const composeBar = document.getElementById('kimodo-compose-drawer');
const sideDrawers = drawers;
function openDrawer() { return drawers.find((d) => d.classList.contains('open')) || null; }
function openSideDrawer() { return sideDrawers.find((d) => d.classList.contains('open')) || null; }
// Tell the viewer how many px the open left drawer covers, so it can shift the
// camera to keep the character centered in the still-visible area (0 = none).
function sendViewportInset() {
const open = openSideDrawer();
const left = open ? Math.round(open.getBoundingClientRect().width) : 0;
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'viewport-inset', left }, '*');
}
// --- Drawer resize handle: drag the open drawer's right edge to trade panel width
// for 3D-view width. The chosen width is persisted per drawer (localStorage) and the
// tab rail + camera inset follow the edge live while dragging. ---
const DRAWER_MIN_W = 158;
function winW() { return Math.max(window.innerWidth || 0, document.documentElement.clientWidth || 0); }
function drawerMaxW() { return Math.max(DRAWER_MIN_W, Math.round(winW() * 0.85)); }
function loadDrawerWidths() { try { return JSON.parse(localStorage.getItem('kimodo-drawer-widths') || '{}') || {}; } catch (e) { return {}; } }
function saveDrawerWidth(id, px) { try { const m = loadDrawerWidths(); m[id] = Math.round(px); localStorage.setItem('kimodo-drawer-widths', JSON.stringify(m)); } catch (e) {} }
let resizerEl = null, resizing = null, resizeRaf = false;
function ensureResizer() {
if (resizerEl) return resizerEl;
resizerEl = document.createElement('div');
resizerEl.id = 'kimodo-drawer-resizer';
resizerEl.title = 'Drag to resize the panel';
resizerEl.innerHTML = '';
document.body.appendChild(resizerEl);
resizerEl.addEventListener('pointerdown', startResize);
return resizerEl;
}
function positionResizer() {
const r = ensureResizer();
const open = openSideDrawer();
if (!open) { r.style.display = 'none'; return; }
const w = Math.round(open.getBoundingClientRect().width);
r.style.left = (w - 6) + 'px'; // straddle the drawer's right edge
r.style.display = 'block';
}
function startResize(e) {
const open = openSideDrawer();
if (!open) return;
resizing = open;
e.preventDefault(); e.stopPropagation();
try { resizerEl.setPointerCapture(e.pointerId); } catch (er) {}
document.body.style.userSelect = 'none';
resizerEl.classList.add('dragging');
}
function onResizeMove(e) {
if (!resizing) return;
const px = Math.max(DRAWER_MIN_W, Math.min(Math.round(e.clientX), drawerMaxW()));
// !important to beat the narrow-screen 50vw-important media-query rule.
resizing.style.setProperty('width', px + 'px', 'important');
positionResizer();
tabs.style.left = px + 'px'; // keep the tab rail glued to the edge while dragging
if (!resizeRaf) { resizeRaf = true; requestAnimationFrame(() => { resizeRaf = false; sendViewportInset(); }); }
}
function endResize() {
if (!resizing) return;
saveDrawerWidth(resizing.id, Math.round(resizing.getBoundingClientRect().width));
resizing = null;
document.body.style.userSelect = '';
if (resizerEl) resizerEl.classList.remove('dragging');
layoutTabs(); sendViewportInset();
}
window.addEventListener('pointermove', onResizeMove);
window.addEventListener('pointerup', endResize);
window.addEventListener('pointercancel', endResize);
// Size each side drawer from the ACTUAL measured window width (reliable inside the
// HF embed iframe, where vw can resolve to the layout viewport, not the visible
// width). Half the width on narrow screens; full fixed width on desktop. Inline
// style overrides the stylesheet (and any stale cached CSS).
function layoutDrawerWidth() {
const w = Math.max(window.innerWidth || 0, document.documentElement.clientWidth || 0);
const widths = loadDrawerWidths();
for (const d of sideDrawers) {
let px;
if (d.id === 'kimodo-compose-drawer') {
// "My Karate" drawer: a comfortable width that keeps the stance cards readable
// on mobile (~200px) while still leaving most of the screen for the 3D view; a
// fixed width on desktop. The user can still drag it thinner/wider (persisted,
// down to DRAWER_MIN_W).
px = (w && w < 760) ? Math.min(204, Math.round(w * 0.62)) : 230;
} else {
const full = d.classList.contains('kimodo-drawer--wide') ? 460 : 330;
px = (w && w < 760) ? Math.round(w * 0.5) : full;
}
// A width the user dragged-to wins over the responsive default (clamped so it
// still fits if the window since shrank).
const saved = widths[d.id];
if (saved) px = Math.max(DRAWER_MIN_W, Math.min(Math.round(saved), drawerMaxW()));
// !important so this measured/dragged width beats the narrow-screen
// `@media (max-width:700px)` 50vw-important rule (else the panel can't shrink).
d.style.setProperty('width', px + 'px', 'important');
}
}
function layoutTabs() {
layoutDrawerWidth();
const open = openSideDrawer(); // bottom bar doesn't push the tab rail
tabs.classList.toggle('drawer-open', !!open);
tabs.style.left = open ? Math.round(open.getBoundingClientRect().width) + 'px' : '0';
positionResizer(); // park the resize handle at the (new) drawer edge
}
function setOpen(drawer, open) {
if (open) for (const d of drawers) if (d !== drawer) d.classList.remove('open');
drawer.classList.toggle('open', !!open);
for (const p of pairs) p.tab.classList.toggle('active', p.drawer.classList.contains('open'));
layoutTabs();
// Refresh the action->clip map each time the library opens so new takes show.
if (open && drawer.id === 'kimodo-actions-drawer') refreshClips().then(renderActions);
// Build/refresh the kata tree when its drawer opens (svg needs a layout size).
if (open && drawer.id === 'kimodo-kata-drawer') refreshKataTree();
// Load the stance library when its drawer opens.
if (open && drawer.id === 'kimodo-stances-drawer') refreshStances();
// Build the storyboard composer (palette + strip) when its drawer opens.
if (open && drawer.id === 'kimodo-compose-drawer') composeOpen();
// Pluggable tabs (tabs/*.tab.js) hook their open behavior off this event,
// so a new tab never has to edit setOpen. detail = the opened drawer's id.
if (open) window.dispatchEvent(new CustomEvent('kimodo-drawer-open', { detail: drawer.id }));
// Shift the viewer camera to keep the character centered beside the open drawer.
sendViewportInset();
}
for (const p of pairs) {
p.tab.addEventListener('click', () => setOpen(p.drawer, !p.drawer.classList.contains('open')));
}
document.addEventListener('pointerdown', (event) => {
const open = openDrawer();
if (!open) return;
if (open.contains(event.target) || tabs.contains(event.target)) return;
if (resizerEl && resizerEl.contains(event.target)) return; // dragging the resize handle, not an outside click
const viewer = document.getElementById('main-preview');
if (viewer && viewer.contains(event.target)) return;
setOpen(open, false);
});
window.addEventListener('resize', () => { layoutTabs(); sendViewportInset(); });
window.addEventListener('orientationchange', () => { layoutTabs(); sendViewportInset(); });
layoutDrawerWidth(); // set the responsive drawer width once at startup
// --- Clothing drawer behavior (one garment per slot -> state textbox + iframe). ---
const state = { ...defaultClothing };
function writeAnonymousClientId() {
const input = inputInside('anonymous-client-id');
if (!input) return;
let id = '';
try {
id = window.localStorage.getItem('kimodoAnonymousClientId') || '';
if (!id) {
const bytes = new Uint8Array(16);
window.crypto.getRandomValues(bytes);
id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
window.localStorage.setItem('kimodoAnonymousClientId', id);
}
} catch (err) {
id = 'session-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
}
input.value = id;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function writeState() {
const input = inputInside('clothing-state');
if (!input) return;
input.value = JSON.stringify(state);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function sendClothing(slot, value) {
const frame = previewFrame();
if (frame && frame.contentWindow) {
frame.contentWindow.postMessage({ kind: 'clothing', slot, value: value || '' }, '*');
}
}
function syncButtons() {
clothingDrawer.querySelectorAll('.kimodo-drawer-option').forEach((button) => {
const slot = button.dataset.slot;
const id = button.dataset.id || '';
const on = (state[slot] || '') === id;
button.classList.toggle('on', on);
button.textContent = (on && id ? '✓ ' : '') + button.textContent.replace(/^✓\s+/, '');
});
}
clothingDrawer.addEventListener('click', (event) => {
const button = event.target.closest('.kimodo-drawer-option');
if (!button) return;
const slot = button.dataset.slot;
const id = button.dataset.id || '';
if (!slot) return;
if (id) state[slot] = id;
else delete state[slot];
writeState();
syncButtons();
sendClothing(slot, id);
});
// --- Actions library: curated move templates (localStorage) -> clips. ---
// Actions = the signed-in user's generated clips (anonymous: this browser's
// clips, tracked in localStorage). One card per clip, mirroring the kata web
// UI's .action-card. Templates/seeds are gone — these are real animations.
const actionsList = document.getElementById('kimodo-actions-list');
const KATA_ROOTS_KEY = 'kimodo-kata-roots-v1';
const MY_CLIPS_KEY = 'kimodo-my-clips-v1';
function lsGet(k, d) { try { const v = JSON.parse(localStorage.getItem(k)); return v == null ? d : v; } catch (e) { return d; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
let kataRoots = new Set(lsGet(KATA_ROOTS_KEY, []));
function saveKataRoots() { lsSet(KATA_ROOTS_KEY, [...kataRoots]); }
let myClips = new Set(lsGet(MY_CLIPS_KEY, []));
function saveMyClips() { lsSet(MY_CLIPS_KEY, [...myClips]); }
function currentUser() { const el = inputInside('current-user'); return el ? (el.value || '').trim() : ''; }
function deriveName(p) {
const s = (p || '').replace(/^(a martial artist|a person|the practitioner|the martial artist)\s+/i, '').trim();
const w = s.split(/\s+/).slice(0, 5).join(' ');
return (w.charAt(0).toUpperCase() + w.slice(1)).slice(0, 32) || 'Move';
}
let actions = []; // [{ id, prompt, seconds, created_by, name }]
let editingId = null; // action id whose prompt is being edited
let busyId = null; // action id currently regenerating
let previewingId = null; // clip id shown in the viewer
let pendingGen = false; // a generation was triggered from this browser
let adding = false; // the add form is generating
let addSeconds = 2.2; // add form's chosen duration
let durOpen = new Set(); // action ids whose options section is expanded
let scrubbing = false; // a card scrubber is being dragged
let aPlayBtn = null, aScrub = null, aLabel = null; // previewing card's live transport refs
async function fetchManifest() {
if (!DATASET_BASE) return [];
try {
const r = await fetch(DATASET_BASE + '/manifest.json', { cache: 'no-store' });
if (!r.ok) return [];
const recs = ((await r.json()) || {}).records || {};
const items = Object.entries(recs).map(([id, m]) => Object.assign({ id }, m));
items.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
return items;
} catch (err) { console.error('manifest fetch failed', err); return []; }
}
async function refreshClips() {
const items = await fetchManifest();
const user = currentUser();
actions = items
.filter((m) => {
if (m.continues_from && m.continues_from.source_id) return false; // standalone moves only
if (user) return m.created_by === user; // signed in: my attributed clips
return !m.created_by && myClips.has(m.id); // anon: this browser's clips
})
.map((m) => ({ id: m.id, prompt: m.prompt || '', seconds: Number(m.seconds) || 2.2,
created_by: m.created_by || '', name: deriveName(m.prompt) }));
}
function scrubFill(el) {
const max = Number(el.max) || 1, pct = (Number(el.value) / max) * 100;
el.style.background = 'linear-gradient(90deg, #5ad1ff ' + pct + '%, #34343c ' + pct + '%)';
}
function sendTransport(action, frameNum) {
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'transport', action, frame: frameNum }, '*');
}
function previewClip(id) {
if (!id) return;
previewingId = id;
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'load-animation', id }, '*');
renderActions();
}
// Drive the hidden logged-in generate path (quota + attribution intact) with a
// fresh random seed. Completion arrives via onPreviewReady().
function fireGenerate(prompt, seconds) {
const pin = document.querySelector('#regen-prompt textarea, #regen-prompt input');
const sin = document.querySelector('#regen-seconds textarea, #regen-seconds input');
const btn = document.querySelector('#regen-btn');
if (!pin || !sin || !btn) return false;
pin.value = prompt; pin.dispatchEvent(new Event('input', { bubbles: true }));
sin.value = String(seconds); sin.dispatchEvent(new Event('input', { bubbles: true }));
pendingGen = true;
setTimeout(() => btn.click(), 40);
return true;
}
function regenAction(act, promptOverride) {
if (busyId || adding) return;
busyId = act.id; renderActions();
if (!fireGenerate(promptOverride || act.prompt, Number(act.seconds) || 2.2)) { busyId = null; renderActions(); return; }
setTimeout(() => { if (busyId === act.id) { busyId = null; renderActions(); } }, 240000); // safety
}
function addAction(promptText, seconds) {
const p = (promptText || '').trim(); if (!p || adding || busyId) return;
adding = true; renderActions();
if (!fireGenerate(p, seconds || addSeconds)) { adding = false; renderActions(); return; }
setTimeout(() => { if (adding) { adding = false; renderActions(); } }, 240000); // safety
}
// The clip currently in the viewer + the frame it's paused/at, for branching.
function previewSource() {
if (!viewerState || !viewerState.id) return null;
return { id: viewerState.id, frame: Math.round(viewerState.frame || 0) };
}
// Kata authoring: branch a NEW move that starts from the frame currently shown
// in the viewer (any previewed clip), via the constrained generate_continue path.
function fireContinue(prompt, seconds, sourceId, frame) {
const pin = document.querySelector('#cont-prompt textarea, #cont-prompt input');
const sin = document.querySelector('#cont-seconds textarea, #cont-seconds input');
const src = document.querySelector('#cont-source-id textarea, #cont-source-id input');
const frm = document.querySelector('#cont-source-frame textarea, #cont-source-frame input');
const btn = document.querySelector('#cont-btn');
if (!pin || !sin || !src || !frm || !btn) return false;
pin.value = prompt; pin.dispatchEvent(new Event('input', { bubbles: true }));
sin.value = String(seconds); sin.dispatchEvent(new Event('input', { bubbles: true }));
src.value = sourceId; src.dispatchEvent(new Event('input', { bubbles: true }));
frm.value = String(frame); frm.dispatchEvent(new Event('input', { bubbles: true }));
pendingGen = true;
setTimeout(() => btn.click(), 40);
return true;
}
function continueAction(act) {
const s = previewSource(); if (!s || busyId || adding) return;
busyId = act.id; renderActions();
if (!fireContinue(act.prompt, Number(act.seconds) || 2.2, s.id, s.frame)) { busyId = null; renderActions(); return; }
setTimeout(() => { if (busyId === act.id) { busyId = null; renderActions(); } }, 240000);
}
function addContinue(promptText, seconds) {
const p = (promptText || '').trim(); const s = previewSource();
if (!p || !s || adding || busyId) return;
adding = true; renderActions();
if (!fireContinue(p, seconds || addSeconds, s.id, s.frame)) { adding = false; renderActions(); return; }
setTimeout(() => { if (adding) { adding = false; renderActions(); } }, 240000);
}
function deleteAction(act) {
const did = document.querySelector('#del-id textarea, #del-id input');
const dbtn = document.querySelector('#del-btn');
if (did && dbtn) {
did.value = act.id; did.dispatchEvent(new Event('input', { bubbles: true }));
setTimeout(() => dbtn.click(), 30);
}
actions = actions.filter((a) => a.id !== act.id); // optimistic
if (myClips.has(act.id)) { myClips.delete(act.id); saveMyClips(); }
if (previewingId === act.id) previewingId = null;
renderActions();
setTimeout(() => refreshClips().then(renderActions), 2500); // reconcile with server
}
function makeKataFromAction(act) {
if (kataRoots.has(act.id)) kataRoots.delete(act.id); else kataRoots.add(act.id);
saveKataRoots(); renderActions();
// Reflect the new/removed kata root in the tree if that drawer is open.
const kd = document.getElementById('kimodo-kata-drawer');
if (kd && kd.classList.contains('open') && typeof refreshKataTree === 'function') refreshKataTree();
}
const startEdit = (act) => { editingId = act.id; renderActions(); };
const cancelEdit = () => { editingId = null; renderActions(); };
function commitEdit(act, value) {
const v = (value || '').trim(); editingId = null;
if (v && v !== act.prompt) { act.prompt = v; act.name = deriveName(v); } // used by the next regenerate
renderActions();
}
// Build one card, faithfully mirroring kata.js renderDrawer's .action-card.
function renderCard(act) {
const has = !!act.id;
const busy = busyId === act.id;
const editing = editingId === act.id;
const playing = previewingId === act.id && !busy && !editing;
const secs = Number(act.seconds) || 2.2;
const card = document.createElement('div'); card.className = 'action-card' + (playing ? ' playing' : '');
const nm = document.createElement('div'); nm.className = 'nm';
nm.innerHTML = act.name + ' ' + secs.toFixed(1) + 's'
+ (playing ? ' ● previewing'
: busy ? ' ⟳ generating…' : '');
card.appendChild(nm);
const durBadge = nm.querySelector('.dur-badge');
if (editing) {
const ta = document.createElement('textarea'); ta.className = 'pr-edit'; ta.rows = 3; ta.value = act.prompt;
ta.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEdit(act, ta.value); } else if (e.key === 'Escape') cancelEdit(); };
const bar = document.createElement('div'); bar.className = 'edit-bar';
const save = document.createElement('button'); save.textContent = '✓ save'; save.onclick = () => commitEdit(act, ta.value);
const cancel = document.createElement('button'); cancel.textContent = 'cancel'; cancel.onclick = cancelEdit;
bar.appendChild(save); bar.appendChild(cancel);
card.appendChild(ta); card.appendChild(bar);
setTimeout(() => { ta.focus(); ta.select(); }, 0);
return card;
}
// delete X — top-right corner
const x = document.createElement('button'); x.className = 'del-x'; x.textContent = '✕'; x.title = 'delete animation'; x.disabled = busy;
x.onclick = () => deleteAction(act); card.appendChild(x);
// prompt + inline edit
const pr = document.createElement('div'); pr.className = 'pr';
const txt = document.createElement('span'); txt.className = 'pr-txt'; txt.textContent = act.prompt;
const ed = document.createElement('button'); ed.className = 'pr-edit-btn'; ed.textContent = '✎'; ed.title = 'edit prompt'; ed.disabled = busy;
ed.onclick = () => startEdit(act);
pr.appendChild(txt); pr.appendChild(ed); card.appendChild(pr);
// preview, or — while previewing this card — a live media transport
if (playing) {
const max = Math.max(0, (viewerState.num_frames || 1) - 1);
const pp = document.createElement('button'); pp.className = 'pp'; pp.textContent = viewerState.playing ? '❚❚' : '▶';
pp.onclick = () => sendTransport('toggle');
const lbl = document.createElement('span'); lbl.className = 'frame-lbl'; lbl.textContent = Math.round(viewerState.frame || 0) + '/' + max;
const sc = document.createElement('input'); sc.type = 'range'; sc.className = 'play-scrub'; sc.min = 0; sc.max = max; sc.step = 1; sc.value = Math.round(viewerState.frame || 0);
sc.oninput = () => { scrubbing = true; const f = Number(sc.value); sendTransport('seek', f); pp.textContent = '▶'; lbl.textContent = f + '/' + max; scrubFill(sc); };
sc.onchange = () => { scrubbing = false; };
scrubFill(sc);
const prow = document.createElement('div'); prow.className = 'play-row';
prow.appendChild(pp); prow.appendChild(sc); prow.appendChild(lbl); card.appendChild(prow);
aPlayBtn = pp; aScrub = sc; aLabel = lbl;
} else {
const prev = document.createElement('button'); prev.className = 'prev-btn'; prev.disabled = !has || busy;
prev.textContent = busy ? 'generating…' : !has ? 'not generated' : '▶ preview';
prev.onclick = () => previewClip(act.id); card.appendChild(prev);
}
// collapsible options: regenerate(s) + duration + rotation
const open = durOpen.has(act.id);
const optRow = document.createElement('div'); optRow.className = 'row'; optRow.style.alignItems = 'center';
const tog = document.createElement('button'); tog.className = 'dur-tog'; tog.style.flex = '1';
tog.textContent = (open ? '▾' : '▸') + ' options · ' + secs.toFixed(1) + 's';
tog.onclick = () => { durOpen.has(act.id) ? durOpen.delete(act.id) : durOpen.add(act.id); renderActions(); };
const mk = document.createElement('button'); mk.className = 'kata-plus'; mk.disabled = !has || busy;
mk.textContent = (has && kataRoots.has(act.id)) ? '✓ kata' : '+ kata';
mk.title = has ? 'start a new kata with this move as the first step' : 'generate this action first';
mk.onclick = () => makeKataFromAction(act);
optRow.appendChild(tog); optRow.appendChild(mk); card.appendChild(optRow);
if (open) {
const rrow = document.createElement('div'); rrow.className = 'regen-row';
const reg = document.createElement('button'); reg.className = 'rg'; reg.disabled = busy;
reg.innerHTML = busy ? '⟳' : '↻ regenerate'; reg.title = 'regenerate from scratch (rest pose)';
reg.onclick = () => regenAction(act);
const src = previewSource();
const regf = document.createElement('button'); regf.className = 'rg'; regf.disabled = busy || !src;
regf.textContent = '↳ from preview frame';
regf.title = src ? ('start this move from frame ' + src.frame + ' of the previewed clip') : 'preview a clip and pause on a frame first';
regf.onclick = () => continueAction(act);
rrow.appendChild(reg); rrow.appendChild(regf); card.appendChild(rrow);
const dur = document.createElement('div'); dur.className = 'dur';
const dtag = document.createElement('span'); dtag.className = 'dur-tag'; dtag.textContent = '⏱';
const drange = document.createElement('input'); drange.type = 'range'; drange.min = '0.5'; drange.max = '10'; drange.step = '0.5'; drange.value = String(secs); drange.disabled = busy;
const dlbl = document.createElement('span'); dlbl.className = 'dur-lbl'; dlbl.textContent = secs.toFixed(1) + 's';
drange.oninput = () => { act.seconds = Number(drange.value); const t = act.seconds.toFixed(1) + 's'; dlbl.textContent = t; if (durBadge) durBadge.textContent = t; tog.textContent = '▾ options · ' + t; };
dur.appendChild(dtag); dur.appendChild(drange); dur.appendChild(dlbl); card.appendChild(dur);
// rotation row — disabled until Phase 4 (rotate_clip)
const rotRow = document.createElement('div'); rotRow.className = 'rot-row';
const rtag = document.createElement('span'); rtag.className = 'dur-tag'; rtag.textContent = '⟲';
const rrange = document.createElement('input'); rrange.type = 'range'; rrange.min = '-180'; rrange.max = '180'; rrange.step = '1'; rrange.value = '0'; rrange.disabled = true;
rrange.title = 'rotation comes with kata authoring (Phase 4)';
const rlbl = document.createElement('span'); rlbl.className = 'dur-lbl'; rlbl.textContent = '0°';
const rsave = document.createElement('button'); rsave.className = 'rot-save'; rsave.textContent = 'save'; rsave.disabled = true;
rotRow.appendChild(rtag); rotRow.appendChild(rrange); rotRow.appendChild(rlbl); rotRow.appendChild(rsave); card.appendChild(rotRow);
}
return card;
}
function renderActions() {
if (!actionsList) return;
actionsList.textContent = '';
// Add form: prompt + (Add & generate / from preview frame) + duration.
const form = document.createElement('div'); form.className = 'add-form';
const addTa = document.createElement('textarea'); addTa.className = 'add-input'; addTa.rows = 2;
addTa.placeholder = 'describe a new move, e.g. "a martial artist throws an elbow strike"';
addTa.disabled = adding;
const submitAdd = () => { const v = addTa.value.trim(); if (v && !adding) addAction(v, addSeconds); };
const submitAddFrame = () => { const v = addTa.value.trim(); if (v && !adding && previewSource()) addContinue(v, addSeconds); };
addTa.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitAdd(); } };
const arow = document.createElement('div'); arow.className = 'add-row';
const add = document.createElement('button'); add.className = 'add-action'; add.disabled = adding;
add.innerHTML = adding ? '⟳ generating…' : '+ Add & generate';
add.onclick = submitAdd;
const psrc = previewSource();
const addf = document.createElement('button'); addf.className = 'add-action'; addf.disabled = adding || !psrc;
addf.textContent = '↳ from preview frame';
addf.title = psrc ? ('add a move that starts from frame ' + psrc.frame + ' of the previewed clip') : 'preview a clip and pause on a frame first';
addf.onclick = submitAddFrame;
arow.appendChild(add); arow.appendChild(addf);
const adur = document.createElement('div'); adur.className = 'dur';
const atag = document.createElement('span'); atag.className = 'dur-tag'; atag.textContent = '⏱';
const arange = document.createElement('input'); arange.type = 'range'; arange.min = '0.5'; arange.max = '10'; arange.step = '0.5'; arange.value = String(addSeconds); arange.disabled = adding;
const albl = document.createElement('span'); albl.className = 'dur-lbl'; albl.textContent = addSeconds.toFixed(1) + 's';
arange.oninput = () => { addSeconds = Number(arange.value); albl.textContent = addSeconds.toFixed(1) + 's'; };
adur.appendChild(atag); adur.appendChild(arange); adur.appendChild(albl);
form.appendChild(addTa); form.appendChild(arow); form.appendChild(adur);
actionsList.appendChild(form);
aPlayBtn = aScrub = aLabel = null; // refreshed if a previewing card renders
if (!DATASET_BASE) {
const n = document.createElement('div'); n.className = 'actions-empty'; n.textContent = 'Saved clips need the dataset store.';
actionsList.appendChild(n);
} else if (!actions.length) {
const n = document.createElement('div'); n.className = 'actions-empty';
n.textContent = currentUser() ? 'No animations yet — generate one above.' : 'Sign in to see your animations, or generate one above.';
actionsList.appendChild(n);
}
for (const act of actions) actionsList.appendChild(renderCard(act));
}
// Live playback state from the viewer keeps the previewing card's scrubber synced.
function onFrame(st) {
viewerState = st || viewerState;
if (previewingId && st && st.id === previewingId && aScrub) {
const max = Math.max(0, (st.num_frames || 1) - 1);
aScrub.max = max;
if (!scrubbing) aScrub.value = st.frame;
if (aLabel) aLabel.textContent = st.frame + '/' + max;
if (aPlayBtn) aPlayBtn.textContent = st.playing ? '❚❚' : '▶';
scrubFill(aScrub);
}
}
let viewerState = { id: null, frame: 0, playing: false, num_frames: 1 };
function onPreviewReady(id) {
if (id) {
previewingId = id;
if (pendingGen) { // a generation from this browser just finished
if (!currentUser()) { myClips.add(id); saveMyClips(); }
pendingGen = false;
}
}
busyId = null; adding = false;
refreshClips().then(renderActions);
}
// In-place clip switch (viewer's own picker / actions Preview): no manifest
// refetch, no clothing touch -- just keep the card highlight + branch source in sync.
function onClipChanged(id) { if (id) { previewingId = id; viewerState = { id, frame: 0, playing: false, num_frames: viewerState.num_frames }; renderActions(); } }
// Main Generate button also counts as "my" generation (anon tracking).
const _gen = document.querySelector('#generate-btn');
if (_gen) _gen.addEventListener('click', () => { pendingGen = true; });
// First map + paint so the drawer is ready before its first open.
refreshClips().then(renderActions);
// --- Kata move tree: ported from kata.js. Builds a timeline-tree from the
// manifest's continues_from edges + the localStorage kata-roots that +kata
// sets, renders it with d3, and plays a path by stitching clips client-side. ---
const treeEl = document.getElementById('kimodo-tree-svg');
const kataSelectEl = document.getElementById('kimodo-kata-select');
const kataStatusEl = document.getElementById('kimodo-kata-status');
function setKataStatus(t) { if (kataStatusEl) kataStatusEl.textContent = t; }
const SCALE_Y = 1.35, COL_GAP = 185, TOP = 40, SPINE_END_SLOP = 4;
let mode = 'path'; // 'path' = play whole path (yellow), 'move' = view one (blue)
let CTX = null; // { byId, childrenOf, parentOf, roots }
let currentRoot = null, selectedId = null, activeId = null;
let pathSet = new Set(), playSegs = null, POS = null;
let ksvg, kgAll, kgBar, kgLink, kgPlay, kPlayLine, kPlayHead, kZoom;
function buildTree(anims) {
const byId = new Map(anims.map((a) => [a.id, a]));
const childrenOf = new Map(), parentOf = new Map(), inTree = new Set();
for (const a of anims) {
const src = a.continues_from && a.continues_from.source_id;
if (src && byId.has(src)) {
inTree.add(a.id); inTree.add(src);
parentOf.set(a.id, src);
if (!childrenOf.has(src)) childrenOf.set(src, []);
childrenOf.get(src).push(a.id);
}
}
const roots = [...inTree].filter((id) => !parentOf.has(id));
for (const id of kataRoots) if (byId.has(id) && !parentOf.has(id) && !roots.includes(id)) roots.push(id);
return { byId, childrenOf, parentOf, roots };
}
function pathToRoot(id) { const p = []; let c = id; while (c) { p.unshift(c); c = CTX.parentOf.get(c); } return p; }
const numFrames = (id) => Number(CTX.byId.get(id) && CTX.byId.get(id).num_frames) || 60;
const branchFrameOf = (id) => { const cf = CTX.byId.get(id) && CTX.byId.get(id).continues_from; const f = cf ? cf.frame : null; return f == null ? 0 : Number(f); };
const childrenSorted = (id) => (CTX.childrenOf.get(id) || []).slice().sort((a, b) => branchFrameOf(a) - branchFrameOf(b));
const spineChild = (id) => {
const ch = CTX.childrenOf.get(id) || [], endFrom = numFrames(id) - 1 - SPINE_END_SLOP;
const enders = ch.filter((c) => branchFrameOf(c) >= endFrom);
return enders.length ? enders.reduce((m, c) => (branchFrameOf(c) > branchFrameOf(m) ? c : m), enders[0]) : null;
};
function descendantsOf(id) {
const out = new Set(), stack = [id];
while (stack.length) { const x = stack.pop(); if (out.has(x)) continue; out.add(x); for (const c of (CTX.childrenOf.get(x) || [])) stack.push(c); }
return out;
}
const fullLabel = (id) => ((CTX.byId.get(id) && CTX.byId.get(id).prompt) || id).replace(/^(a martial artist|a person|the practitioner|the martial artist)\s+/i, '').trim();
const nodeLabel = (id) => { const t = fullLabel(id); return t.length > 22 ? t.slice(0, 21) + '…' : t; };
function computeLayout() {
POS = new Map();
let nextCol = 0;
const place = (id, col, yStart) => {
POS.set(id, { x: col * COL_GAP, y: yStart, nf: numFrames(id) });
const sp = spineChild(id);
for (const c of childrenSorted(id)) {
const cy = yStart + branchFrameOf(c) * SCALE_Y;
if (c === sp) place(c, col, cy); else { nextCol += 1; place(c, nextCol, cy); }
}
};
if (currentRoot && CTX.byId.has(currentRoot)) place(currentRoot, 0, TOP);
}
function setupGraph() {
treeEl.innerHTML = '';
ksvg = d3.select(treeEl);
kgAll = ksvg.append('g');
kgBar = kgAll.append('g'); kgLink = kgAll.append('g'); kgPlay = kgAll.append('g');
const kgNode = kgAll.append('g'); kgAll._node = kgNode;
kPlayLine = kgPlay.append('line').attr('stroke', '#fff3a0').attr('stroke-width', 5).attr('stroke-linecap', 'round').style('display', 'none');
kPlayHead = kgPlay.append('circle').attr('r', 4).attr('fill', '#ffffff').style('display', 'none');
kZoom = d3.zoom().scaleExtent([0.15, 2.5]).on('zoom', (e) => kgAll.attr('transform', e.transform));
ksvg.call(kZoom);
}
const modeColor = () => (mode === 'path' ? '#ffe14a' : '#4ea8ff');
function nodeColor(id) {
if (id === activeId) return modeColor();
if (pathSet.has(id)) return '#e0a24a';
if (id === selectedId) return modeColor();
if ((CTX.childrenOf.get(id) || []).length) return '#5fb98c';
return '#8a8a93';
}
const nodeRadius = (id) => (id === activeId ? 9 : id === selectedId ? 7 : 5);
const barColor = (id) => (id === activeId ? modeColor() : pathSet.has(id) ? '#e0a24a' : id === selectedId ? modeColor() : '#3f3f48');
function refreshNodeStyles() {
if (kgAll && kgAll._node) kgAll._node.selectAll('g.n circle').attr('fill', (d) => nodeColor(d.id)).attr('r', (d) => nodeRadius(d.id));
if (kgBar) kgBar.selectAll('line.bar').attr('stroke', (d) => barColor(d.id)).attr('stroke-width', (d) => (d.id === activeId ? 5 : 3));
if (kgLink) kgLink.selectAll('path.conn').attr('stroke', (d) => (pathSet.has(d.id) ? '#e0a24a' : '#4a4a52'));
}
function updateGraph() {
const kgNode = kgAll._node;
const nodes = [...POS.keys()].map((id) => Object.assign({ id, label: nodeLabel(id) }, POS.get(id)));
kgBar.selectAll('line.bar').data(nodes, (d) => d.id).join('line').attr('class', 'bar')
.attr('x1', (d) => d.x).attr('y1', (d) => d.y).attr('x2', (d) => d.x).attr('y2', (d) => d.y + d.nf * SCALE_Y)
.attr('stroke-linecap', 'round').attr('stroke', (d) => barColor(d.id)).attr('stroke-width', (d) => (d.id === activeId ? 5 : 3));
const conns = [];
for (const id of POS.keys()) {
const p = CTX.parentOf.get(id);
if (p && POS.has(p)) { const pp = POS.get(p), cc = POS.get(id); conns.push({ id, x1: pp.x, y: pp.y + branchFrameOf(id) * SCALE_Y, x2: cc.x }); }
}
kgLink.selectAll('path.conn').data(conns, (d) => d.id).join('path').attr('class', 'conn').attr('fill', 'none')
.attr('stroke-width', 1.5).attr('stroke', (d) => (pathSet.has(d.id) ? '#e0a24a' : '#4a4a52'))
.attr('d', (d) => 'M' + d.x1 + ',' + d.y + ' L' + d.x2 + ',' + d.y);
const node = kgNode.selectAll('g.n').data(nodes, (d) => d.id);
const nEnter = node.enter().append('g').attr('class', 'n').style('cursor', 'pointer').on('click', (e, d) => onNodeClick(d.id));
nEnter.append('circle');
nEnter.append('title');
nEnter.append('text').attr('dy', '0.32em').attr('x', 9).attr('font-size', '12px').attr('fill', '#dcdce0')
.attr('paint-order', 'stroke').attr('stroke', '#1c1c1f').attr('stroke-width', 3);
const nAll = nEnter.merge(node);
nAll.attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')');
nAll.select('circle').attr('fill', (d) => nodeColor(d.id)).attr('r', (d) => nodeRadius(d.id));
nAll.select('text').text((d) => d.label);
nAll.select('title').text((d) => fullLabel(d.id));
node.exit().remove();
}
function buildGraph() {
setupGraph(); computeLayout(); updateGraph();
const w = treeEl.clientWidth || 460;
ksvg.call(kZoom.transform, d3.zoomIdentity.translate(w / 2, 24).scale(0.7));
}
function onNodeClick(id) {
if (id === selectedId) mode = (mode === 'path' ? 'move' : 'path'); else mode = 'path';
select(id);
}
// --- client-side stitch (port of run_motion_api.py stitch_path) ---
function poseHeading(P0) { const d2 = P0[2][2] - P0[1][2], d0 = P0[2][0] - P0[1][0]; return Math.atan2(d2, -d0); }
async function fetchRecord(id) {
// A clip can live in the dataset (community / forked katas, the Space's in-process
// builds) OR on the remote backend (locally-generated composer moves). Try the
// dataset first (cacheable, the common case), then fall back to the remote — so a
// MIXED spine (a forked community kata with a freshly-added LOCAL move) resolves
// every clip and stitches client-side instead of failing to play.
try {
const r = await fetch(DATASET_BASE + '/animations/' + encodeURIComponent(id) + '.json', { cache: 'force-cache' });
if (r.ok) return await r.json();
} catch (e) {}
if (typeof KIMODO_BACKEND !== 'undefined' && KIMODO_BACKEND) {
const rr = await fetch(KIMODO_BACKEND + '/animations/' + encodeURIComponent(id));
if (rr.ok) return await rr.json();
}
throw new Error('record not found: ' + id);
}
async function stitchPath(ids) {
const recs = [];
for (const id of ids) recs.push(await fetchRecord(id));
const outG = [], outR = [], outP = [];
const targetFrames = []; // stitched-output frame index of each move's end (its target pose)
let alpha = 0, Tx = 0, Tz = 0;
const n = recs.length;
for (let idx = 0; idx < n; idx++) {
const r = recs[idx];
let G = (r.global_quats_xyzw || []).map((fr) => fr.map((q) => q.slice()));
let R = (r.root_positions || []).map((p) => p.slice());
let P = (r.posed_joints || []).map((fr) => fr.map((j) => j.slice()));
if (idx > 0) {
const beta = alpha - poseHeading(P[0]) + (Number(r.heading_offset) || 0) * Math.PI / 180;
const c = Math.cos(beta), s = Math.sin(beta), px = R[0][0], pz = R[0][2];
for (const p of R) { const x0 = p[0] - px, z0 = p[2] - pz; p[0] = x0 * c + z0 * s + Tx; p[2] = -x0 * s + z0 * c + Tz; }
for (const fr of P) for (const j of fr) { const x0 = j[0] - px, z0 = j[2] - pz; j[0] = x0 * c + z0 * s + Tx; j[2] = -x0 * s + z0 * c + Tz; }
const qy = Math.sin(beta / 2), qw = Math.cos(beta / 2);
for (const fr of G) for (const q of fr) {
const gx = q[0], gy = q[1], gz = q[2], gw = q[3];
q[0] = qw * gx + qy * gz; q[1] = qw * gy + qy * gw; q[2] = qw * gz - qy * gx; q[3] = qw * gw - qy * gy;
}
}
const lo = idx === 0 ? 0 : 1;
let hi = R.length;
if (idx < n - 1) {
const cf = recs[idx + 1].continues_from; let bf = cf ? cf.frame : null;
if (bf != null) { bf = bf < 0 ? R.length + bf : bf; if (bf >= 0 && bf < R.length) hi = bf + 1; }
}
const startIdx = outP.length;
for (let f = lo; f < hi; f++) { outG.push(G[f]); outR.push(R[f]); outP.push(P[f]); }
// The "target" pose of a move is its most EXPRESSIVE frame, NOT the last frame
// (usually a neutral landing/recovery). Drama = farthest joint from the pelvis
// (reach / cartwheel inversion / punch extension) + a bonus when a foot is
// ABOVE the pelvis (high kicks, somersaults, aerials).
let bestIdx = outP.length - 1, bestDrama = -1;
for (let si = startIdx; si < outP.length; si++) {
const fr = outP[si], px = fr[0][0], py = fr[0][1], pz = fr[0][2];
let maxd = 0;
for (let j = 1; j < fr.length; j++) {
const dx = fr[j][0]-px, dy = fr[j][1]-py, dz = fr[j][2]-pz, d = Math.sqrt(dx*dx+dy*dy+dz*dz);
if (d > maxd) maxd = d;
}
const footAbove = Math.max(fr[10][1], fr[11][1]) - py; // joints 10/11 = feet
const drama = maxd + Math.max(0, footAbove);
if (drama > bestDrama) { bestDrama = drama; bestIdx = si; }
}
targetFrames.push(bestIdx);
const k = hi - 1; alpha = poseHeading(P[k]); Tx = R[k][0]; Tz = R[k][2];
}
const s0 = recs[0];
return {
num_frames: outP.length, fps: Number(s0.fps) || 30,
num_joints: outP.length ? outP[0].length : 22,
bone_names: s0.bone_names || [], parents: s0.parents || [],
posed_joints: outP, global_quats_xyzw: outG, root_positions: outR,
target_frames: targetFrames, // red target skeletons drawn at each (only for multi-move katas)
// Attribution: a kata is credited to whoever authored its opening move.
created_by: s0.created_by || '', created_by_name: s0.created_by_name || '',
};
}
function f32b64(nested) {
const flat = [];
(function rec(a) { if (typeof a === 'number') flat.push(a); else for (const x of a) rec(x); })(nested);
const bytes = new Uint8Array(new Float32Array(flat).buffer);
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
return btoa(bin);
}
function viewerPayload(m, id, prompt, opts) {
// m may be a RAW stitched record (posed_joints/global_quats_xyzw/root_positions) OR an
// already-encoded preview payload (joint_data_b64/...). When it's already b64 (the
// server-stitched in-process build), pass it through verbatim instead of re-encoding
// undefined raw arrays — so the live iframe can be reused without a reload.
const b64 = !!m.joint_data_b64;
const nf = m.num_frames || m.preview_frames;
return {
id: id || m.id || '', prompt: prompt || m.prompt || '', keepCamera: !!(opts && opts.keepCamera),
num_joints: m.num_joints, num_frames: nf, preview_frames: m.preview_frames || nf,
preview_fps: m.preview_fps || m.fps, fps: m.fps || m.preview_fps,
bone_names: m.bone_names, parents: m.parents,
joint_data_b64: b64 ? m.joint_data_b64 : f32b64(m.posed_joints),
quat_data_b64: b64 ? (m.quat_data_b64 || null) : f32b64(m.global_quats_xyzw),
root_data_b64: b64 ? (m.root_data_b64 || null) : f32b64(m.root_positions),
// Red target skeletons at each move's seam (only for stitched katas, >1 move).
target_frames: (m.target_frames && m.target_frames.length > 1) ? m.target_frames : null,
created_by: m.created_by || '', created_by_name: m.created_by_name || '',
};
}
function loadIntoViewer(m, id, prompt, opts) {
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'load-payload', payload: viewerPayload(m, id, prompt, opts) }, '*');
}
// Tell the viewer to re-center on the next motion load (used when (re)opening a kata so
// a fresh editing session frames the character once) AND turn OFF the follow-camera so
// playback doesn't lock onto / track the character (only the ⌖ button enables that).
function reframeViewer() {
composeCamLock = false;
const f = previewFrame(); if (!f || !f.contentWindow) return;
f.contentWindow.postMessage({ kind: 'camera-reframe' }, '*');
f.contentWindow.postMessage({ kind: 'camera-lock', on: false }, '*');
}
async function select(id) {
selectedId = id; activeId = null; playSegs = null;
pathSet = new Set(mode === 'path' ? pathToRoot(id) : []);
refreshNodeStyles();
const path = mode === 'path' ? pathToRoot(id) : [id];
setKataStatus(mode === 'move' ? 'loading move…' : 'stitching ' + path.length + ' moves…');
try {
const m = await stitchPath(path);
loadIntoViewer(m, path.length === 1 ? id : '', fullLabel(id));
playSegs = []; let f0 = 0;
path.forEach((nid, i) => {
const nf = numFrames(nid), lo = i === 0 ? 0 : 1; let hi = nf;
if (i < path.length - 1) { const cf = CTX.byId.get(path[i + 1]) && CTX.byId.get(path[i + 1]).continues_from; let bf = cf ? cf.frame : null; if (bf != null) { bf = bf < 0 ? nf + bf : bf; if (bf >= 0 && bf < nf) hi = bf + 1; } }
const len = Math.max(0, hi - lo);
playSegs.push({ id: nid, start: f0, end: f0 + len, lo }); f0 += len;
});
setKataStatus(mode === 'move' ? ('move: ' + nodeLabel(id)) : ('kata path · ' + path.length + ' moves · ' + m.num_frames + 'f'));
} catch (e) { console.error(e); setKataStatus('error: ' + e.message); }
}
// Playhead: slide a dot down the active move's bar as the viewer plays.
function kataOnFrame(st) {
if (!playSegs || !playSegs.length || !POS || !kPlayLine) return;
const f = Math.round(st.frame || 0);
const seg = playSegs.find((s) => f >= s.start && f < s.end) || playSegs[playSegs.length - 1];
if (!seg) return;
if (seg.id !== activeId) { activeId = seg.id; refreshNodeStyles(); }
const pos = POS.get(seg.id); if (!pos) return;
const clipFrame = (seg.lo || 0) + (f - seg.start);
const y = pos.y + clipFrame * SCALE_Y;
kPlayLine.attr('x1', pos.x).attr('y1', pos.y).attr('x2', pos.x).attr('y2', y).style('display', '');
kPlayHead.attr('cx', pos.x).attr('cy', y).style('display', '');
}
function populateKataSelect(roots) {
if (!kataSelectEl) return;
kataSelectEl.innerHTML = '';
for (const id of roots) {
const o = document.createElement('option'); o.value = id;
o.textContent = fullLabel(id).slice(0, 40) + ' (' + descendantsOf(id).size + ' moves)';
kataSelectEl.appendChild(o);
}
if (currentRoot) kataSelectEl.value = currentRoot;
kataSelectEl.onchange = () => { currentRoot = kataSelectEl.value; selectedId = activeId = null; playSegs = null; pathSet = new Set(); buildGraph(); setKataStatus(descendantsOf(currentRoot).size + ' moves. Click a node to play.'); };
}
async function refreshKataTree() {
if (!treeEl) return;
const items = await fetchManifest();
CTX = buildTree(items);
const bar = document.querySelector('#kimodo-kata-drawer .kimodo-kata-bar');
if (!CTX.roots.length) {
currentRoot = null;
if (kPlayLine) { kPlayLine.style('display', 'none'); kPlayHead.style('display', 'none'); }
treeEl.innerHTML = '';
if (bar) bar.style.display = 'none';
setKataStatus('No katas yet. Open Actions, generate a move, then press + kata to start one.');
return;
}
if (bar) bar.style.display = '';
if (!currentRoot || !CTX.byId.has(currentRoot)) currentRoot = CTX.roots[0];
populateKataSelect(CTX.roots);
buildGraph();
setKataStatus(descendantsOf(currentRoot).size + ' moves. Click a node to play the path up to it.');
}
let STANCES = [];
let _stanceFilterWired = false;
const SMPLX22_BONES = ['pelvis','left_hip','right_hip','spine1','left_knee','right_knee','spine2',
'left_ankle','right_ankle','spine3','left_foot','right_foot','neck','left_collar','right_collar',
'head','left_shoulder','right_shoulder','left_elbow','right_elbow','left_wrist','right_wrist'];
function stanceName(m) { return m.stance_name || (m.prompt || '').replace(/^\[STANCE\]\s*/, '') || m.id; }
async function refreshStances() {
const status = document.getElementById('kimodo-stances-status');
const list = document.getElementById('kimodo-stances-list');
if (!list) return;
const fEl = document.getElementById('kimodo-stance-filter');
if (fEl && !_stanceFilterWired) { fEl.addEventListener('input', renderStances); _stanceFilterWired = true; }
if (status) status.textContent = 'loading…';
const items = await fetchManifest();
// The manifest only carries a meta subset (prompt is in it; is_stance/tags are
// not), so identify stances by their "[STANCE] …" prompt prefix.
STANCES = items.filter((m) => (m.prompt || '').startsWith('[STANCE]'));
if (status) status.textContent = STANCES.length ? (STANCES.length + ' stances') : 'no stances yet';
renderStances();
}
function renderStances() {
const list = document.getElementById('kimodo-stances-list');
const fEl = document.getElementById('kimodo-stance-filter');
if (!list) return;
const q = (fEl && fEl.value || '').trim().toLowerCase();
const match = (m) => !q || stanceName(m).toLowerCase().includes(q)
|| (m.stance_tags || []).join(' ').toLowerCase().includes(q);
list.innerHTML = '';
const shown = STANCES.filter(match);
if (!shown.length) { list.innerHTML = 'no matches
'; return; }
for (const m of shown) {
const card = document.createElement('button');
card.type = 'button'; card.className = 'kimodo-drawer-option'; card.style.cssText =
'display:block;width:100%;text-align:left;margin-bottom:6px;padding:7px 9px;line-height:1.3';
const tags = (m.stance_tags || []).map((t) => '#' + t + '').join(' ');
card.innerHTML = '' + stanceName(m) + '
'
+ '' + tags + '
';
card.onclick = async () => {
try {
const rec = await fetchRecord(m.id);
// Stance records lack num_joints and have empty bone_names; the viewer
// skips clips whose num_joints != 22, so derive them (like stitchPath does).
if (!rec.num_joints) rec.num_joints = (rec.posed_joints && rec.posed_joints[0]) ? rec.posed_joints[0].length : 22;
if (!rec.bone_names || !rec.bone_names.length) rec.bone_names = SMPLX22_BONES;
loadIntoViewer(rec, m.id, stanceName(m));
} catch (e) { console.error('stance preview failed', e); }
};
list.appendChild(card);
}
}
const SMPLX22_PARENTS = [-1,0,0,0,1,2,3,4,5,6,7,8,9,9,9,12,13,14,16,17,18,19];
let composeStances = []; // palette: [STANCE] manifest records
let composeStrip = []; // [{ id, name, prompt, advance, turn, seconds, _uid }]
let composeSelId = null; // palette chip currently previewed
let _czFirstPick = null; // CONFIRMED start stance for a NEW kata (awaiting the END)
let _czTentative = null; // stance tapped-to-preview but NOT yet confirmed (tap again to confirm)
let composeCamLock = false; // center+lock-camera toggle (the player header button)
let _czLastHint = ''; // last instruction text shown, to pulse it only on change
let _czSlideUid = null; // _uid of a just-added move to slide in from the top
let _czGenPulseUid = null; // move just generated → pulse the edit card gray→purple
let _czSelPulseUid = null; // generated move just selected to edit → pulse green→purple
let composeBusy = false;
let _czUid = 0; // monotonic id per sequence card (for enter animation)
let _czAnimateUid = null; // the one card to play the "enter" animation on next render
let composeView = 'library'; // 'library' (My Katas) | 'create' (composer)
let composeScope = 'mine'; // 'mine' | 'community'
let composeKatas = []; // [{root, label, count, ids:[spine], recipe, owner}]
const _czName = (m) => m.stance_name || (m.prompt || '').replace(/^\[STANCE\]\s*/, '') || m.id;
const _czDefaultPrompt = (name) => 'A person moves into ' + String(name || 'a pose').replace(/\s*@\d+$/, '').toLowerCase() + '.';
const _czGlyph = (n) => /kick/i.test(n) ? '🦵' : /punch|fist|strike|block/i.test(n) ? '👊' : /jump|leap|fly|somersault|flip|aerial|cartwheel|roll/i.test(n) ? '🤸' : /bow/i.test(n) ? '🙇' : /fall/i.test(n) ? '🤕' : '🧍';
// Pose thumbnails: a lightweight front-view stick figure drawn from a stance's
// frame-0 posed_joints (x = right, y = up). Cached by id; fetched lazily per card.
const _poseThumb = {};
function poseThumbSVG(P) {
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
for (const j of P) { if (j[0] < minX) minX = j[0]; if (j[0] > maxX) maxX = j[0]; if (j[1] < minY) minY = j[1]; if (j[1] > maxY) maxY = j[1]; }
const sp = Math.max(maxX - minX, maxY - minY, 0.5), pad = sp * 0.16;
const vbX = minX - pad, vbY = -(maxY + pad), vbW = (maxX - minX) + 2 * pad, vbH = (maxY - minY) + 2 * pad;
const sw = sp * 0.03, r = sp * 0.024;
let bones = '';
for (let i = 0; i < P.length; i++) { const p = SMPLX22_PARENTS[i]; if (p < 0) continue;
bones += ''; }
let dots = '';
for (const j of P) dots += '';
return '';
}
async function ensurePoseThumb(id) {
if (_poseThumb[id] != null) return _poseThumb[id];
const rec = await fetchRecord(id);
const P = rec && rec.posed_joints && rec.posed_joints[0];
_poseThumb[id] = P ? poseThumbSVG(P) : '';
return _poseThumb[id];
}
function fillPoseThumb(el, id) {
if (_poseThumb[id]) { el.innerHTML = _poseThumb[id]; return; }
el.textContent = '…';
ensurePoseThumb(id).then((svg) => { if (svg) el.innerHTML = svg; else el.textContent = '🧍'; }).catch(() => { el.textContent = '🧍'; });
}
// Canonicalise a stance's frame-0 pose: re-root pelvis to XZ origin + rotate about
// Y so the hip-line heading is 0 (Y kept, so airborne poses preserve height). Same
// convention as the backend _place_pose, so per-move turn/advance behave correctly.
function canonKeyframe(P, Q) {
const px = P[0][0], pz = P[0][2];
const P0 = P.map((j) => [j[0]-px, j[1], j[2]-pz]);
const phi = -Math.atan2(P0[2][2]-P0[1][2], -(P0[2][0]-P0[1][0]));
const c = Math.cos(phi), s = Math.sin(phi);
const Pc = P0.map((j) => [c*j[0]+s*j[2], j[1], -s*j[0]+c*j[2]]);
const qy = Math.sin(phi/2), qw = Math.cos(phi/2);
const Qc = Q.map((q) => { const gx=q[0], gy=q[1], gz=q[2], gw=q[3];
return [qw*gx+qy*gz, qw*gy+qy*gw, qw*gz-qy*gx, qw*gw-qy*gy]; });
return { posed: Pc, quats: Qc };
}
// Place a canon pose at a ground (x,z) with a Y-rotation (deg) — mirrors the backend
// _place_pose, done client-side so the move's 2D placement needs no backend change.
function placePose(posed, quats, rotDeg, tx, tz) {
const th = (rotDeg || 0) * Math.PI / 180, c = Math.cos(th), s = Math.sin(th);
const P = posed.map((j) => [c*j[0]+s*j[2] + (tx||0), j[1], -s*j[0]+c*j[2] + (tz||0)]);
const qy = Math.sin(th/2), qw = Math.cos(th/2);
const Q = quats.map((q) => { const gx=q[0], gy=q[1], gz=q[2], gw=q[3];
return [qw*gx+qy*gz, qw*gy+qy*gw, qw*gz-qy*gx, qw*gw-qy*gy]; });
return { posed: P, quats: Q };
}
async function previewComposeStance(id, name) {
try {
const rec = await fetchRecord(id);
if (!rec.num_joints) rec.num_joints = (rec.posed_joints && rec.posed_joints[0]) ? rec.posed_joints[0].length : 22;
if (!rec.bone_names || !rec.bone_names.length) rec.bone_names = SMPLX22_BONES;
_czViewerHasPreview = false; // a single browsed stance is loaded, not the kata preview
loadIntoViewer(rec, id, name, { keepCamera: true }); // browsing stances must not jog the camera
} catch (e) { console.error('compose preview failed', e); }
}
function setComposeStatus(t) { const s = document.getElementById('kimodo-compose-status'); if (s) s.textContent = t || ''; }
// No-op retained so existing call sites stay valid: the composer is a normal side
// drawer again (positioned by .kimodo-drawer CSS), not a JS-anchored bottom bar.
function positionComposeBar() {}
// Stable per-stance number (#1, #2…) for easy identification across the picker
// and the sequence cards.
let _stanceNum = {};
function stanceBadge(id) {
const b = document.createElement('span'); b.className = 'kimodo-cz-thumbnum';
b.textContent = '#' + (_stanceNum[id] || '?'); return b;
}
async function composeOpen() {
const items = await fetchManifest();
composeStances = items.filter((m) => (m.prompt || '').startsWith('[STANCE]'));
_stanceNum = {}; composeStances.forEach((m, i) => { _stanceNum[m.id] = i + 1; });
composeKatas = katasFromManifest(items);
renderComposePalette(); renderComposeStrip();
setComposeView(composeView); // render whichever view is active (library by default)
if (!composeCanBuild()) setComposeStatus('⚠ Build unavailable here (no backend / compose bridge). Preview still works.');
}
// --- View switching: library (home) | create | player ----------------------
// The views are a horizontal carousel (library left of create left of player);
// the active one is centered, the rest slide off to their side.
const _czOrder = { library: 0, create: 1, player: 2 };
let composeCurrentKata = null; // the kata currently being viewed/edited (for the name overlay)
let composeEditing = false; // create view is editing an existing kata (vs a fresh "New kata")
function setComposeView(view, opts) {
opts = opts || {};
composeView = view;
if (view !== 'create') closeStancePicker();
// render BEFORE sliding so the incoming view has content during the animation
if (view === 'library') renderComposeLibrary();
if (view === 'create') renderComposeStrip();
if (view === 'player') renderComposePlayer();
const wrap = document.getElementById('kimodo-cz-views');
if (opts.noSlide && wrap) wrap.classList.add('cz-noslide'); // switch in place (edit ↔ view)
[['library', 'kimodo-cz-library'], ['create', 'kimodo-cz-create'], ['player', 'kimodo-cz-player']].forEach(([name, id]) => {
const el = document.getElementById(id); if (!el) return;
el.classList.remove('cz-active', 'cz-left', 'cz-right');
el.classList.add(_czOrder[name] === _czOrder[view] ? 'cz-active' : (_czOrder[name] < _czOrder[view] ? 'cz-left' : 'cz-right'));
});
if (opts.noSlide && wrap) { void wrap.offsetWidth; setTimeout(() => wrap.classList.remove('cz-noslide'), 30); }
const ct = document.getElementById('kimodo-cz-createtitle'); if (ct) ct.textContent = composeEditing ? 'Editing' : 'New kata';
updateComposeViewerName();
updateComposeLaunch(); // launch button only shows in Create
}
// The selected-kata name lives on the 3D view (top-left) while a kata is open
// (playing OR editing). "New kata" (fresh) shows no overlay.
function updateComposeViewerName() {
const box = document.getElementById('kimodo-cz-vname'); if (!box) return;
const showing = (composeView === 'player' || (composeView === 'create' && composeEditing)) && composeCurrentKata;
if (showing) {
const drawer = document.getElementById('kimodo-compose-drawer');
const w = drawer ? Math.round(drawer.getBoundingClientRect().width) : 230;
box.style.left = (w + 16) + 'px';
renderViewerName(composeCurrentKata.label || composeCurrentKata.name || 'Kata');
// Attribution beside the name: "by X" for community katas, "⑂ forked from Y" for forks.
const byEl = document.getElementById('kimodo-cz-vname-by');
if (byEl) {
const k = composeCurrentKata; const bits = [];
if (!k.local && k.contributor) bits.push('by ' + k.contributor);
if (k.forked_from) bits.push('⑂ ' + (k.forked_from.contributor_name || 'anonymous'));
byEl.textContent = bits.join(' · ');
}
box.classList.add('show');
} else {
box.classList.remove('show');
}
}
function renderViewerName(name) {
const txt = document.getElementById('kimodo-cz-vname-text'); if (txt) txt.textContent = name;
}
function startRenameKata() {
const box = document.getElementById('kimodo-cz-vname'); const k = composeCurrentKata; if (!box || !k) return;
const cur = k.label || k.name || 'Kata';
box.innerHTML = '';
const inp = document.createElement('input'); inp.type = 'text'; inp.value = cur;
box.appendChild(inp); inp.focus(); inp.select();
const finish = (save) => {
const v = (inp.value || '').trim();
if (save && v) {
k.label = v; k.name = v;
if (composePlayer && composePlayer.k === k) { composePlayer.k.label = v; }
if (k.local && k.lid) { const a = loadMyKatas(); const e = a.find((x) => x.lid === k.lid); if (e) { e.name = v; saveMyKatas(a); } }
}
restoreViewerNameDom(); renderViewerName(k.label || 'Kata');
};
inp.onkeydown = (ev) => { if (ev.key === 'Enter') finish(true); else if (ev.key === 'Escape') finish(false); };
inp.onblur = () => finish(true);
}
// Rebuild the name box's text + edit-icon DOM after an inline rename. Both the
// text and the ✎ start a rename.
function restoreViewerNameDom() {
const box = document.getElementById('kimodo-cz-vname'); if (!box) return;
box.innerHTML = '';
const txt = document.getElementById('kimodo-cz-vname-text'); if (txt) { txt.style.cursor = 'text'; txt.onclick = startRenameKata; }
const ed = document.getElementById('kimodo-cz-vname-edit'); if (ed) ed.onclick = startRenameKata;
}
// Start a fresh kata (+ Create a kata).
function newComposeKata() {
composeStrip = []; composeSelId = null; composePlayer = null; composeKataEditable = true; _prevScrubRegion = null;
reframeViewer(); // fresh kata → re-center the camera once on the first stance load
// a fresh local kata, persisted to localStorage as soon as a move is generated
composeCurrentKata = { local: true, lid: 'k' + Date.now().toString(36), name: 'Untitled kata', label: 'Untitled kata', recipe: [], ids: [] };
setComposeView('player');
setComposeStatus('');
}
// Animate the "+ Add a stance" button sliding down into view (used when entering
// edit on an existing kata, so it reads as "controls became editable" in place).
function revealAddStance(animate) {
const b = document.getElementById('kimodo-cz-addstance'); if (!b) return;
b.classList.remove('cz-slidedown');
if (animate) { void b.offsetWidth; b.classList.add('cz-slidedown'); }
}
function openStancePicker() {
const pk = document.getElementById('kimodo-cz-picker'); if (!pk) return;
_czFirstPick = null; _czTentative = null; // fresh selection each open
renderComposePalette(); pk.style.display = '';
void pk.offsetWidth; pk.classList.add('open'); // slide in from the right
const f = document.getElementById('kimodo-cz-pfilter'); if (f) { f.value = ''; }
}
function closeStancePicker() {
const pk = document.getElementById('kimodo-cz-picker'); if (!pk) return;
_czFirstPick = null; _czTentative = null;
pk.classList.remove('open');
setTimeout(() => { if (!pk.classList.contains('open')) pk.style.display = 'none'; }, 240);
}
// --- My Katas library ------------------------------------------------------
// Reconstruct kata chains from the manifest (root = no continues_from but has a
// continuation), following the end-frame child as the spine. Carries the root's
// compose_recipe + compose_owner (stamped at build time) for listing + editing.
function katasFromManifest(items) {
const byId = {}; items.forEach((m) => { if (m.id) byId[m.id] = m; });
const children = {};
items.forEach((m) => {
const src = (m.continues_from || {}).source_id;
if (src && byId[src] && m.id) (children[src] = children[src] || []).push(m.id);
});
const branchFrame = (cid) => { const f = (byId[cid].continues_from || {}).frame; return f == null ? -1 : Number(f); };
const out = [];
for (const m of items) {
const rid = m.id;
if (!rid || (m.continues_from || {}).source_id || !children[rid]) continue; // only roots that start a chain
const path = [rid], seen = { [rid]: 1 }; let cur = rid;
while (children[cur]) {
const nxt = children[cur].reduce((a, b) => branchFrame(b) > branchFrame(a) ? b : a, children[cur][0]);
if (seen[nxt]) break;
path.push(nxt); seen[nxt] = 1; cur = nxt;
}
// count all descendants (not just the spine) + 1 for the root
const all = {}; const stack = [rid];
while (stack.length) { const x = stack.pop(); (children[x] || []).forEach((c) => { if (!all[c]) { all[c] = 1; stack.push(c); } }); }
// Recipe = the most complete one stamped along the chain (every move stamps the
// recipe-so-far; the LAST move holds them all — the root's is truncated to move 1).
let recipe = m.compose_recipe || null;
for (const sid of path) { const sm = byId[sid]; const r = sm && sm.compose_recipe; if (r && r.length && (!recipe || r.length > recipe.length)) recipe = r; }
let label = recipe && recipe.length ? recipeLabel(recipe) : (m.prompt || rid);
// Attribution: the root's creator name, else the first spine move that has one
// (older roots were built anonymously but their later moves may carry a name).
let contributor = m.created_by_name || m.created_by || '';
if (!contributor) { for (const sid of path) { const sm = byId[sid]; if (sm && (sm.created_by_name || sm.created_by)) { contributor = sm.created_by_name || sm.created_by; break; } } }
out.push({ root: rid, label: String(label).slice(0, 40), count: Object.keys(all).length + 1, ids: path, recipe,
owner: m.compose_owner || '', contributor: contributor || '', contributor_id: m.compose_owner || m.created_by || '',
forked_from: m.compose_forked_from || null, created: m.created_at || 0 });
}
out.sort((a, b) => b.created - a.created);
return out;
}
function recipeLabel(recipe) {
const names = recipe.map((r) => (r.name || '').replace(/\s*@\d+$/, '')).filter(Boolean);
if (!names.length) return 'Kata';
return names[0] + (names.length > 1 ? ' → ' + names[names.length - 1] : '');
}
function renderComposeLibrary() {
const list = document.getElementById('kimodo-cz-katalist'); if (!list) return;
document.querySelectorAll('#kimodo-cz-scope .kimodo-cz-segbtn').forEach((b) => b.classList.toggle('active', b.dataset.scope === composeScope));
// Mine = the localStorage entries (recipe-backed, instant); Community = the
// server manifest. Mine cards are always recipe-editable; their filmstrips draw
// from stance ids, so they don't depend on per-clip record files.
let shown;
if (composeScope === 'mine') {
shown = loadMyKatas().map((e) => ({ lid: e.lid, root: e.root || null, label: e.name, count: (e.recipe || []).length, recipe: e.recipe, ids: e.ids || null, forked_from: e.forked_from || null, published: !!e.published, published_root: e.published_root || null, local: true }));
} else {
shown = composeKatas;
}
list.innerHTML = '';
// + Create a kata — always at the top of the list.
const createCard = document.createElement('div'); createCard.className = 'kimodo-cz-createcard';
createCard.innerHTML = '+Create a kata';
createCard.onclick = () => newComposeKata();
list.appendChild(createCard);
if (!shown.length) {
const e = document.createElement('div'); e.className = 'kimodo-cz-libempty';
e.textContent = composeScope === 'mine'
? 'No katas yet — tap + Create a kata above.'
: 'No katas in this dataset yet.';
list.appendChild(e); return;
}
for (const k of shown) {
const card = document.createElement('div'); card.className = 'kimodo-cz-katacard';
// Mine = delete (your draft); Community = fork (copy into Mine — never delete others').
if (k.local) {
const del = document.createElement('button'); del.type = 'button'; del.className = 'kimodo-cz-katadel'; del.textContent = '✕'; del.title = 'delete kata';
del.onclick = (e) => { e.stopPropagation(); deleteKata(k); };
card.appendChild(del);
} else {
const fork = document.createElement('button'); fork.type = 'button'; fork.className = 'kimodo-cz-katafork'; fork.textContent = '⑂ Fork'; fork.title = 'Fork into My Katas';
fork.onclick = (e) => { e.stopPropagation(); forkKata(k); };
card.appendChild(fork);
}
const head = document.createElement('div'); head.className = 'kimodo-cz-katahead';
const nm = document.createElement('div'); nm.className = 'kimodo-cz-kataname'; nm.textContent = k.label;
head.appendChild(nm);
// Attribution: who made it (Community), and fork lineage if any.
const meta = document.createElement('div'); meta.className = 'kimodo-cz-katameta';
if (!k.local) { const by = document.createElement('div'); by.className = 'kimodo-cz-kataby'; by.textContent = 'by ' + (k.contributor || 'anonymous'); meta.appendChild(by); }
if (k.forked_from) { const ff = document.createElement('div'); ff.className = 'kimodo-cz-katafrom'; ff.textContent = '⑂ forked from ' + (k.forked_from.contributor_name || 'anonymous'); meta.appendChild(ff); }
// filmstrip: the recipe's stance poses (start/end accented), else the spine clips
const film = document.createElement('div'); film.className = 'kimodo-cz-film';
const frames = k.recipe && k.recipe.length ? k.recipe.map((r) => r.id) : k.ids;
frames.forEach((sid, i) => {
const fr = document.createElement('div');
fr.className = 'kimodo-cz-frame' + (i === 0 ? ' start' : (i === frames.length - 1 ? ' end' : ''));
fillPoseThumb(fr, sid);
film.appendChild(fr);
});
card.appendChild(head);
if (meta.childNodes.length) card.appendChild(meta);
card.appendChild(film);
// Local-only: publish a completed kata to the community. On the Space katas
// auto-publish (no backend), so the button only appears on local (KIMODO_BACKEND),
// and only once the kata has at least one generated move clip.
if (k.local && KIMODO_BACKEND && k.ids && k.ids.length) {
const pub = document.createElement('button'); pub.type = 'button';
pub.className = 'kimodo-cz-katapub' + (k.published ? ' published' : '');
pub.textContent = k.published ? '✓ Published to community' : '⬆ Publish to community';
pub.disabled = !!k.published;
pub.onclick = (e) => { e.stopPropagation(); if (!k.published) publishKata(k, pub); };
card.appendChild(pub);
}
// Tapping the card = watch the kata (no buttons on the card). Editing lives in
// the player header for user-created (recipe-backed) katas.
card.onclick = () => playKata(k);
list.appendChild(card);
}
}
// editKata is now folded into the unified kata view (a Mine kata opens editable);
// kept as a thin alias.
function editKata(k) { playKata(k); }
// --- Unified kata view state ----------------------------------------------
let composePlayer = null; // scrubber state { numFrames, fps, poseFrac:[..], curFrame, playing } | null
let _czViewerHasPreview = false; // true = the viewer holds the kata preview; false = a browsed single stance
let composeKataEditable = false;
let composeGeneratingIdx = -1; // move index currently generating (for the loading button)
let composeJustGenIdx = -1; // move index just generated (for the gray→normal transition)
let _prevScrubRegion = null; // last rail span, so the scrubber animates as it grows
let _czPopUids = null; // card _uids to play the "juicy add" pop animation on
let composeSelMove = -1; // move index selected for 3D placement (overlaid figure)
const _czTransport = (action, frame) => { const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'transport', action, frame }, '*'); };
const _czTime = (frame, fps) => { const s = Math.max(0, Math.round((frame || 0) / (fps || 30))); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); };
// Fraction [0..1] at which each pose is reached, from the recipe's move durations
// (clip num_frames ≈ seconds*fps), else evenly spaced.
function posefracFromKata(k) {
const rec = k.recipe;
if (rec && rec.length > 1) {
const segs = rec.slice(1).map((r) => Math.max(0.3, Number(r.seconds) || 2.2));
const tot = segs.reduce((a, b) => a + b, 0) || 1;
const out = [0]; let c = 0; for (const s of segs) { c += s; out.push(c / tot); } return out;
}
const n = ((k.ids && k.ids.length) || 1) + 1;
return Array.from({ length: n }, (_, i) => i / Math.max(1, n - 1));
}
// The contiguous run of generated (clean) moves from move 1 — the part that has a
// playable, stitchable preview. { ids:[clip], secs:[seconds] }.
function generatedPrefix() {
const ids = [], secs = [];
for (let i = 1; i < composeStrip.length; i++) { const c = composeStrip[i]; if (c.clipId && !c.dirty) { ids.push(c.clipId); secs.push(Number(c.seconds) || 2.2); } else break; }
return { ids, secs };
}
function posefracFromStrip() {
const { secs } = generatedPrefix();
const tot = secs.reduce((a, b) => a + b, 0) || 1; let cum = 0; const pf = [0];
for (const s of secs) { cum += s; pf.push(cum / tot); }
while (pf.length < composeStrip.length) pf.push(1);
return pf;
}
// Re-stitch a kata's spine clips from wherever they live: the remote backend (local
// dev — the composer's clips are saved there, not in the HF dataset), else the
// HF-dataset JS stitch (the deployed Space's in-process clips).
async function composeStitch(ids, prebuilt) {
// The in-process (Space) build hands back a ready server-stitched preview — use it
// directly so we neither reload the iframe nor re-fetch the just-saved (CDN-lagging) clips.
if (prebuilt) return prebuilt;
if (KIMODO_BACKEND) {
// Try the remote backend (holds locally-built clips). If it doesn't have these
// clips (e.g. a forked COMMUNITY kata, whose clips live in the dataset), fall
// back to the client-side dataset stitch so it still plays.
try {
const r = await fetch(KIMODO_BACKEND + '/stitch_path', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, save: false }) });
if (r.ok) return await r.json();
} catch (e) {}
return await stitchPath(ids);
}
return await stitchPath(ids);
}
// Open a kata in the unified view. Your own (recipe-backed) katas open editable
// with per-move controls + the generated preview; community katas are read-only.
async function playKata(k, prebuilt, keepCam) {
const editable = !!(k.local && k.recipe && k.recipe.length);
composeCurrentKata = k; composeKataEditable = editable; composePlayer = null; _prevScrubRegion = null;
if (!keepCam) reframeViewer(); // fresh session → re-center the camera once on the first motion load
// Build the working strip: stances + per-move data, with clipIds from the saved spine.
if (k.recipe && k.recipe.length) {
composeStrip = k.recipe.map((r, idx) => ({ id: r.id, name: r.name || r.id, prompt: r.prompt || '', seconds: r.seconds || 2.2, tx: r.tx || 0, tz: (r.tz != null ? r.tz : (r.advance || 0)), rot: (r.rot != null ? r.rot : (r.turn || 0)), clipId: (idx >= 1 && k.ids) ? (k.ids[idx - 1] || null) : null, dirty: false, expanded: false, _uid: ++_czUid }));
} else {
composeStrip = (k.ids || []).map((id, i) => ({ id: id, name: 'pose ' + i, prompt: '', seconds: 2.2, tx: 0, tz: 0, rot: 0, clipId: i >= 1 ? k.ids[i - 1] : null, dirty: false, expanded: false, _uid: ++_czUid }));
}
composeStrip.forEach((c, k2) => { if (k2 >= 1 && c.clipId) _czSnapshot(c); }); // saved state for revert-on-discard
composeSelMove = -1;
setComposeView('player');
setComposeStatus('loading kata…');
const ids = (k.ids && k.ids.length) ? k.ids : null;
if (!ids) { setComposeStatus(editable ? 'Tap a move’s GENERATE to create it.' : 'nothing to play'); composePlayer = null; renderComposePlayer(); return; }
try {
const m = await composeStitch(ids, prebuilt);
m.num_joints = m.num_joints || ((m.posed_joints && m.posed_joints[0]) ? m.posed_joints[0].length : 22);
if (!m.bone_names || !m.bone_names.length) m.bone_names = SMPLX22_BONES;
if (!m.parents || !m.parents.length) m.parents = SMPLX22_PARENTS;
_czViewerHasPreview = true;
loadIntoViewer(m, ids.length === 1 ? ids[0] : '', k.label, keepCam ? { keepCamera: true } : undefined);
composePlayer = { numFrames: m.num_frames || m.preview_frames || 1, fps: m.fps || m.preview_fps || 30, poseFrac: posefracFromStrip(), curFrame: 0, playing: true, genCount: ids.length, boundaries: _czBoundaries(m), _payload: m };
renderComposePlayer(); updateComposeViewerName();
setTimeout(() => _czTransport('play'), 120);
setComposeStatus('');
} catch (e) { console.error(e); setComposeStatus('could not load kata: ' + e.message); renderComposePlayer(); }
}
// --- Per-move generation (independent: each move pins its two stances) -------
async function generateMove(i) {
if (composeBusy) return;
const c = composeStrip[i]; if (!c || i < 1) return;
composeBusy = true; composeGeneratingIdx = i; renderComposePlayer(); // button → loading state
setComposeStatus('generating move ' + i + '…');
try {
const recA = await fetchRecord(composeStrip[i - 1].id), recB = await fetchRecord(c.id);
const A = canonKeyframe(recA.posed_joints[0], recA.global_quats_xyzw[0]);
const B0 = canonKeyframe(recB.posed_joints[0], recB.global_quats_xyzw[0]);
const B = placePose(B0.posed, B0.quats, c.rot || 0, c.tx || 0, c.tz || 0); // place the END pose in the move's local frame
const move = { prompt: (c.prompt || _czDefaultPrompt(c.name)).trim(), seconds: c.seconds || 2.2,
keyframes: [ { posed_joints: A.posed, global_quats_xyzw: A.quats, frac: 0.0, turn_deg: 0, advance: 0 },
{ posed_joints: B.posed, global_quats_xyzw: B.quats, frac: 1.0, turn_deg: 0, advance: 0 } ] };
// Space (no remote backend): route this ONE move through the in-process bridge;
// completion + clipId assignment arrive via __kimodoOnComposeBuilt.
if (!KIMODO_BACKEND) { generateMoveInProcess(i, move); return; }
// Local: direct fetch to the remote backend, await the clip id.
const r = await fetch(KIMODO_BACKEND + '/generate_between', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ num_steps: 60, post_processing: true }, move)) });
if (!r.ok) throw new Error(r.status + ' ' + (await r.text()).slice(0, 120));
c.clipId = (await r.json()).id; c.dirty = false; c.expanded = false;
_czSnapshot(c); // saved state for revert-on-discard
saveCurrentKata();
composeBusy = false; composeGeneratingIdx = -1; composeJustGenIdx = i; clearPlaceMode(); // gray → normal on re-render
await updateKataPreview(i); // play from this move's start pose, not the kata top
_czGenPulseUid = c._uid; // set before the FINAL render so the card pulses gray→purple
selectMove(i); // keep the move selected (so it stays open + plays solo)
setComposeStatus('');
} catch (e) { composeBusy = false; composeGeneratingIdx = -1; console.error(e); renderComposePlayer(); setComposeStatus('generate failed: ' + e.message); }
}
// Re-stitch the generated prefix and refresh the scrubbable preview. When `startMove`
// is given, playback begins at THAT move's start pose (not frame 0) — so after a
// GENERATE you see the kata pick up from the just-built move, not replay from the top.
async function updateKataPreview(startMove, prebuilt) {
const { ids } = generatedPrefix();
if (!ids.length && !prebuilt) { composePlayer = null; renderComposePlayer(); return; }
try {
let m;
if (prebuilt) {
// Server-stitched preview handed back from the in-process (Space) build. Reuse the
// LIVE viewer iframe (no reload) and DON'T re-fetch the just-saved clips — the
// dataset CDN lags right after a commit, so a client re-stitch would 404.
m = prebuilt;
// The viewer plays `preview_frames` (the server downsamples to <=120). Align
// num_frames to that so the scrubber + seek don't run past the on-screen clip.
m.num_frames = m.preview_frames || m.num_frames || 1;
} else {
m = await composeStitch(ids);
m.num_joints = (m.posed_joints && m.posed_joints[0]) ? m.posed_joints[0].length : 22;
if (!m.bone_names || !m.bone_names.length) m.bone_names = SMPLX22_BONES;
m.parents = SMPLX22_PARENTS;
}
_czViewerHasPreview = true;
loadIntoViewer(m, '', (composeCurrentKata && composeCurrentKata.label) || 'kata', { keepCamera: true });
composePlayer = { numFrames: m.num_frames || 1, fps: m.fps || m.preview_fps || 30, poseFrac: posefracFromStrip(), curFrame: 0, playing: true, genCount: ids.length, boundaries: _czBoundaries(m), _payload: m };
renderComposePlayer();
// Start at the requested move's start frame (pf[startMove-1]) instead of the top.
let sf = 0;
if (startMove && startMove >= 1) {
const pf = composePlayer.poseFrac, fr = pf[Math.min(startMove - 1, pf.length - 1)] || 0;
sf = Math.round(fr * ((composePlayer.numFrames || 1) - 1));
}
composePlayer.curFrame = sf;
setTimeout(() => { if (sf > 0) { _czTransport('seek', sf); } _czTransport('play'); updatePlayerProgress(sf, true); }, 120);
} catch (e) { console.error(e); setComposeStatus('preview failed: ' + e.message); }
}
// Persist the working kata (recipe + generated prefix ids) to localStorage.
function saveCurrentKata() {
const k = composeCurrentKata; if (!k || !k.local) return;
k.recipe = composeRecipe();
const { ids } = generatedPrefix(); k.ids = ids; k.root = ids[0] || null;
if (!k.label) k.label = recipeLabel(k.recipe);
k.name = k.label;
const a = loadMyKatas(); const idx = a.findIndex((x) => x.lid === k.lid);
const entry = { lid: k.lid, name: k.label, recipe: k.recipe, ids: k.ids, root: k.root, forked_from: k.forked_from || (idx >= 0 ? a[idx].forked_from : null) || null, ts: (idx >= 0 && a[idx].ts) ? a[idx].ts : Date.now() };
if (idx >= 0) a[idx] = entry; else a.unshift(entry);
saveMyKatas(a);
}
// Fork a community kata into My Katas: copy its recipe + spine (clips are reused so it
// plays instantly), keep attribution to the original author, and open it editable.
function forkKata(k) {
const ids = Array.isArray(k.ids) ? k.ids.slice() : [];
let recipe = (k.recipe && k.recipe.length) ? JSON.parse(JSON.stringify(k.recipe)) : [];
// The COMPLETE kata is the spine (ids). Older/community katas often stamped only a
// truncated recipe (the first move's stances) or none at all — reconstruct the
// missing moves from the spine clips so the fork has ALL moves. A recipe needs
// ids.length + 1 stances (stance i-1 → i = move i, clip ids[i-1]).
if (!recipe.length && ids.length) recipe.push({ id: ids[0], name: 'start', prompt: '', seconds: 2.2, tx: 0, tz: 0, rot: 0 });
while (recipe.length < ids.length + 1) {
const i = recipe.length; const cid = ids[i - 1] || ids[ids.length - 1] || ('m' + i);
recipe.push({ id: cid, name: 'move ' + i, prompt: '', seconds: 2.2, tx: 0, tz: 0, rot: 0 });
}
if (!recipe.length) { setComposeStatus('This kata can’t be forked (no moves found).'); return; }
const forked_from = { root: k.root || null, contributor: k.contributor_id || '', contributor_name: k.contributor || '' };
const entry = { lid: 'k' + Date.now().toString(36), name: (k.label || 'Kata') + ' (fork)', recipe, ids, root: (ids && ids[0]) || null, forked_from, ts: Date.now() };
const a = loadMyKatas(); a.unshift(entry); saveMyKatas(a);
composeScope = 'mine';
setComposeStatus('Forked “' + (k.label || 'kata') + '” into My Katas (' + ids.length + ' move' + (ids.length === 1 ? '' : 's') + ').');
playKata({ lid: entry.lid, root: entry.root, label: entry.name, count: recipe.length, recipe, ids, forked_from, local: true });
}
// Mark a Mine kata as published (so the card flips to "✓ Published" and won't re-publish).
function markKataPublished(lid, root) {
const a = loadMyKatas(); const e = a.find((x) => x.lid === lid);
if (e) { e.published = true; e.published_root = root || null; saveMyKatas(a); }
}
// LOCAL-ONLY: publish a completed kata to the community dataset. Locally a kata's move
// clips live on the remote and the recipe in localStorage, so nothing is in the dataset
// until this pushes it. Hands {ids, recipe, name, owner, forked_from} to the hidden
// gradio #kata-publish-btn, which fetches the clips from the remote, stamps the chain +
// attribution, and saves them so the kata appears in Community.
function publishKata(k, btnEl) {
const ids = (k.ids || []).filter(Boolean);
if (!ids.length) { setComposeStatus('Generate this kata’s moves before publishing.'); return; }
const trigger = document.querySelector('#kata-publish-btn button, #kata-publish-btn');
const payloadInput = inputInside('kata-publish-payload');
const resultInput = inputInside('kata-publish-result');
if (!trigger || !payloadInput || !resultInput) { setComposeStatus('Publish backend not found.'); return; }
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '⟳ publishing…'; }
setComposeStatus('Publishing “' + (k.label || 'kata') + '” to community…');
const anonEl = inputInside('anonymous-client-id'); const anon = anonEl ? (anonEl.value || '') : '';
resultInput.value = '';
payloadInput.value = JSON.stringify({ ids, recipe: k.recipe || null, name: k.label || '', owner: myAnonId(), forked_from: k.forked_from || null, anon_id: anon });
payloadInput.dispatchEvent(new Event('input', { bubbles: true }));
const started = Date.now();
const timer = window.setInterval(() => {
const val = resultInput.value;
if (val) { window.clearInterval(timer); finish(val); }
else if (Date.now() - started > 180000) { window.clearInterval(timer); finish('{"ok":false,"error":"timed out"}'); }
}, 500);
window.setTimeout(() => trigger.click(), 40);
function finish(val) {
let r = null; try { r = JSON.parse(val); } catch (e) {}
if (r && r.ok) {
markKataPublished(k.lid, r.root);
setComposeStatus('Published “' + (r.name || k.label || 'kata') + '” ✓ — it’s now in Community.');
renderComposeLibrary();
// Refetch the manifest so the freshly-published kata appears under Community
// (the cached composeKatas was loaded when the drawer opened).
fetchManifest().then((items) => { composeKatas = katasFromManifest(items); renderComposeLibrary(); }).catch(() => {});
} else {
if (btnEl) { btnEl.disabled = false; btnEl.textContent = '⬆ Publish to community'; }
setComposeStatus('Publish failed: ' + ((r && r.error) || 'unknown'));
}
}
}
// Space per-move bridge: hand ONE move (its two pinned stances + the already-built
// prefix) to the hidden gradio #compose-btn, which runs a single generate_between on
// the @GPU model and returns the kata-so-far. One @GPU call per GENERATE — not a
// whole-batch rebuild. Completion arrives via __kimodoOnComposeBuilt.
function generateMoveInProcess(i, move) {
const ta = document.querySelector('#compose-payload textarea, #compose-payload input');
const btn = document.querySelector('#compose-btn');
if (!ta || !btn) { composeBusy = false; composeGeneratingIdx = -1; renderComposePlayer(); setComposeStatus('generation bridge not found'); return; }
// The generated prefix (moves 1..i-1, already built) chains this move and lets the
// server stitch the preview up to here.
const prefix = [];
for (let k = 1; k < i; k++) { const pc = composeStrip[k]; if (pc.clipId && !pc.dirty) prefix.push(pc.clipId); else break; }
const payload = { move: Object.assign({ index: i }, move), prefix_ids: prefix };
payload.recipe = composeRecipe(); // stamp the FULL recipe-so-far on EVERY move, so the chain's last move always holds the complete kata (the root's is captured before later moves exist)
if (!prefix.length) { payload.owner = myAnonId(); if (composeCurrentKata && composeCurrentKata.forked_from) payload.forked_from = composeCurrentKata.forked_from; } // root move stamps owner + fork lineage
ta.value = JSON.stringify(payload); ta.dispatchEvent(new Event('input', { bubbles: true }));
composePending = true;
setTimeout(() => btn.click(), 50);
// safety: clear the loading state if the bridge never calls back.
setTimeout(() => { if (composePending) { composePending = false; composeBusy = false; composeGeneratingIdx = -1; renderComposePlayer(); setComposeStatus('still working… (check the viewer)'); } }, 600000);
}
// --- Timeline geometry: each move's card height = its duration, so the card column
// IS the timeline (a longer move = a taller card). The scrubber rail rides alongside
// over the generated region as the unified playhead. ---
const CZ_PXPS = 80; // timeline pixels per second
const CZ_MINCARD = 58; // min compact-card height so the thumb + labels stay readable
const CZ_MINREG = 88; // min active-move duration region (fits the from + end stances)
function _czDur(c) { return Math.max(0.6, Math.min(4, Number(c && c.seconds) || 2.2)); }
// Saved (generated) state of a move, so an edit can be reverted (we edit + save one
// move at a time). Snapshot when a move becomes clean; restore on discard.
function _czSnapshot(c) { c._saved = { seconds: c.seconds, prompt: c.prompt, tx: c.tx, tz: c.tz, rot: c.rot }; }
function _czRevert(c) { if (c._saved) { c.seconds = c._saved.seconds; c.prompt = c._saved.prompt; c.tx = c._saved.tx; c.tz = c._saved.tz; c.rot = c._saved.rot; } c.dirty = false; }
// px span of the moves CURRENTLY in the loaded preview (composePlayer.genCount), so
// the scrubber survives editing a move (which marks it dirty but keeps the preview).
function _czGenPx() { let px = 0; const n = (composePlayer && composePlayer.genCount) || generatedPrefix().ids.length; for (let i = 1; i <= n && i < composeStrip.length; i++) px += _czDur(composeStrip[i]) * CZ_PXPS; return px; }
// Single shared duration-drag (one set of window listeners, not one per card per
// render — avoids the leak of re-wiring on every re-render).
let _czDragState = null; // { card, heightEl, minH, i, durv, sy, sd }
function _czDragMove(e) {
const st = _czDragState; if (!st) return;
const dy = e.clientY - st.sy; if (Math.abs(dy) > 3 && st.card) st.card._dragged = true;
const c = composeStrip[st.i]; if (!c) return;
let d = st.sd + dy / CZ_PXPS; d = Math.max(0.6, Math.min(4, Math.round(d * 10) / 10));
c.seconds = d;
if (st.heightEl) st.heightEl.style.height = Math.max(st.minH || CZ_MINCARD, d * CZ_PXPS) + 'px';
if (st.durv) st.durv.textContent = d.toFixed(1) + 's';
}
function _czDragEnd() {
const st = _czDragState; if (!st) return; _czDragState = null;
const c = composeStrip[st.i]; if (!c) return;
if (c.clipId) c.dirty = true; // changed a generated move → needs REFRESH (keep the preview/scrubber)
saveCurrentKata();
renderComposePlayer(); // refresh the dirty color + REFRESH button + card height
}
window.addEventListener('pointermove', _czDragMove);
window.addEventListener('pointerup', _czDragEnd);
window.addEventListener('pointercancel', _czDragEnd);
function wireDurationDrag(grip, card, heightEl, minH, i, durv) {
grip.addEventListener('pointerdown', (e) => {
if (card) card._dragged = false;
_czDragState = { card, heightEl, minH, i, durv, sy: e.clientY, sd: _czDur(composeStrip[i]) };
try { grip.setPointerCapture(e.pointerId); } catch (x) {}
e.preventDefault(); e.stopPropagation();
});
}
// Unified kata view (#kimodo-cz-player): the timeline of move cards (height = duration)
// + the scrubber rail over the generated region, and the active move's controls below.
function renderComposePlayer() {
const root = document.getElementById('kimodo-cz-player'); if (!root) return;
const k = composeCurrentKata; if (!k) { root.innerHTML = ''; return; }
const editable = composeKataEditable;
const poses = composeStrip;
const last = poses.length - 1;
const hasPreview = !!(composePlayer && composePlayer.numFrames > 1);
root.innerHTML = '';
// header: back + (optional play/pause)
const head = document.createElement('div'); head.className = 'kimodo-cz-phead';
const back = document.createElement('button'); back.type = 'button'; back.className = 'kimodo-cz-pback'; back.textContent = '‹'; back.title = 'Back to katas';
back.onclick = () => { clearPlaceMode(); composePlayer = null; composeCurrentKata = null; composeKataEditable = false; _czTransport('pause'); setComposeView('library'); };
const spacer = document.createElement('div'); spacer.style.flex = '1';
head.appendChild(back); head.appendChild(spacer);
if (hasPreview) {
// Center + lock-on-character (left of play/pause). Toggle: press to center & lock,
// press again to free the camera.
const lock = document.createElement('button'); lock.type = 'button'; lock.id = 'kimodo-cz-plock'; lock.className = 'kimodo-cz-plock' + (composeCamLock ? ' on' : ''); lock.textContent = '⌖';
lock.title = composeCamLock ? 'Camera locked on character — tap to free' : 'Center & lock the camera on the character';
lock.onclick = () => {
composeCamLock = !composeCamLock;
const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'camera-lock', on: composeCamLock }, '*');
lock.classList.toggle('on', composeCamLock);
lock.title = composeCamLock ? 'Camera locked on character — tap to free' : 'Center & lock the camera on the character';
};
head.appendChild(lock);
const pp = document.createElement('button'); pp.type = 'button'; pp.className = 'kimodo-cz-pplay'; pp.id = 'kimodo-cz-pplay'; pp.textContent = composePlayer.playing ? '❚❚' : '▶';
pp.onclick = () => { composePlayer.playing = !composePlayer.playing; _czTransport(composePlayer.playing ? 'play' : 'pause'); pp.textContent = composePlayer.playing ? '❚❚' : '▶'; };
head.appendChild(pp);
}
// Sticky top bar: back/play + the contextual instruction (anchored at the top
// while the timeline scrolls below it).
const topbar = document.createElement('div'); topbar.className = 'kimodo-cz-ptop';
topbar.appendChild(head);
// One move is editable at a time: an ungenerated (being-built) move, or a dirty
// (edited-but-not-refreshed) generated move. While either exists the kata has
// unsaved work, so "+ Add a stance" is hidden until it's clean.
const hasUngen = poses.some((c, k) => k >= 1 && !c.clipId);
const hasDirty = poses.some((c, k) => k >= 1 && c.clipId && c.dirty);
const hasUnsaved = hasUngen || hasDirty;
// The editor expands on exactly one move: the ungenerated (being-built) one if any
// (locked active until generated), else the move you selected to re-edit, else none.
let activeIdx = -1;
if (editable) {
let ungen = -1; for (let i = last; i >= 1; i--) { if (!poses[i].clipId) { ungen = i; break; } }
if (ungen >= 1) activeIdx = ungen;
else if (composeSelMove >= 1 && composeSelMove <= last) activeIdx = composeSelMove;
}
if (editable) {
let hintHTML = '';
if (poses.length === 0) hintHTML = 'Tap + Add a stance below to begin.';
else if (activeIdx >= 1 && poses[activeIdx] && poses[activeIdx]._uid === _czGenPulseUid) {
// Just generated/updated: celebrate + point at the next action (pulses since the text changed).
hintHTML = 'Move #' + activeIdx + ' updated. Keep adding stances with + Add a stance or keep editing.';
}
else if (activeIdx >= 1) {
const btn = poses[activeIdx].clipId ? 'Refresh' : 'Generate';
hintHTML = 'Editing pose on 3d viewer
'
+ 'drag ring to move • knob to rotate
'
+ 'changing duration
'
+ 'drag … up/down
'
+ '' + btn + ' when done';
}
else hintHTML = 'Tap a move to edit it, or + add the next stance below.';
if (hintHTML) { const hint = document.createElement('div'); hint.className = 'kimodo-cz-hint' + (hintHTML !== _czLastHint ? ' pulse' : ''); hint.innerHTML = hintHTML; topbar.appendChild(hint); _czLastHint = hintHTML; }
}
root.appendChild(topbar);
// "+ Add a stance" at the END of the timeline — only when the kata is fully clean.
const appendAdd = () => {
if (!editable || hasUnsaved) return;
const add = document.createElement('button'); add.type = 'button'; add.id = 'kimodo-cz-addstance'; add.className = 'kimodo-cz-addbtn'; add.textContent = '+ Add a stance';
add.onclick = openStancePicker; root.appendChild(add);
};
if (!poses.length) { appendAdd(); return; }
const previewMoves = (composePlayer && composePlayer.genCount) || 0; // moves in the loaded preview
const body = document.createElement('div'); body.className = 'kimodo-cz-pbody';
const track = document.createElement('div'); track.className = 'kimodo-cz-ptrack'; track.id = 'kimodo-cz-ptrack';
// played-region fill that sweeps DOWN over the generated moves
const fill = document.createElement('div'); fill.className = 'kimodo-cz-pfill'; fill.id = 'kimodo-cz-pfill'; if (hasPreview) track.appendChild(fill);
// move cards (START → END going down); each card's HEIGHT encodes its duration
for (let i = 1; i <= last; i++) track.appendChild(renderTimelineCard(i, last, editable, activeIdx));
body.appendChild(track);
// scrubber rail over the generated region = the unified playhead (reused component).
// Based on the loaded preview, so it stays put while you edit a (dirty) move.
if (hasPreview && previewMoves >= 1) {
const rail = document.createElement('div'); rail.className = 'kimodo-cz-prail'; rail.id = 'kimodo-cz-prail';
const railfill = document.createElement('div'); railfill.className = 'kimodo-cz-prailfill'; railfill.id = 'kimodo-cz-prailfill';
const handle = document.createElement('div'); handle.className = 'kimodo-cz-phandle'; handle.id = 'kimodo-cz-phandle'; handle.textContent = '⇕';
rail.appendChild(railfill); rail.appendChild(handle); body.appendChild(rail);
wireScrubRail(rail);
}
root.appendChild(body);
appendAdd(); // + Add a stance at the END of the timeline
if (hasPreview) {
const time = document.createElement('div'); time.className = 'kimodo-cz-ptime';
time.innerHTML = '0:00' + _czTime(composePlayer.numFrames - 1, composePlayer.fps) + '';
root.appendChild(time);
requestAnimationFrame(positionScrubber); // size the rail to the generated region after layout
}
composeJustGenIdx = -1; _czPopUids = null; _czSlideUid = null; _czGenPulseUid = null; _czSelPulseUid = null; // one-shot animations consumed
}
// A move on the timeline. The ACTIVE (being-edited) move expands to the editor
// (start stance on top, end stance on the bottom = drag handle, then GENERATE +
// refine + numbers). Every other move is a compact card whose HEIGHT = its duration.
function renderTimelineCard(i, last, editable, activeIdx) {
const c = composeStrip[i];
const isEnd = (i === last);
const pop = _czPopUids && _czPopUids.has(c._uid);
const slide = (c._uid === _czSlideUid);
const row = document.createElement('div'); row.className = 'kimodo-cz-pcardrow' + (pop ? ' cz-pop' : '') + (i === activeIdx ? ' cz-activerow' : '') + (slide ? ' cz-slidein' : ''); row.dataset.i = i;
if (editable && i === activeIdx) { row.appendChild(renderEditCard(i, last)); return row; }
const generated = !!(c.clipId && !c.dirty);
row.style.height = Math.max(CZ_MINCARD, _czDur(c) * CZ_PXPS) + 'px';
const card = document.createElement('div');
card.className = 'kimodo-cz-tlcard' + (isEnd ? ' end' : '') + (generated ? ' gen' : ' cz-pending') + (composeJustGenIdx === i ? ' cz-justgen' : '');
const node = document.createElement('span'); node.className = 'kimodo-cz-tlnode ' + (isEnd ? 'endn' : 'midn'); card.appendChild(node);
const thumb = document.createElement('div'); thumb.className = 'kimodo-cz-tlsthumb'; fillPoseThumb(thumb, c.id); thumb.appendChild(stanceBadge(c.id)); card.appendChild(thumb);
const meta = document.createElement('div'); meta.className = 'kimodo-cz-tlmeta';
const nm = document.createElement('div'); nm.className = 'kimodo-cz-tlname'; nm.textContent = (isEnd ? 'END · ' : '') + (c.name || '').replace(/\s*@\d+$/, '');
const durv = document.createElement('div'); durv.className = 'kimodo-cz-tldur'; durv.textContent = _czDur(c).toFixed(1) + 's';
meta.appendChild(nm); meta.appendChild(durv); card.appendChild(meta);
if (generated) { const chk = document.createElement('span'); chk.className = 'kimodo-cz-tlcheck'; chk.textContent = '✓'; card.appendChild(chk); }
card.onclick = () => selectMove(i); // tap to edit this move
row.appendChild(card);
return row;
}
// The ACTIVE move editor: the move's START stance is pinned to the top of the
// duration control and its END stance to the bottom — drag the end down to lengthen
// the move (the gap = duration). GENERATE sits right under the end stance, then the
// collapsed Refine prompt + the move/rotation numbers.
function renderEditCard(i, last) {
const c = composeStrip[i];
const start = composeStrip[i - 1];
const isEnd = (i === last);
const dirty = !!(c.clipId && c.dirty); // was generated, now edited → needs REFRESH
const newMove = !c.clipId; // not generated yet → gray
let pulseCls = '';
if (c._uid === _czGenPulseUid) pulseCls = ' cz-genpulse'; // gray → purple (just generated)
else if (c._uid === _czSelPulseUid) pulseCls = ' cz-selpulse'; // green → purple (just selected)
const card = document.createElement('div'); card.className = 'kimodo-cz-editcard' + (newMove ? ' cz-new' : (dirty ? ' cz-dirty' : '')) + pulseCls;
// "Move N" label at the top-left so you know which move you're editing.
const moveLab = document.createElement('div'); moveLab.className = 'kimodo-cz-emmovelab'; moveLab.textContent = 'Move ' + i; card.appendChild(moveLab);
const startShown = !!c._showStart, endShown = (c._showEnd !== false); // end shown by default, start hidden
// duration region: start pose (top) → gap → end pose (bottom). The two thumbs TOGGLE
// their viewer skeletons (green start / red end) — no text labels.
const region = document.createElement('div'); region.className = 'kimodo-cz-emregion'; region.style.height = Math.max(CZ_MINREG, _czDur(c) * CZ_PXPS) + 'px';
const from = document.createElement('div'); from.className = 'kimodo-cz-emfrom';
const fThumb = document.createElement('span'); fThumb.className = 'kimodo-cz-emthumb start' + (startShown ? ' shown' : ''); fThumb.title = 'show / hide the start pose'; fillPoseThumb(fThumb, start.id); fThumb.appendChild(stanceBadge(start.id));
fThumb.onclick = (e) => { e.stopPropagation(); c._showStart = !startShown; sendPlaceVis(i); renderComposePlayer(); };
from.appendChild(fThumb);
const durBig = document.createElement('div'); durBig.className = 'kimodo-cz-emdur'; durBig.textContent = _czDur(c).toFixed(1) + 's';
const end = document.createElement('div'); end.className = 'kimodo-cz-emend';
const eThumb = document.createElement('span'); eThumb.className = 'kimodo-cz-emthumb end' + (endShown ? ' shown' : ''); eThumb.title = 'show / hide the end pose'; fillPoseThumb(eThumb, c.id); eThumb.appendChild(stanceBadge(c.id));
eThumb.onclick = (e) => { e.stopPropagation(); c._showEnd = !endShown; sendPlaceVis(i); renderComposePlayer(); };
end.appendChild(eThumb);
region.appendChild(from); region.appendChild(durBig); region.appendChild(end);
card.appendChild(region);
// Delete (✕) at the TOP-RIGHT of the whole move.
if (isEnd) { const x = document.createElement('button'); x.type = 'button'; x.textContent = '✕'; x.className = 'kimodo-cz-x kimodo-cz-emx'; x.title = 'remove this move'; x.onpointerdown = (e) => e.stopPropagation(); x.onclick = (e) => { e.stopPropagation(); composeRemove(i); }; card.appendChild(x); }
// GENERATE / REFRESH right below the end stance
const gen = document.createElement('button'); gen.type = 'button'; gen.className = 'kimodo-cz-genbtn show' + (c.clipId ? ' refresh' : ''); // Refresh = blue
const generating = composeBusy && composeGeneratingIdx === i;
if (generating) { gen.classList.add('loading'); gen.disabled = true; gen.innerHTML = ''; } // spinner only, no text
else { gen.disabled = composeBusy; gen.textContent = c.clipId ? '↻ Refresh' : 'Generate'; }
gen.onclick = (e) => { e.stopPropagation(); generateMove(i); };
card.appendChild(gen);
// Refine prompt (collapsed) below GENERATE — the prompt text is never shown inline.
const refine = document.createElement('details'); refine.className = 'kimodo-cz-refine'; if (c._refineOpen) refine.open = true;
const sm = document.createElement('summary'); sm.textContent = 'Refine prompt'; refine.appendChild(sm);
const tp = document.createElement('textarea'); tp.className = 'kimodo-cz-tprompt'; tp.value = c.prompt; tp.placeholder = 'Describe the move into this pose…';
tp.oninput = () => { c.prompt = tp.value; markMoveDirty(i, gen); };
refine.appendChild(tp); refine.addEventListener('toggle', () => { c._refineOpen = refine.open; });
card.appendChild(refine);
// Move/rotation numbers below the refine toggle.
const place = document.createElement('div'); place.className = 'kimodo-cz-placerow'; place.id = 'kimodo-cz-place-' + i; place.innerHTML = czPlaceText(c);
card.appendChild(place);
// Resize handle (dots) across the BOTTOM of the whole card — drag it (as if resizing
// the whole div) to set the move length; the card grows taller as the move lengthens.
const handle = document.createElement('div'); handle.className = 'kimodo-cz-emhandle kimodo-cz-cardhandle'; handle.title = 'drag to set the move length';
const dots = document.createElement('span'); dots.className = 'kimodo-cz-dots'; handle.appendChild(dots);
card.appendChild(handle);
wireDurationDrag(handle, null, region, CZ_MINREG, i, durBig); // resizes the duration region (whole card grows)
return card;
}
// Compact "x 0.0 · y 0.0 · ↻ 0°" readout for a move's 2D placement (no label, to
// save vertical/horizontal space on a thin sidebar).
function czPlaceText(c) {
return 'x ' + (c.tx || 0).toFixed(1) + ' · y ' + (c.tz || 0).toFixed(1) + ' · ↻ ' + Math.round(c.rot || 0) + '°';
}
// Select a move: it becomes the active move (controls panel shows below the timeline)
// and its END pose is overlaid in the viewer for 3D placement. Re-render so the
// controls follow the selection.
function selectMove(i) {
// Edit one move at a time: if another (generated) move has unsaved edits, confirm,
// then revert it to its saved state before switching.
const dj = composeStrip.findIndex((c, k) => k >= 1 && c.clipId && c.dirty);
if (dj >= 0 && dj !== i) {
if (!window.confirm('Discard unsaved changes to the move you were editing?')) return;
_czRevert(composeStrip[dj]); saveCurrentKata();
}
composeSelMove = i;
const sc = composeStrip[i]; // selecting a clean generated move → pulse green→purple
if (sc && sc.clipId && !sc.dirty && _czGenPulseUid !== sc._uid) _czSelPulseUid = sc._uid;
if (composePlayer) composePlayer.loopMove = null;
renderComposePlayer();
sendPlacePose(i); // overlay this move's END pose so it can be moved/rotated in 3D
// For a NEW (ungenerated) move, park the figure at this move's START pose — the prior
// stance — not the end, so a generate visibly animates start → end.
if (sc && !sc.clipId) {
if (composePlayer && composePlayer._payload && composePlayer._payload.num_frames > 1) {
// Re-load the generated-prefix preview (browsing stances had replaced the viewer
// motion) and park at its LAST frame — this move's start pose, at the correct
// world position in the kata (not the origin). Paused, so a GENERATE animates
// start → end. Use the PAYLOAD's frame count (composePlayer.numFrames may have
// been clobbered by a browsed stance) so move 3+ lands at the right pose.
composePlayer.numFrames = composePlayer._payload.num_frames; // repair after browsing
composePlayer.playing = false;
const lf = composePlayer.numFrames - 1; composePlayer.curFrame = lf;
_czViewerHasPreview = true;
loadIntoViewer(composePlayer._payload, '', (composeCurrentKata && composeCurrentKata.label) || 'kata', { keepCamera: true });
_czTransport('pause'); // don't let the reloaded preview drift before we seek
setTimeout(() => { _czTransport('seek', lf); updatePlayerProgress(lf, false); }, 160);
} else {
// First move (no generated prefix yet): load the start stance (index i-1) at origin.
const ps = composeStrip[i - 1];
if (ps) previewComposeStance(ps.id, _czName(ps));
}
}
}
// Push the move's END stance pose + current placement to the viewer's place mode.
// Per-stance world transforms {x,z,heading} from the stitched preview, so a move's
// place overlay can render at the PREVIOUS pose's accumulated position (not the origin).
function _czBoundaries(m) {
const P = m && m.posed_joints, R = m && m.root_positions;
const nf = (m && m.num_frames) || (P ? P.length : 0);
const pf = posefracFromStrip();
return pf.map((frac) => {
const f = Math.max(0, Math.min(nf - 1, Math.round(frac * (nf - 1))));
const head = (P && P[f]) ? poseHeading(P[f]) : 0;
return { x: (R && R[f]) ? R[f][0] : 0, z: (R && R[f]) ? R[f][2] : 0, heading: head };
});
}
// Toggle the start/end pose skeletons in the viewer (driven by the edit-card thumbs).
function sendPlaceVis(i) {
const c = composeStrip[i]; if (!c) return;
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'place-vis', start: !!c._showStart, end: c._showEnd !== false }, '*');
}
async function sendPlacePose(i) {
const c = composeStrip[i]; if (!c) return;
try {
const rec = await fetchRecord(c.id);
const B = canonKeyframe(rec.posed_joints[0], rec.global_quats_xyzw[0]);
// The move's START pose (previous stance) — shown as a green skeleton (hidden by default).
let startPosed = null; const sc = composeStrip[i - 1];
if (sc) { try { const rs = await fetchRecord(sc.id); startPosed = canonKeyframe(rs.posed_joints[0], rs.global_quats_xyzw[0]).posed; } catch (e) {} }
// World transform at the START of move i (= end of move i-1 in the stitched kata),
// so the overlay lands where the move actually ends, not at the origin.
const base = (composePlayer && composePlayer.boundaries && composePlayer.boundaries[i - 1]) || { x: 0, z: 0, heading: 0 };
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'place-pose', posed: B.posed, startPosed, parents: SMPLX22_PARENTS, tx: c.tx || 0, tz: c.tz || 0, rot: c.rot || 0, base, startVis: !!c._showStart, endVis: c._showEnd !== false }, '*');
} catch (e) { console.error('place-pose failed', e); }
}
// Exit 3D placement (clear the overlay + selection).
function clearPlaceMode() {
composeSelMove = -1;
const f = previewFrame(); if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'place-clear' }, '*');
}
// The viewer reports the overlaid figure moved/rotated → update the move + readout.
function composeOnPlaceUpdate(msg) {
if (composeSelMove < 0) return;
const c = composeStrip[composeSelMove]; if (!c) return;
c.tx = msg.tx || 0; c.tz = msg.tz || 0; c.rot = msg.rot || 0;
const el = document.getElementById('kimodo-cz-place-' + composeSelMove);
if (el) el.innerHTML = czPlaceText(c); // live update, no full re-render
if (c.clipId && !c.dirty) { // edited a generated move → dirty (color), keep the preview
c.dirty = true;
const card = el && el.closest('.kimodo-cz-editcard'); if (card) card.classList.add('cz-dirty');
}
}
// First edit of a generated move → mark dirty + slide the GENERATE button in +
// drop the (now-stale) preview from this move onward.
function markMoveDirty(i, genBtn) {
const c = composeStrip[i];
if (c.clipId && !c.dirty) {
// Mark dirty for the color + REFRESH affordance, but KEEP the existing preview
// (and its scrubber) — it only refreshes when the user presses Refresh. Update
// inline (no re-render) so an in-progress prompt edit keeps focus.
c.dirty = true;
if (genBtn) genBtn.classList.add('show');
const card = document.querySelector('#kimodo-cz-ptrack .cz-activerow .kimodo-cz-editcard');
if (card) card.classList.add('cz-dirty');
}
}
// Size + position the rail to cover ONLY the generated region of the track (the
// bottom portion, from the track bottom up to the top of the topmost generated card).
function positionScrubber() {
const track = document.getElementById('kimodo-cz-ptrack');
const rail = document.getElementById('kimodo-cz-prail');
if (!track || !composePlayer) return;
// The rail spans the GENERATED region only, starting just below the START marker.
const startRow = track.querySelector('.kimodo-cz-tlstart');
const top = startRow ? (startRow.offsetTop + startRow.offsetHeight) : 0;
const genPx = Math.max(8, _czGenPx());
if (rail) { rail.style.top = top + 'px'; rail.style.height = genPx + 'px'; }
const fill = document.getElementById('kimodo-cz-pfill'); if (fill) fill.style.top = top + 'px';
updatePlayerProgress(composePlayer.curFrame || 0, composePlayer.playing);
}
// Live-scrub: dragging the rail pauses + seeks the viewer. The rail spans the
// generated region top→bottom, so y maps straight to playback frac.
function wireScrubRail(rail) {
const seekFromY = (clientY) => {
const r = rail.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (clientY - r.top) / Math.max(1, r.height)));
const fr = Math.round(frac * ((composePlayer.numFrames || 1) - 1));
composePlayer.playing = false; _czTransport('seek', fr); updatePlayerProgress(fr, false);
const pp = document.getElementById('kimodo-cz-pplay'); if (pp) pp.textContent = '▶';
};
let dragging = false;
rail.addEventListener('pointerdown', (e) => { dragging = true; try { rail.setPointerCapture(e.pointerId); } catch (x) {} seekFromY(e.clientY); e.preventDefault(); });
rail.addEventListener('pointermove', (e) => { if (dragging) { seekFromY(e.clientY); e.preventDefault(); } });
const end = () => { dragging = false; };
rail.addEventListener('pointerup', end); rail.addEventListener('pointercancel', end);
}
// Move the fill + handle + current-card highlight + time to `frame`. Timeline flows
// top→bottom (START at top), so the played fraction sweeps DOWNWARD.
function updatePlayerProgress(frame, playing) {
const p = composePlayer; if (!p) return;
p.curFrame = frame; if (playing != null) p.playing = playing;
const frac = Math.max(0, Math.min(1, frame / Math.max(1, (p.numFrames - 1))));
const rail = document.getElementById('kimodo-cz-prail');
const fill = document.getElementById('kimodo-cz-pfill');
const railfill = document.getElementById('kimodo-cz-prailfill');
const handle = document.getElementById('kimodo-cz-phandle');
const regH = rail ? rail.clientHeight : 0; // rail == generated region
if (fill) fill.style.height = Math.round(frac * regH) + 'px'; // sweeps down from the first move
if (rail && handle) { handle.style.top = Math.round(frac * regH) + 'px'; if (railfill) railfill.style.height = Math.round(frac * regH) + 'px'; }
// current card = the move being played INTO (first whose reach-frac >= progress)
let curI = (p.poseFrac && p.poseFrac.length) ? p.poseFrac.findIndex((f) => f >= frac - 1e-4) : -1; if (curI < 0) curI = (p.poseFrac && p.poseFrac.length) ? p.poseFrac.length - 1 : 0;
document.querySelectorAll('#kimodo-cz-ptrack .kimodo-cz-pcardrow').forEach((row) => row.classList.toggle('cur', Number(row.dataset.i) === curI));
const cur = document.getElementById('kimodo-cz-pcur'); if (cur) cur.textContent = _czTime(frame, p.fps);
const pp = document.getElementById('kimodo-cz-pplay'); if (pp) pp.textContent = p.playing ? '❚❚' : '▶';
}
// Driven by the viewer's per-frame messages while the player view is open.
function composeOnFrame(msg) {
if (!composePlayer || composeView !== 'player' || !msg) return;
// Only trust frame counts while the viewer holds the KATA preview — a browsed single
// stance must not overwrite the kata's length (it would mis-place the seek/scrubber).
if (!_czViewerHasPreview) return;
if (typeof msg.num_frames === 'number' && msg.num_frames > 1) composePlayer.numFrames = msg.num_frames;
updatePlayerProgress(Math.round(msg.frame || 0), !!msg.playing);
}
function deleteKata(k) {
if (!window.confirm('Delete this kata?')) return;
if (k.local) { // Mine entry — drop the localStorage record (+ server clips if known)
saveMyKatas(loadMyKatas().filter((e) => e.lid !== k.lid));
if (k.root) { const did = document.querySelector('#del-id textarea, #del-id input'); const dbtn = document.querySelector('#del-btn'); if (did && dbtn) { did.value = k.root; did.dispatchEvent(new Event('input', { bubbles: true })); dbtn.click(); } }
} else {
const did = document.querySelector('#del-id textarea, #del-id input');
const dbtn = document.querySelector('#del-btn');
for (const id of (k.ids || [k.root])) { if (did && dbtn) { did.value = id; did.dispatchEvent(new Event('input', { bubbles: true })); dbtn.click(); } }
composeKatas = composeKatas.filter((x) => x.root !== k.root);
}
renderComposeLibrary();
setComposeStatus('deleted “' + k.label + '”');
}
function renderComposePalette() {
const pal = document.getElementById('kimodo-compose-palette'); if (!pal) return;
pal.innerHTML = '';
const fEl = document.getElementById('kimodo-cz-pfilter');
const q = (fEl && fEl.value || '').trim().toLowerCase();
const list = q ? composeStances.filter((m) => _czName(m).toLowerCase().includes(q) || (m.stance_tags || []).join(' ').toLowerCase().includes(q)) : composeStances;
// Anchored hint above the (scrolling) grid: two-tap prompt for the first move,
// else a plain add-the-next-stance prompt.
const startPhase = (composeStrip.length === 0 && !_czFirstPick); // picking START (green) vs END (red)
const hintEl = document.getElementById('kimodo-cz-pickhint');
if (hintEl) {
const cl = startPhase ? 's' : 'e'; // color only the ✓ and the start/end word
hintEl.innerHTML = _czTentative
? ('Tap the ✓ stance to confirm')
: ('Tap a stance to set ' + (startPhase ? 'a start pose' : 'the end pose'));
}
if (!composeStances.length) { pal.innerHTML = 'no stances in this dataset
'; return; }
if (!list.length) { pal.innerHTML = 'no stances match “' + q.replace(/'; return; }
for (const m of list) {
const card = document.createElement('div');
const isStartPick = _czFirstPick && m.id === _czFirstPick.id; // confirmed START (during END phase)
const isTentative = _czTentative && m.id === _czTentative.id; // tapped to preview, awaiting confirm
card.className = 'kimodo-cz-pcard' + (isStartPick ? ' cz-startpick' : (isTentative ? (' cz-tentative ' + (startPhase ? 'tent-start' : 'tent-end')) : (m.id === composeSelId ? ' sel' : '')));
card.title = _czName(m);
const thumb = document.createElement('div'); thumb.className = 'kimodo-cz-pthumb'; fillPoseThumb(thumb, m.id); thumb.appendChild(stanceBadge(m.id));
if (isStartPick) { const tg = document.createElement('span'); tg.className = 'kimodo-cz-picktag'; tg.textContent = 'START'; card.appendChild(tg); }
if (isTentative) { const ck = document.createElement('span'); ck.className = 'kimodo-cz-pcheck'; ck.textContent = '✓'; card.appendChild(ck); } // big ✓ over the whole card = confirm
const nm = document.createElement('div'); nm.className = 'kimodo-cz-pname'; nm.textContent = _czName(m);
const add = document.createElement('button'); add.type = 'button'; add.textContent = '+';
add.title = 'add to storyboard'; add.className = 'kimodo-cz-padd';
add.onclick = (e) => { e.stopPropagation(); composeAdd(m); };
// In the picker, tapping a stance selects it (two-tap for the first move).
card.onclick = () => composeAdd(m);
card.appendChild(thumb); card.appendChild(nm); card.appendChild(add);
pal.appendChild(card);
}
}
function _czStanceEntry(m, isStart) {
return { id: m.id, name: _czName(m), prompt: isStart ? '' : _czDefaultPrompt(_czName(m)),
tx: 0, tz: 0, rot: 0, seconds: 2.2, clipId: null, dirty: true, expanded: true, _uid: ++_czUid };
}
// Bring the active (being-edited) move — the newly added one at the bottom — into view.
function _czScrollToActive() {
setTimeout(() => {
const el = document.querySelector('#kimodo-cz-ptrack .cz-activerow') || document.getElementById('kimodo-cz-addstance');
if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 80);
}
// Restart the picker hint's pulse so a changed instruction grabs attention.
function _czPulsePickHint() {
const ph = document.getElementById('kimodo-cz-pickhint'); if (!ph) return;
ph.classList.remove('pulse'); void ph.offsetWidth; ph.classList.add('pulse');
}
// Tap a stance once = preview it (a ✓ marks it); tap the SAME one again = confirm. So
// you can browse through the stances before committing. The first move needs TWO
// confirmations (START then END); later moves need one.
function _czTentativeTap(m) {
if (!_czTentative || _czTentative.id !== m.id) { // browse: move the ✓ + preview
_czTentative = m; renderComposePalette(); previewComposeStance(m.id, _czName(m));
return false; // not confirmed
}
return true; // tapped the ✓'d one again → confirm
}
function composeAdd(m) {
if (composeStrip.length === 0) {
// FIRST MOVE: confirm a START, then confirm an END.
if (!_czFirstPick) {
if (!_czTentativeTap(m)) return;
_czFirstPick = m; _czTentative = null; // START confirmed
renderComposePalette(); _czPulsePickHint(); previewComposeStance(m.id, _czName(m));
return;
}
// (the END may equal the START — a hold/return move is allowed)
if (!_czTentativeTap(m)) return;
composeStrip.push(_czStanceEntry(_czFirstPick, true)); // START (index 0)
const endEntry = _czStanceEntry(m, false); // END (index 1) = move 1
composeStrip.push(endEntry);
_czFirstPick = null; _czTentative = null;
closeStancePicker();
_czPopUids = new Set([endEntry._uid]);
_czSlideUid = endEntry._uid; // slide the first move in from the top
renderComposePlayer();
selectMove(1); _czScrollToActive(); // select move 1 + scroll it into view
return;
}
// Subsequent moves: confirm one stance (the new END), chained from the previous.
if (!_czTentativeTap(m)) return;
const entry = _czStanceEntry(m, false);
composeStrip.push(entry);
_czTentative = null;
closeStancePicker();
_czPopUids = new Set([entry._uid]);
_czSlideUid = entry._uid; // slide the new move in from the top
renderComposePlayer();
selectMove(composeStrip.length - 1); _czScrollToActive();
}
function composeRemove(i) {
if (!window.confirm('Remove this stance from the kata?')) return;
const drop = () => {
composeStrip.splice(i, 1);
// A lone START stance (no move) is meaningless → clear back to empty so the kata
// returns to the "tap to add" state.
if (composeStrip.length === 1) composeStrip = [];
composeSelMove = -1;
saveCurrentKata(); updateKataPreview(); renderComposePlayer();
};
const track = document.getElementById('kimodo-cz-ptrack');
const row = track && track.querySelector('.kimodo-cz-pcardrow[data-i="' + i + '"]');
const blk = row && row.nextElementSibling && row.nextElementSibling.classList.contains('kimodo-cz-mblock') ? row.nextElementSibling : null;
if (row) { // shrink the card (and its move block) away, then drop
[row, blk].forEach((el) => { if (!el) return; el.style.height = el.offsetHeight + 'px'; void el.offsetHeight; el.classList.add('cz-removing'); el.style.height = '0px'; });
setTimeout(drop, 240);
} else drop();
}
// The old separate "create strip" + global Build button are superseded by the
// unified kata view with per-move GENERATE; keep thin shims so any caller works.
function renderComposeStrip() { renderComposePlayer(); }
function updateComposeLaunch() { const btn = document.getElementById('kimodo-compose-launch'); if (btn) btn.classList.remove('show'); }
// Build the canon'd keyframe pair list shared by both paths.
async function composeKeyframes() {
const kfs = [];
for (const c of composeStrip) { const rec = await fetchRecord(c.id); kfs.push(canonKeyframe(rec.posed_joints[0], rec.global_quats_xyzw[0])); }
const moves = [];
for (let i = 1; i < composeStrip.length; i++) {
const c = composeStrip[i], A = kfs[i-1], B0 = kfs[i];
const B = placePose(B0.posed, B0.quats, c.rot || 0, c.tx || 0, c.tz || 0);
moves.push({
prompt: (c.prompt || _czDefaultPrompt(c.name)).trim(), seconds: c.seconds || 2.2,
keyframes: [
{ posed_joints: A.posed, global_quats_xyzw: A.quats, frac: 0.0, turn_deg: 0, advance: 0 },
{ posed_joints: B.posed, global_quats_xyzw: B.quats, frac: 1.0, turn_deg: 0, advance: 0 },
],
});
}
return moves;
}
// Compact, editable recipe (stance ids + transition prompts/dials) stored on the
// kata root so MY KARATE can list + reopen it. Excludes the heavy pose arrays.
function composeRecipe() {
return composeStrip.map((c) => ({ id: c.id, name: c.name, prompt: c.prompt || '', seconds: c.seconds || 2.2, tx: c.tx || 0, tz: c.tz || 0, rot: c.rot || 0 }));
}
function myAnonId() { try { return window.localStorage.getItem('kimodoAnonymousClientId') || ''; } catch (e) { return ''; } }
// "Mine" is tracked CLIENT-SIDE in localStorage at build time — robust against
// manifest propagation lag, missing compose_owner, and fileless clip records.
// Each entry keeps the editable recipe, so the card + filmstrip render from the
// STANCE ids (which reliably resolve) without any per-clip fetch.
function loadMyKatas() { try { return JSON.parse(window.localStorage.getItem('kimodoMyKatas') || '[]'); } catch (e) { return []; } }
function saveMyKatas(a) { try { window.localStorage.setItem('kimodoMyKatas', JSON.stringify(a.slice(0, 80))); } catch (e) {} }
function recordMyKata(recipe, ids) {
if (!recipe || !recipe.length) return;
ids = Array.isArray(ids) ? ids : (ids ? [ids] : null);
const a = loadMyKatas();
a.unshift({ lid: 'k' + Date.now().toString(36), name: recipeLabel(recipe), recipe, ids: ids, root: (ids && ids[0]) || null, ts: Date.now() });
saveMyKatas(a);
}
const composeCanBuild = () => !!(KIMODO_BACKEND || document.querySelector('#compose-btn'));
let composePending = false;
function composeBuild() {
if (composeStrip.length < 2 || composeBusy || !composeCanBuild()) return;
return KIMODO_BACKEND ? composeBuildRemote() : composeBuildInProcess();
}
// Direct-backend path (local eval): /generate_between + /stitch_path over HTTP,
// with post_processing → 0-drift seams. Plays in-place via loadIntoViewer.
async function composeBuildRemote() {
composeBusy = true; renderComposeStrip(); setComposeStatus('building storyboard…');
try {
const moves = await composeKeyframes();
let prev = null; const ids = [];
for (let i = 0; i < moves.length; i++) {
setComposeStatus('generating move ' + (i+1) + '/' + moves.length + '…');
const body = Object.assign({ num_steps: 60, post_processing: true, continues_from_id: prev }, moves[i]);
const r = await fetch(KIMODO_BACKEND + '/generate_between', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) throw new Error('move ' + (i+1) + ': ' + r.status + ' ' + (await r.text()).slice(0, 120));
const j = await r.json(); ids.push(j.id); prev = j.id;
}
setComposeStatus('stitching…');
const sr = await fetch(KIMODO_BACKEND + '/stitch_path', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, save: false }) });
if (!sr.ok) throw new Error('stitch ' + sr.status);
const m = await sr.json();
m.num_joints = (m.posed_joints && m.posed_joints[0]) ? m.posed_joints[0].length : 22;
if (!m.bone_names || !m.bone_names.length) m.bone_names = SMPLX22_BONES;
m.parents = SMPLX22_PARENTS;
loadIntoViewer(m, '', 'storyboard · ' + ids.length + ' moves');
composeStrip.forEach((c) => { c.built = true; }); // mark all moves "done"
recordMyKata(composeRecipe(), ids); // remote path knows the full spine
setComposeStatus('▶ built ' + ids.length + ' move' + (ids.length === 1 ? '' : 's') + ' · ' + m.num_frames + 'f');
setComposeView('library'); // show it under Mine
} catch (e) { console.error(e); setComposeStatus('build failed: ' + e.message); }
finally { composeBusy = false; renderComposeStrip(); }
}
// In-process path (deployed Space): hand the move list to the hidden gradio
// #compose-btn, which runs generate_between + stitch on the @GPU model and
// re-renders the viewer. Completion arrives via the viewer's preview-ready event.
async function composeBuildInProcess() {
const ta = document.querySelector('#compose-payload textarea, #compose-payload input');
const btn = document.querySelector('#compose-btn');
if (!ta || !btn) { setComposeStatus('compose bridge not found'); return; }
composeBusy = true; renderComposeStrip();
setComposeStatus('building ' + (composeStrip.length - 1) + ' moves on the Space (this can take a while)…');
try {
const moves = await composeKeyframes();
ta.value = JSON.stringify({ moves, recipe: composeRecipe(), owner: myAnonId() }); ta.dispatchEvent(new Event('input', { bubbles: true }));
composePending = true;
setTimeout(() => btn.click(), 50);
// safety: clear the busy state if no preview-ready arrives.
setTimeout(() => { if (composePending) { composePending = false; composeBusy = false; renderComposeStrip(); setComposeStatus('still working… (check the viewer)'); } }, 600000);
} catch (e) { composeBusy = false; renderComposeStrip(); setComposeStatus('build failed: ' + e.message); }
}
// In-process build completion arrives via a gradio .then() bridge carrying the
// build meta (incl. kata_ids), so we record the kata with REPLAYABLE spine ids.
window.__kimodoOnComposeBuilt = function (meta) {
if (!composePending) return;
composePending = false; composeBusy = false; composeGeneratingIdx = -1;
const ok = meta && meta.kata_root;
if (ok) {
const ids = meta.kata_ids || [meta.kata_root];
for (let i = 1; i < composeStrip.length && i - 1 < ids.length; i++) { composeStrip[i].clipId = ids[i - 1]; composeStrip[i].dirty = false; composeStrip[i].expanded = false; _czSnapshot(composeStrip[i]); }
if (meta.move_index) { composeJustGenIdx = meta.move_index; clearPlaceMode(); } // per-move bridge: highlight the move just built
saveCurrentKata();
const builtIdx = meta.move_index || (composeStrip.length - 1);
// Refresh via the SAME clean path as re-selecting the kata from the library — which
// the user confirmed correctly extends the timeline and ALSO doesn't reload the 3D
// model (it postMessages the motion in). We hand it the server-stitched preview so it
// neither reloads the iframe nor re-fetches the just-saved (CDN-lagging) clips, and
// keepCam=true so the camera doesn't jump on every move.
// meta comes from a gr.JSON output, which Gradio (Svelte 5) hands over as a reactive
// PROXY. postMessage()'s structured clone can't clone a proxy ("[object Array] could
// not be cloned"), so deep-copy the preview payload to a plain object first.
let _sp = null;
try { _sp = meta.preview_payload ? JSON.parse(JSON.stringify(meta.preview_payload)) : null; } catch (e) { _sp = null; }
if (_sp) {
playKata(composeCurrentKata, _sp, true).then(() => setComposeStatus('▶ generated [b18 · moves ' + (composePlayer ? composePlayer.genCount : '?') + ' · frames ' + (composePlayer ? composePlayer.numFrames : '?') + ']'));
} else {
updateKataPreview(builtIdx, null).then(() => { if (builtIdx >= 1 && builtIdx < composeStrip.length) { if (composeStrip[builtIdx]) _czGenPulseUid = composeStrip[builtIdx]._uid; selectMove(builtIdx); } });
setComposeStatus('▶ generated [b18 · fallback]');
}
} else {
renderComposePlayer();
setComposeStatus((meta && meta.error) ? ('generate failed: ' + meta.error) : 'done');
}
};
// Fallback: if the bridge never fires, clear the busy state on viewer reload.
window.addEventListener('message', (e) => {
if ((e.data || {}).kind === 'kimodo-preview-ready' && composePending) {
setTimeout(() => { if (composePending) { composePending = false; composeBusy = false; renderComposeStrip(); } }, 3000);
}
});
(function wireCompose() {
const launch = document.getElementById('kimodo-compose-launch');
if (launch) launch.addEventListener('click', composeBuild);
const scope = document.getElementById('kimodo-cz-scope');
if (scope) scope.addEventListener('click', (e) => { const b = e.target.closest('.kimodo-cz-segbtn'); if (b) { composeScope = b.dataset.scope; renderComposeLibrary(); } });
const back = document.getElementById('kimodo-cz-createback');
if (back) back.addEventListener('click', () => { composeCurrentKata = null; composeEditing = false; setComposeView('library'); });
const addst = document.getElementById('kimodo-cz-addstance');
if (addst) addst.addEventListener('click', openStancePicker);
const pclose = document.getElementById('kimodo-cz-pickerclose');
if (pclose) pclose.addEventListener('click', closeStancePicker);
const vedit = document.getElementById('kimodo-cz-vname-edit');
if (vedit) vedit.addEventListener('click', startRenameKata);
const vtxt = document.getElementById('kimodo-cz-vname-text');
if (vtxt) { vtxt.style.cursor = 'text'; vtxt.addEventListener('click', startRenameKata); }
const pfilter = document.getElementById('kimodo-cz-pfilter');
if (pfilter) pfilter.addEventListener('input', renderComposePalette);
window.addEventListener('resize', updateComposeViewerName);
})();
// --- Bottom-right HF account widget (login / account drawer). ---
(function () {
function acctInfo() {
const el = inputInside('current-user-info');
if (!el || !el.value) return { username: '', name: '', avatar: '' };
try { return JSON.parse(el.value) || {}; } catch (e) { return { username: '', name: '', avatar: '' }; }
}
function triggerHfLogin() {
// HF Spaces run inside a huggingface.co iframe. iOS/Safari block THIRD-PARTY cookies
// there, so the OAuth session cookie never sticks and login silently fails (HF's own
// docs say to run the flow outside the iframe). Gradio also defers its navigation in a
// 500ms setTimeout, which iOS blocks as non-user-initiated. So: navigate SYNCHRONOUSLY
// within this tap, and when we're framed, break OUT to the Space's own origin so the
// whole OAuth flow is first-party.
const loggedIn = !!acctInfo().username;
const route = (loggedIn ? '/logout' : '/login/huggingface') + '?_target_url=/';
let framed = true;
try { framed = window.self !== window.top; } catch (e) { framed = true; }
const host = (window.__kimodoSpaceHost || '').replace(/^https?:\/\//, '').replace(/\/+$/, '');
// Only SAFARI/iOS blocks third-party cookies in an iframe (ITP), so only there must we
// leave huggingface.co. Other browsers keep the cookie in-frame, so they stay on the
// HF site (gradio self-heals to the host if a browser does block them).
const ua = navigator.userAgent || '';
const appleWebkit = /iP(hone|ad|od)/.test(ua) || (/Safari/.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|Android|OPR)/.test(ua));
if (framed && host && appleWebkit) {
const url = 'https://' + host + route;
try { window.top.location.href = url; return; } catch (e) {} // break the tab out of the iframe (first-party OAuth)
try { window.open(url, '_blank'); return; } catch (e) {} // fallback: new first-party tab
}
try { window.location.assign(route); return; } catch (e) {} // in-frame (HF site) / top-level (.hf.space / local)
const b = document.querySelector('#kimodo-login a, #kimodo-login button, #kimodo-login');
if (b) b.click(); // last-resort fallback
}
function updateAcct() {
const btn = document.getElementById('kimodo-acct-btn');
const drawer = document.getElementById('kimodo-acct-drawer');
if (!btn) return;
const info = acctInfo();
const loggedIn = !!info.username;
btn.textContent = loggedIn ? '☰' : '🤗'; // ☰ : 🤗
btn.title = loggedIn ? 'Account' : 'Sign in with Hugging Face';
if (loggedIn && drawer) {
const av = document.getElementById('kimodo-acct-avatar');
const nm = document.getElementById('kimodo-acct-name');
const us = document.getElementById('kimodo-acct-user');
if (av) { if (info.avatar) { av.src = info.avatar; av.style.display = ''; } else { av.style.display = 'none'; } }
if (nm) nm.textContent = info.name || info.username;
if (us) us.textContent = '@' + info.username;
}
if (!loggedIn && drawer) drawer.classList.remove('open');
}
function wireAcct() {
const btn = document.getElementById('kimodo-acct-btn');
const drawer = document.getElementById('kimodo-acct-drawer');
if (!btn || btn.dataset.ready === '1') return;
btn.dataset.ready = '1';
btn.addEventListener('click', () => {
if (acctInfo().username) { if (drawer) drawer.classList.toggle('open'); }
else triggerHfLogin();
});
const close = document.getElementById('kimodo-acct-close');
if (close) close.addEventListener('click', () => { if (drawer) drawer.classList.remove('open'); });
const logout = document.getElementById('kimodo-acct-logout');
if (logout) logout.addEventListener('click', triggerHfLogin); // LoginButton is "Sign out" when logged in
const tut = document.getElementById('kimodo-acct-tutorial');
if (tut) tut.addEventListener('click', () => {
// Re-arm the tutorial (the WHO AM I tab listens and re-pulses) and close the drawer.
window.dispatchEvent(new CustomEvent('kimodo-tutorial-restart'));
if (drawer) drawer.classList.remove('open');
});
}
// #current-user-info is populated by demo.load after hydration — sync a few times.
let tries = 0;
const timer = setInterval(() => { wireAcct(); updateAcct(); if (++tries > 25) clearInterval(timer); }, 600);
wireAcct(); updateAcct();
})();
// --- Pluggable tabs (tabs/*.tab.js) — run in this same scope, after the helpers. ---
(function () {
let busy = false;
let dojoMode = 'splat';
let lastScene = null;
let dojoScope = 'mine';
let improvedByAya = false; // current prompt came from tiny-aya → button reads "reroll"
let improving = false; // a tiny-aya suggestion call is in flight
let _communityCache = null; // canonical + contributed dojos fetched from the dataset
let _lastDojo = null; // { scene, cfg } of the just-generated dojo (for "Save to dataset")
// Community starters — tapping one opens the create view prefilled, so generating
// produces a real flux + splat scene (which then lands under "Mine").
const COMMUNITY_SEED = [
{ name: 'Classic dojo', mode: 'splat', settings: { room_size: 6.5, room_height: 3.2 },
prompt: 'A traditional martial arts dojo room, polished wooden floor, training mats, wooden wall panels, warm lantern light, full room visible, high detail, no people, no text.' },
{ name: 'Mountain dojo', mode: 'splat', settings: { room_size: 7.5, room_height: 4 },
prompt: 'An outdoor mountain dojo courtyard at sunrise, stone tiles, wooden gates, misty trees, full environment visible, high detail, no people, no text.' },
{ name: 'Neon dojo', mode: 'splat', settings: { room_size: 6.5, room_height: 3.2 },
prompt: 'A futuristic neon martial arts training room, glossy floor, holographic wall panels, dramatic rim light, full room visible, no people, no text.' }
];
function dojoEls() {
return {
library: document.getElementById('kimodo-dojo-library'),
create: document.getElementById('kimodo-dojo-create'),
scope: document.getElementById('kimodo-dojo-scope'),
add: document.getElementById('kimodo-dojo-add'),
back: document.getElementById('kimodo-dojo-back'),
grid: document.getElementById('kimodo-dojo-grid'),
emptyMsg: document.getElementById('kimodo-dojo-empty'),
improve: document.getElementById('kimodo-dojo-improve'),
save: document.getElementById('kimodo-dojo-save'),
erase: document.getElementById('kimodo-dojo-erase'),
prompt: document.getElementById('kimodo-dojo-prompt'),
mode: document.getElementById('kimodo-dojo-mode'),
roomSize: document.getElementById('kimodo-dojo-room-size'),
roomSizeValue: document.getElementById('kimodo-dojo-room-size-value'),
roomHeight: document.getElementById('kimodo-dojo-room-height'),
roomHeightValue: document.getElementById('kimodo-dojo-room-height-value'),
skybox: document.getElementById('kimodo-dojo-skybox'),
seed: document.getElementById('kimodo-dojo-seed'),
steps: document.getElementById('kimodo-dojo-steps'),
generate: document.getElementById('kimodo-dojo-generate'),
clear: document.getElementById('kimodo-dojo-clear'),
status: document.getElementById('kimodo-dojo-status'),
stage: document.getElementById('kimodo-dojo-stage'),
iso: document.getElementById('kimodo-dojo-iso'),
isoImg: document.getElementById('kimodo-dojo-iso-img')
};
}
function setStatus(text) {
const els = dojoEls();
if (els.status) els.status.textContent = text;
}
function formatSeconds(value) {
const seconds = Math.max(0, Math.round(Number(value) || 0));
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
if (minutes <= 0) return rest + 's';
return minutes + 'm ' + String(rest).padStart(2, '0') + 's';
}
function stageLabel(stage) {
if (/^flux/.test(stage || '')) return 'Flux';
if (/^splat/.test(stage || '')) return 'TripoSplat';
if (/^panorama/.test(stage || '')) return 'Panorama';
if (/^viewer/.test(stage || '')) return 'Viewer';
return 'Dojo';
}
function bestProgressText(stage) {
if (!stage) return '';
const progress = Array.isArray(stage.progress) ? stage.progress : [];
for (let i = progress.length - 1; i >= 0; i -= 1) {
if (!progress[i]) continue;
const item = progress[i];
const bits = [];
if (item.desc) bits.push(item.desc);
if (Number.isFinite(Number(item.progress))) bits.push(Math.round(Number(item.progress) * 100) + '%');
if (item.index != null && item.length != null) bits.push(item.index + '/' + item.length + (item.unit ? ' ' + item.unit : ''));
if (bits.length) return bits.join(' - ');
}
if (stage.progress_desc) return stage.progress_desc;
return '';
}
function progressFraction(stage) {
const progress = Array.isArray(stage && stage.progress) ? stage.progress : [];
for (let i = progress.length - 1; i >= 0; i -= 1) {
const item = progress[i] || {};
const direct = Number(item.progress);
if (Number.isFinite(direct) && direct > 0 && direct <= 1) return direct;
const index = Number(item.index);
const length = Number(item.length);
if (Number.isFinite(index) && Number.isFinite(length) && index > 0 && length > 0) return Math.min(1, index / length);
}
return null;
}
function setTiming(stage) {
const els = dojoEls();
if (!els.time || !stage) return;
const parts = [];
const fraction = progressFraction(stage);
const estimatedRemaining = stage.eta == null && fraction && stage.elapsed ? (Number(stage.elapsed) * (1 - fraction)) / fraction : null;
if (stage.elapsed != null) parts.push('elapsed ' + formatSeconds(stage.elapsed));
if (stage.eta != null) parts.push('remaining ' + formatSeconds(stage.eta));
else if (estimatedRemaining != null) parts.push('remaining ~' + formatSeconds(estimatedRemaining));
if (stage.rank != null && stage.rank > 0) parts.push('queue #' + stage.rank);
if (stage.queue_size != null && stage.queue_size > 0) parts.push(stage.queue_size + ' waiting');
if (stage.job_status) parts.push(String(stage.job_status).toLowerCase());
els.time.textContent = parts.length ? stageLabel(stage.stage) + ': ' + parts.join(' - ') : '';
if (els.detail) els.detail.textContent = bestProgressText(stage);
}
function hiddenInput(id) { return inputInside(id); }
function writeHidden(id, value) {
const input = hiddenInput(id);
if (!input) return false;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
function readHidden(id) {
const input = hiddenInput(id);
return input ? input.value || '' : '';
}
function roomSettings() {
const els = dojoEls();
return {
room_size: Math.max(1, Number(els.roomSize && els.roomSize.value) || 6.5),
room_height: Math.max(1, Number(els.roomHeight && els.roomHeight.value) || 3.2),
use_panorama_skybox: !!(els.skybox && els.skybox.checked)
};
}
function sendScene(scene) {
const frame = previewFrame();
if (!scene) return;
const payload = { ...scene, ...roomSettings(), render_mode: dojoMode };
payload.use_panorama_skybox = !!(dojoEls().skybox && dojoEls().skybox.checked);
if (!payload.image && payload.panorama_image) payload.image = payload.panorama_image;
if (payload.render_mode === 'panorama' && !payload.image && payload.source_image) payload.image = payload.source_image;
if (frame && frame.contentWindow) frame.contentWindow.postMessage({ kind: 'dojo-scene', scene: payload, image: payload.image }, '*');
}
var CHAR_HEIGHT_M = 1.7; // character ≈ SMPLX_HEIGHT — room size is shown relative to it
function updateRoomLabels() {
const els = dojoEls();
const settings = roomSettings();
if (els.roomSizeValue) els.roomSizeValue.textContent = settings.room_size.toFixed(1);
if (els.roomHeightValue) els.roomHeightValue.textContent = settings.room_height.toFixed(1);
const ratio = document.getElementById('kimodo-dojo-room-ratio');
if (ratio) ratio.textContent = (settings.room_size / CHAR_HEIGHT_M).toFixed(1);
}
function sendRoomSettings() {
const frame = previewFrame();
const settings = roomSettings();
if (lastScene) Object.assign(lastScene, settings);
if (frame && frame.contentWindow) frame.contentWindow.postMessage({ kind: 'dojo-room-settings', settings }, '*');
}
// ---- Generation stage: iso image loads in, then a 3D-conversion scan ----
function stageReset() {
const els = dojoEls();
if (els.stage) els.stage.hidden = true;
if (els.iso) els.iso.classList.remove('generating', 'has-img', 'converting');
if (els.isoImg) els.isoImg.removeAttribute('src');
if (els.save) els.save.hidden = true;
_lastDojo = null;
}
function stageGenerating() { // iso not ready yet → shimmer placeholder
const els = dojoEls();
if (els.stage) els.stage.hidden = false;
if (els.iso) { els.iso.classList.add('generating'); els.iso.classList.remove('has-img', 'converting'); }
}
function stageIsoReady(src) { // iso image arrived → fade/deblur it in
const els = dojoEls();
if (!src || !els.iso || !els.isoImg) return;
if (els.stage) els.stage.hidden = false;
els.iso.classList.remove('generating');
if (els.isoImg.getAttribute('src') !== src) {
els.isoImg.onload = () => els.iso.classList.add('has-img');
els.isoImg.src = src;
}
if (els.isoImg.complete && els.isoImg.naturalWidth) els.iso.classList.add('has-img');
}
function stageConverting(on) { // 3D-conversion scan overlay
const els = dojoEls();
if (els.iso) els.iso.classList.toggle('converting', !!on);
}
function setBusy(next) {
busy = !!next;
const els = dojoEls();
if (els.generate) {
els.generate.disabled = busy;
els.generate.innerHTML = busy ? '⟳ Generating…' : 'Generate Scene';
}
if (els.improve) {
els.improve.disabled = busy || improving;
if (busy) { els.improve.classList.add('loading'); els.improve.innerHTML = '⟳ generating…'; }
else { els.improve.classList.remove('loading'); updateImproveBtn(); }
}
}
function waitForResult(previousValue, onStage) {
return new Promise((resolve, reject) => {
const started = Date.now();
let lastValue = previousValue || '';
const timer = window.setInterval(() => {
const value = readHidden('dojo-result');
if (value && value !== lastValue) {
lastValue = value;
try {
const msg = JSON.parse(value);
if (onStage) onStage(msg);
if (msg.done || msg.source === 'dit360' || msg.source === 'default' || msg.source === 'none' || msg.error) {
window.clearInterval(timer);
resolve(value);
}
} catch (e) {
window.clearInterval(timer);
reject(e);
}
}
if (Date.now() - started > 600000) {
window.clearInterval(timer);
reject(new Error('Scene generation timed out.'));
}
}, 300);
});
}
function currentConfig() {
const els = dojoEls();
return {
prompt: (els.prompt && els.prompt.value || '').trim(),
seed: Number(els.seed && els.seed.value),
steps: Number(els.steps && els.steps.value),
mode: dojoMode,
...roomSettings()
};
}
// ---------- Saved dojos (localStorage) ----------
function loadMyDojos() { try { return JSON.parse(localStorage.getItem('kimodoMyDojos') || '[]'); } catch (e) { return []; } }
function saveMyDojos(list) {
let arr = (list || []).slice(0, 16);
// Scene payloads carry data-URL splats (can be MBs); drop the oldest until it fits.
while (arr.length) {
try { localStorage.setItem('kimodoMyDojos', JSON.stringify(arr)); return true; }
catch (e) { arr = arr.slice(0, arr.length - 1); }
}
try { localStorage.setItem('kimodoMyDojos', '[]'); } catch (e) {}
return false;
}
function dojoName(prompt) {
const head = (prompt || '').replace(/[.,].*$/, '').trim().replace(/^(a|an|the)\s+/i, '');
const name = head.split(/\s+/).slice(0, 4).join(' ');
return (name || 'Dojo').slice(0, 30);
}
// After a dojo is saved to the dataset, add a LIGHT (URL-backed) entry to My dojos —
// no heavy data-URL splat in localStorage, durable across reloads.
function addMineFromSaved(entry) {
const iso = communityUrl(entry.iso);
const mineEntry = {
id: entry.id, name: entry.name, prompt: entry.prompt, mode: 'splat',
settings: entry.settings, thumb: iso, contributor: entry.contributor, saved: true, ts: Date.now(),
scene: {
ok: true, source: 'klein-triposplat', render_mode: 'splat',
image: iso, isometric_image: iso, source_image: iso, splat: communityUrl(entry.splat),
room_size: entry.settings && entry.settings.room_size, room_height: entry.settings && entry.settings.room_height
}
};
const arr = loadMyDojos().filter((x) => x.id !== entry.id);
arr.unshift(mineEntry);
saveMyDojos(arr);
}
function dojoAnonId() { const a = inputInside('anonymous-client-id'); return a ? (a.value || '') : ''; }
// Publish the just-generated dojo to the dataset (tagged as the contributor) and
// add it to My dojos.
function saveDojoToDataset() {
if (!_lastDojo || !_lastDojo.scene) { setStatus('Generate a dojo first.'); return; }
const sc = _lastDojo.scene, cfg = _lastDojo.cfg || {};
const iso = sc.isometric_image || sc.source_image, splat = sc.splat;
if (!iso || !splat) { setStatus('Nothing to save (no splat).'); return; }
const els = dojoEls();
const trigger = document.querySelector('#dojo-save-btn button, #dojo-save-btn');
if (!trigger || !hiddenInput('dojo-save-payload')) { setStatus('Save backend missing.'); return; }
if (els.save) { els.save.disabled = true; els.save.innerHTML = '⟳ saving…'; }
setStatus('Saving to the dataset…');
writeHidden('dojo-save-result', '');
writeHidden('dojo-save-payload', JSON.stringify({
name: dojoName(cfg.prompt || (els.prompt && els.prompt.value) || 'Dojo'),
prompt: cfg.prompt || '', settings: { room_size: cfg.room_size, room_height: cfg.room_height },
iso: iso, splat: splat, anon_id: dojoAnonId()
}));
const started = Date.now();
const timer = window.setInterval(() => {
const val = readHidden('dojo-save-result');
if (val) { window.clearInterval(timer); finish(val); }
else if (Date.now() - started > 150000) { window.clearInterval(timer); finish('{"ok":false,"error":"timed out"}'); }
}, 400);
window.setTimeout(() => trigger.click(), 40);
function finish(val) {
if (els.save) { els.save.disabled = false; els.save.innerHTML = '☁ Save to my dojos'; }
try {
const r = JSON.parse(val);
if (r.ok && r.entry) {
addMineFromSaved(r.entry);
_communityCache = null; // contributed dojo now shows in Community
if (els.save) els.save.hidden = true;
setStatus('Saved to My dojos ✓ — shared to Community as ' + (r.entry.contributor || 'you') + '.');
} else {
setStatus('Save failed: ' + (r.error || 'unknown'));
}
} catch (e) { setStatus('Save failed.'); }
}
}
// ---------- View + library ----------
function showView(view) {
const els = dojoEls();
if (els.library) els.library.hidden = (view !== 'library');
if (els.create) els.create.hidden = (view !== 'create');
if (view === 'library') renderLibrary();
}
function syncModeButtons() {
const els = dojoEls();
if (!els.mode) return;
els.mode.querySelectorAll('.kimodo-cz-segbtn').forEach((b) => b.classList.toggle('active', b.dataset.mode === dojoMode));
}
function applySettings(settings) {
const els = dojoEls();
if (!settings) return;
if (els.roomSize && settings.room_size) els.roomSize.value = settings.room_size;
if (els.roomHeight && settings.room_height) els.roomHeight.value = settings.room_height;
if (els.skybox) els.skybox.checked = !!settings.use_panorama_skybox;
if (els.seed && settings.seed != null) els.seed.value = settings.seed;
if (els.steps && settings.steps != null) els.steps.value = settings.steps;
updateRoomLabels();
}
function openCreate(seed) {
showView('create');
stageReset();
const els = dojoEls();
improvedByAya = false;
if (seed) {
if (els.prompt) { els.prompt.value = seed.prompt || ''; els.prompt.dispatchEvent(new Event('input', { bubbles: true })); }
dojoMode = seed.mode || 'splat';
syncModeButtons();
applySettings(seed.settings);
setStatus('Loaded "' + (seed.name || 'starter') + '". Tweak and Generate.');
} else {
if (els.prompt) { els.prompt.value = ''; } // blank by default — placeholder "a random dojo"
setStatus('Describe a dojo, or let tiny-aya decide 🎲');
}
updateImproveBtn();
}
function mineCard(entry) {
const card = document.createElement('div');
card.className = 'kimodo-dojo-card';
card.title = entry.name || 'dojo';
if (entry.thumb) {
const img = document.createElement('img');
img.className = 'thumb'; img.src = entry.thumb; img.alt = entry.name || 'dojo';
card.appendChild(img);
} else {
const ph = document.createElement('div'); ph.className = 'ph'; ph.textContent = '🏯'; card.appendChild(ph);
}
const del = document.createElement('button'); del.className = 'del'; del.type = 'button'; del.textContent = '✕'; del.title = 'Delete';
del.onclick = (e) => { e.stopPropagation(); if (!window.confirm('Delete this dojo?')) return; saveMyDojos(loadMyDojos().filter((x) => x.id !== entry.id)); renderLibrary(); };
card.appendChild(del);
const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = entry.name || 'Dojo'; card.appendChild(nm);
card.onclick = () => applySavedDojo(entry);
return card;
}
function communityCard(seed) {
const card = document.createElement('div');
card.className = 'kimodo-dojo-card';
card.title = seed.name;
const ph = document.createElement('div'); ph.className = 'ph'; ph.textContent = '🏯'; card.appendChild(ph);
const tag = document.createElement('span'); tag.className = 'badge'; tag.textContent = 'STARTER'; card.appendChild(tag);
const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = seed.name; card.appendChild(nm);
card.onclick = () => openCreate(seed);
return card;
}
// Canonical community dojos are hosted in the dataset (viewer/dojos/community.json),
// each with a real flux iso thumbnail + a splat that applies instantly.
function communityUrl(path) { return (typeof DATASET_BASE !== 'undefined' && DATASET_BASE ? DATASET_BASE : '') + '/' + path; }
function loadCommunity() {
if (_communityCache) return Promise.resolve(_communityCache);
const grab = (f) => fetch(communityUrl(f), { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null);
return Promise.all([grab('viewer/dojos/community.json'), grab('viewer/dojos/contributed.json')]).then(([canon, contrib]) => {
const list = [].concat(Array.isArray(canon) ? canon : [], Array.isArray(contrib) ? contrib : []);
if (list.length) { _communityCache = list; return list; }
return null;
});
}
function communityRealCard(d) {
const card = document.createElement('div');
card.className = 'kimodo-dojo-card';
card.title = d.name + (d.contributor ? ' · by ' + d.contributor : '');
const img = document.createElement('img'); img.className = 'thumb'; img.src = communityUrl(d.iso); img.alt = d.name; card.appendChild(img);
const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = d.name; card.appendChild(nm);
if (d.contributor) { const by = document.createElement('div'); by.className = 'by'; by.textContent = 'by ' + d.contributor; card.appendChild(by); }
card.onclick = () => applyCommunityDojo(d);
return card;
}
function applyCommunityDojo(d) {
const iso = communityUrl(d.iso);
const scene = {
ok: true, source: 'klein-triposplat', render_mode: 'splat',
image: iso, isometric_image: iso, source_image: iso,
splat: communityUrl(d.splat),
room_size: d.settings && d.settings.room_size, room_height: d.settings && d.settings.room_height
};
dojoMode = 'splat'; syncModeButtons(); applySettings(d.settings);
lastScene = scene; sendScene(scene);
setStatus('Applying "' + d.name + '"…');
}
// Clear the applied dojo scene → back to the empty grid floor.
function clearDojo() {
const frame = previewFrame();
if (frame && frame.contentWindow) frame.contentWindow.postMessage({ kind: 'dojo-clear' }, '*');
lastScene = null;
setStatus('Dojo cleared — back to the grid floor.');
}
function clearCard() {
const card = document.createElement('div');
card.className = 'kimodo-dojo-card kimodo-dojo-clearcard';
card.title = 'Clear the dojo — back to the grid floor';
const ph = document.createElement('div'); ph.className = 'ph'; ph.textContent = '▦'; card.appendChild(ph);
const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = 'No dojo'; card.appendChild(nm);
card.onclick = clearDojo;
return card;
}
function renderLibrary() {
const els = dojoEls();
if (els.scope) els.scope.querySelectorAll('.kimodo-cz-segbtn').forEach((b) => b.classList.toggle('active', b.dataset.scope === dojoScope));
if (!els.grid) return;
els.grid.innerHTML = '';
if (dojoScope === 'mine') {
els.grid.appendChild(clearCard()); // first card: clear + back to the grid floor
const arr = loadMyDojos();
arr.forEach((e) => els.grid.appendChild(mineCard(e)));
if (els.emptyMsg) els.emptyMsg.textContent = arr.length ? '' : 'No dojos yet — tap + Add dojo to create one.';
return;
}
// Community: canonical hosted dojos (instant apply), else the tap-to-generate starters.
if (els.emptyMsg) els.emptyMsg.textContent = 'Loading community dojos…';
loadCommunity().then((list) => {
if (dojoScope !== 'community' || !els.grid) return;
els.grid.innerHTML = '';
els.grid.appendChild(clearCard());
if (list && list.length) list.forEach((d) => els.grid.appendChild(communityRealCard(d)));
else COMMUNITY_SEED.forEach((s) => els.grid.appendChild(communityCard(s)));
if (els.emptyMsg) els.emptyMsg.textContent = '';
});
}
function applySavedDojo(entry) {
const scene = entry.scene;
if (!scene) { openCreate(entry); return; } // recipe-only fallback → re-generate
dojoMode = scene.render_mode || (scene.splat || scene.splat_url ? 'splat' : 'panorama');
syncModeButtons();
applySettings(entry.settings);
lastScene = scene;
sendScene(scene);
setStatus('Applied "' + (entry.name || 'dojo') + '".');
}
// ---------- tiny-aya prompt suggestion ----------
// Improve-button label tracks state: blank prompt → "let tiny-aya decide",
// tiny-aya already wrote one → "reroll", otherwise → "improve".
function updateImproveBtn() {
const els = dojoEls();
if (!els.improve || improving) return;
const has = !!(els.prompt && els.prompt.value.trim());
els.improve.textContent = improvedByAya ? 'Reroll with tiny-aya 🎲'
: (has ? '✦ Improve with tiny-aya' : 'let tiny-aya decide 🎲');
}
function improvePrompt() {
const els = dojoEls();
const payloadInput = hiddenInput('dojo-suggest-payload');
const trigger = document.querySelector('#dojo-suggest-btn button, #dojo-suggest-btn');
if (!payloadInput || !trigger) { setStatus('Suggest backend missing.'); return; }
const words = (els.prompt && els.prompt.value || '').trim();
improving = true;
if (els.improve) { els.improve.disabled = true; els.improve.textContent = '🎲 thinking…'; }
setStatus(words ? 'Rerolling your prompt with tiny-aya…' : 'Letting tiny-aya invent a dojo…');
writeHidden('dojo-suggest-result', '');
writeHidden('dojo-suggest-payload', JSON.stringify({ words: words }));
const started = Date.now();
const timer = window.setInterval(() => {
const val = readHidden('dojo-suggest-result');
if (val) { window.clearInterval(timer); finishImprove(val); }
else if (Date.now() - started > 150000) { window.clearInterval(timer); finishImprove('{"ok":false,"error":"timed out"}'); }
}, 300);
window.setTimeout(() => trigger.click(), 40);
function finishImprove(val) {
improving = false;
if (els.improve) els.improve.disabled = busy;
try {
const r = JSON.parse(val);
if (r.ok && r.prompt) {
if (els.prompt) { els.prompt.value = r.prompt; els.prompt.dispatchEvent(new Event('input', { bubbles: true })); }
improvedByAya = true; // set AFTER the input event (which resets it) so the button reads "reroll"
setStatus('Prompt ready ✦ — tweak it or tap Generate.');
} else {
setStatus('tiny-aya failed: ' + (r.error || 'unknown'));
}
} catch (e) { setStatus('tiny-aya failed.'); }
updateImproveBtn();
}
}
// ---------- Generate ----------
async function runGenerate() {
if (busy) return;
const cfg = currentConfig();
if (!cfg.prompt) { setStatus('Describe a dojo first, or tap “let tiny-aya decide 🎲”.'); return; }
const payloadInput = hiddenInput('dojo-payload');
const trigger = document.querySelector('#dojo-btn button, #dojo-btn');
if (!payloadInput || !trigger) { setStatus('Dojo backend controls are missing.'); return; }
setBusy(true);
stageReset();
stageGenerating();
setStatus('Generating the dojo image…');
writeHidden('dojo-result', '');
writeHidden('dojo-payload', JSON.stringify(cfg));
try {
trigger.click();
const value = await waitForResult('', (stage) => {
const st = stage.stage || '';
const iso = stage.isometric_image || stage.source_image;
if (st === 'flux-iso-start' || st === 'flux-iso-progress') {
stageGenerating();
setStatus(stage.status || 'Generating the dojo image…');
} else if (st === 'flux-iso-done') {
stageIsoReady(iso);
setStatus(stage.status || 'Image ready — converting to 3D…');
} else if (st === 'splat-start' || st === 'splat-progress') {
if (iso) stageIsoReady(iso);
stageConverting(true);
setStatus(bestProgressText(stage) || stage.status || 'Converting to 3D…');
} else if (st === 'splat-done') {
stageConverting(false);
setStatus(stage.status || 'Loading the scene into the viewer…');
} else if (st === 'fallback') {
stageConverting(false);
setStatus(stage.warning || stage.error || 'Fallback scene generated.');
}
});
const scene = JSON.parse(value);
if (scene && (scene.image || scene.splat || scene.splat_url)) {
Object.assign(scene, roomSettings());
lastScene = scene;
sendScene(scene);
// A real splat scene can be saved to the dataset + My dojos (tap the Save button).
if (scene.source === 'klein-triposplat' && scene.splat) {
_lastDojo = { scene: scene, cfg: cfg };
const saveBtn = dojoEls().save; if (saveBtn) saveBtn.hidden = false;
setStatus('Generated ✓ — tap “☁ Save to my dojos” to keep it.');
} else if (scene.source === 'klein-triposplat' || scene.source === 'dit360') {
setStatus(scene.info || 'Scene applied.');
} else {
setStatus((scene.warning || 'Using default scene.') + ' Default scene applied.');
}
} else {
setStatus((scene && scene.error) || 'Scene generation failed.');
}
} catch (e) {
console.error('dojo scene generation failed', e);
setStatus('Scene generation failed: ' + e.message);
} finally {
setBusy(false);
}
}
function initDojo() {
const drawer = document.getElementById('kimodo-dojo-drawer');
const els = dojoEls();
if (!drawer || drawer.dataset.ready === '1') { renderLibrary(); return; }
drawer.dataset.ready = '1';
updateRoomLabels();
showView('library');
if (els.scope) els.scope.addEventListener('click', (e) => {
const b = e.target.closest('.kimodo-cz-segbtn[data-scope]');
if (!b) return;
dojoScope = b.dataset.scope || 'mine';
renderLibrary();
});
if (els.add) els.add.addEventListener('click', () => openCreate(null));
if (els.back) els.back.addEventListener('click', () => showView('library'));
if (els.improve) els.improve.addEventListener('click', () => { if (!busy && !improving) improvePrompt(); });
if (els.save) els.save.addEventListener('click', () => { if (!els.save.disabled) saveDojoToDataset(); });
if (els.erase) els.erase.addEventListener('click', () => {
if (els.prompt) { els.prompt.value = ''; els.prompt.dispatchEvent(new Event('input', { bubbles: true })); els.prompt.focus(); }
setStatus('Prompt cleared — let tiny-aya decide 🎲');
});
// Manual edits drop the "reroll" state (a fresh prompt → "improve"/"decide").
if (els.prompt) els.prompt.addEventListener('input', () => { if (improving) return; improvedByAya = false; updateImproveBtn(); });
if (els.mode) els.mode.addEventListener('click', (e) => {
const button = e.target.closest('.kimodo-cz-segbtn[data-mode]');
if (!button || busy) return;
dojoMode = button.dataset.mode || 'splat';
syncModeButtons();
setStatus(dojoMode === 'panorama' ? 'Panorama mode selected.' : 'Splat mode selected.');
if (lastScene) sendScene(lastScene);
});
['roomSize', 'roomHeight', 'skybox'].forEach((key) => {
const input = els[key];
if (!input) return;
input.addEventListener('input', () => { updateRoomLabels(); sendRoomSettings(); });
input.addEventListener('change', () => { updateRoomLabels(); if (lastScene) sendScene(lastScene); });
});
if (els.generate) els.generate.addEventListener('click', runGenerate);
}
window.addEventListener('kimodo-drawer-open', (e) => {
if (e.detail === 'kimodo-dojo-drawer') initDojo();
});
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.kind === 'dojo-status' && msg.text) {
if (/loaded in viewer/i.test(msg.text)) stageConverting(false);
setStatus(msg.text);
}
});
})();
// WHO AM I tab — info on Karate Wiener, and tutorial STEP 1: the rail tab pulses
// (vaporwave) on first start to invite the user in, stops once they open it, and can be
// re-armed from the account drawer's "Restart tutorial" (kimodo-tutorial-restart event).
(function () {
var TKEY = 'kimodoTutorial';
function load() { try { return JSON.parse(localStorage.getItem(TKEY) || '{}') || {}; } catch (e) { return {}; } }
function save(o) { try { localStorage.setItem(TKEY, JSON.stringify(o)); } catch (e) {} }
function tab() { return document.getElementById('kimodo-whoami-toggle'); }
function setPulse(on) { var t = tab(); if (t) t.classList.toggle('kimodo-whoami-pulse', !!on); }
function seen() { return !!load().whoamiSeen; }
// Pulse the tab until the user has opened it once (step 1 of the tutorial).
function applyState() { setPulse(!seen()); }
function markSeen() {
var o = load();
if (!o.whoamiSeen) { o.whoamiSeen = true; save(o); }
setPulse(false);
}
// Opening the WHO AM I drawer completes step 1.
window.addEventListener('kimodo-drawer-open', function (e) {
if (e.detail === 'kimodo-whoami-drawer') markSeen();
});
// Restart the tutorial (from the account drawer): re-arm step 1's pulse.
window.addEventListener('kimodo-tutorial-restart', function () {
var o = load(); o.whoamiSeen = false; save(o);
setPulse(true);
});
// Don't start the first-run pulse until the intro TITLE overlay has finished. The intro
// (#kw-title-overlay) is added just after this script and REMOVED from the DOM when it
// ends; it covers the whole screen meanwhile. So: wait until it has appeared and gone,
// or — if no intro exists (no audio asset) — proceed after a short grace window.
function gateInitialPulse() {
var present = function () { return !!document.getElementById('kw-title-overlay'); };
var sawIntro = present();
var iv = setInterval(function () {
if (present()) { sawIntro = true; return; } // intro still on screen
if (sawIntro) { clearInterval(iv); applyState(); } // appeared then gone = intro done
}, 250);
// No intro showed up within the grace window => nothing to wait for.
setTimeout(function () { if (!sawIntro && !present()) { clearInterval(iv); applyState(); } }, 1300);
// Hard safety: never wait forever.
setTimeout(function () { clearInterval(iv); applyState(); }, 15000);
}
gateInitialPulse();
})();
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.kind === 'kimodo-preview-ready') {
if (window.__kimodoHideSplash) window.__kimodoHideSplash(); // viewer is up — reveal the page
// Fresh iframe boot/after-generate. The new iframe already applies the
// current clothing from cfg.defaultClothing, so we do NOT resend it here
// (resending re-attaches garments and visibly reloads them).
onPreviewReady(msg.id);
sendViewportInset(); // a freshly-loaded viewer doesn't know the open-drawer offset
} else if (msg.kind === 'kimodo-viewer-error') {
if (window.__kimodoHideSplash) window.__kimodoHideSplash(); // CDN/module load failed — don't trap the user
} else if (msg.kind === 'kimodo-clip-changed') {
// In-place clip switch in the same iframe: just move the card highlight.
onClipChanged(msg.id);
} else if (msg.kind === 'kimodo-frame') {
onFrame(msg); // actions card play-row
kataOnFrame(msg); // kata tree playhead
if (typeof composeOnFrame === 'function') composeOnFrame(msg); // MY KARATE player scrubber
} else if (msg.kind === 'place-update') {
if (typeof composeOnPlaceUpdate === 'function') composeOnPlaceUpdate(msg); // 3D figure moved/rotated
} else if (msg.kind === 'play-kata' && Array.isArray(msg.ids) && msg.ids.length) {
// Picker selected a whole kata: stitch its path client-side (stitchPath lives
// in the kata module) and play it in the viewer.
stitchPath(msg.ids)
.then((m) => loadIntoViewer(m, '', (msg.label || 'kata')))
.catch((e) => console.error('play-kata stitch failed', e));
}
});
writeState();
writeAnonymousClientId();
syncButtons();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();