karate-wiener / app.py
polats's picture
Deploy Karate Wiener (kimodo kata maker)
d446228 verified
Raw
History Blame Contribute Delete
484 kB
from __future__ import annotations
import os
import random
import tempfile
import threading
import time
import traceback
import json
import html
import base64
import hashlib
import io
import mimetypes
import urllib.request
from pathlib import Path
import gradio as gr
import numpy as np
import torch
from scipy.spatial.transform import Rotation
try:
import spaces # type: ignore
GPU = spaces.GPU
except Exception: # pragma: no cover
def GPU(*dargs, **dkwargs): # noqa: N802
def wrap(fn):
return fn
if len(dargs) == 1 and callable(dargs[0]) and not dkwargs:
return dargs[0]
return wrap
from kata_store import store, HFDatasetAnimationStore
from quota_store import _format_remaining, bucket_for_request, quota_store
# Local dev: load secrets (HF_TOKEN, NVIDIA_NIM_API_KEY, …) from a .env next to this file.
# On the Space these come from the runtime env, so a missing .env / dotenv is fine.
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
except Exception:
pass
DEFAULT_GENERATION_MODEL = os.environ.get("KIMODO_MODEL", "kimodo-smplx-rp")
MODEL_NAME = DEFAULT_GENERATION_MODEL
# Optional remote kimodo-motion-api backend (e.g. a workstation running the real
# model). When set, generation HTTP-calls this server instead of loading the model
# in-process -- used by the local Gradio dev server, which can't load the model
# (its transformers version differs from the Space's). The deployed Spaces leave
# this unset and use the in-process @GPU model. Set via KIMODO_REMOTE_URL.
KIMODO_REMOTE_URL = os.environ.get("KIMODO_REMOTE_URL", "").strip().rstrip("/")
# SMPL-X 22-joint parent indices (matches the model's bone order); the remote API
# returns bone_names but not parents, and we have no local model to query offline.
_SMPLX22_PARENTS = [-1, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 12, 13, 14, 16, 17, 18, 19]
GENERATION_MODELS = [
("SMPL-X body (22 joints)", "kimodo-smplx-rp"),
("SOMA body + fingers (77 joints)", "kimodo-soma-rp"),
]
DEFAULT_PROMPT = "a martial artist steps into a fighting stance and throws a straight punch"
MAX_SEED = 2_147_483_647
DEFAULT_DATASET_REPO = "polats/kimodo-kata-animations"
# Approx height (m) of the SMPL-X neutral mesh kimodo motion is authored at.
# Used to stride-scale a display character so its root translation matches its size.
SMPLX_HEIGHT = 1.7
# Alternate display characters the viewer can retarget kimodo motion onto. Each
# is a skinned rig + a {kimodo SMPL-X-22 joint -> rig bone} mapping; the viewer's
# rest-mode retargeter drives it from the clip's global_quats_xyzw. The GLB is
# embedded into the viewer at render time (not stored in preview records).
#
# unirig_citizen: the s&box citizen re-rigged through UniRig (anonymous bone_N
# skeleton). Mapping mirrors kimodo-motion-api web/src/rigs.js unirigCitizenMapping().
_CITIZEN_GLB_PATH = Path(__file__).parent / "assets" / "unirig_citizen.glb"
UNIRIG_CITIZEN_MAPPING = {
"pelvis": "bone_0",
"spine1": "bone_1", "spine2": "bone_2", "spine3": "bone_3",
"neck": "bone_4", "head": "bone_5",
"left_hip": "bone_44", "right_hip": "bone_48",
"left_knee": "bone_45", "right_knee": "bone_49",
"left_ankle": "bone_46", "right_ankle": "bone_50",
"left_foot": "bone_47", "right_foot": "bone_51",
"left_collar": "bone_6", "right_collar": "bone_25",
"left_shoulder": "bone_7", "right_shoulder": "bone_26",
"left_elbow": "bone_8", "right_elbow": "bone_27",
"left_wrist": "bone_9", "right_wrist": "bone_28",
}
def _load_glb_b64(path: Path) -> str:
try:
return base64.b64encode(path.read_bytes()).decode("ascii")
except Exception as exc: # pragma: no cover - missing asset just disables the rig
print(f"Could not load display character GLB {path}: {exc}")
return ""
# Loaded once at import; injected into the viewer srcdoc, never into stored records.
_CHARACTER_CATALOG = [
{"id": "skeleton", "label": "Procedural skeleton"},
{
"id": "citizen",
"label": "s&box Citizen",
"mapping": UNIRIG_CITIZEN_MAPPING,
"scale": 1.0,
"glb_b64": _load_glb_b64(_CITIZEN_GLB_PATH),
},
]
# Citizen is the default view (falls back to the skeleton for clips with no quats).
DEFAULT_CHARACTER = "citizen"
# Clothing garments: skinned GLBs re-rigged onto the SAME citizen bone_N skeleton
# (decompiled from s&box .vmdl_c via clothing_rig.py). A garment is "worn" by
# name-matching its bones to the citizen's and copying transforms each frame, so
# it deforms with the body. Too large to embed per render, so they're hosted in
# the dataset store and loaded by URL (browser-cached across renders).
_DATASET_REPO = os.environ.get("KIMODO_DATASET_REPO", "").strip() or DEFAULT_DATASET_REPO
_VIEWER_ASSET_BASE = f"https://huggingface.co/datasets/{_DATASET_REPO}/resolve/main/viewer"
# Space owner(s): the HF usernames allowed to set the featured (start-up) kata. Comma list.
OWNER_USERNAMES = {u.strip().lower() for u in os.environ.get("KIMODO_OWNER_USERNAMES", "polats").split(",") if u.strip()}
# Where the featured-kata pointer lives in the dataset (read at start-up to pick the kata
# that plays first, and written by an owner via set_featured).
_FEATURED_PATH = "viewer/featured.json"
# Community kata upvotes live here: { root_id: [voter_key, ...] } (voter_key = lowercased
# username, else "anon:<anonId>"). Count = len(voters). Read at start-up for the Top sort.
_VOTES_PATH = "viewer/votes.json"
# One garment per slot can be worn; slot -> wardrobe dropdown label.
CLOTHING_SLOTS = {"head": "Hat", "face": "Glasses", "torso_over": "Jacket", "legs": "Trousers"}
CLOTHING_CATALOG = [
{"id": "beanie_green", "label": "Beanie Green", "slot": "head", "layer": 3,
"url": f"{_VIEWER_ASSET_BASE}/clothing/beanie_green_sausage.glb"},
{"id": "nerdy_glasses", "label": "Nerdy Glasses", "slot": "face", "layer": 4,
"url": f"{_VIEWER_ASSET_BASE}/clothing/nerdy_glasses_sausage.glb"},
{"id": "kimono", "label": "Kimono", "slot": "torso_over", "layer": 2,
"url": f"{_VIEWER_ASSET_BASE}/clothing/kimono_sausage.glb"},
{"id": "kimono_trousers", "label": "Kimono Trousers", "slot": "legs", "layer": 1,
"url": f"{_VIEWER_ASSET_BASE}/clothing/kimono_trousers_sausage.glb"},
]
# Worn on the citizen by default. (No beanie by default — the head slot is empty;
# it's still in the catalog so it can be added from the wardrobe.)
DEFAULT_CLOTHING = {"face": "nerdy_glasses",
"torso_over": "kimono", "legs": "kimono_trousers"}
_DEFAULT_SCENE_PATH = Path(__file__).parent / "assets" / "default_scene.webp"
_TITLE_OVERLAY_AUDIO_PATH = Path(__file__).parent / "assets" / "karate_wiener_title_overlay.wav"
# Looping theme that starts when the intro overlay's sting finishes. Embedded as a base64 data URI
# (same as the intro) so it never depends on HF asset serving; kept as MP3 (not WAV) so the inline
# payload stays ~1.5 MB instead of ~15 MB.
_THEME_AUDIO_PATH = Path(__file__).parent / "assets" / "karate_wiener_source.mp3"
DIT360_SPACE = os.environ.get("KIMODO_DIT360_SPACE", "Insta360-Research/DiT360").strip()
KLEIN_SPACE = os.environ.get("KIMODO_KLEIN_SPACE", "polats/tiny-army-klein-zerogpu").strip()
TRIPOSPLAT_SPACE = os.environ.get("KIMODO_TRIPOSPLAT_SPACE", "polats/kimodo-triposplat-zerogpu").strip()
# tiny-aya (Cohere Tiny Aya) text model — used to suggest/improve dojo scene prompts AND
# to voice Karate Wiener's chat replies (WHO AM I tab). Falls back to hosted Nemotron
# (NVIDIA NIM, see NIM_KEY below) when the Space is asleep / out of quota / erroring.
TINY_AYA_SPACE = os.environ.get("KIMODO_TINY_AYA_SPACE", "polats/tiny-army-tiny-aya-zerogpu").strip()
# VoxCPM text-to-speech Space — clones Karate Wiener's voice from a reference wav for the
# chat. A ZeroGPU "sidecar" Space reached via gradio_client (same call locally + on the
# Space), mirroring tiny-army's _voxcpm_predict. Reference voice lives in the dataset.
VOXCPM_SPACE = os.environ.get("KIMODO_VOXCPM_SPACE", "polats/tiny-army-voxcpm-zerogpu").strip()
_WIENER_VOICE_PATH = "viewer/whoami/wiener-voice.wav"
# Hosted Nemotron (NVIDIA NIM) — OpenAI-compatible chat endpoint used as a fallback for the
# Tiny Aya Space when it is asleep / out of GPU quota / erroring (mirrors tiny-army, where
# Nemotron-30B is too big to self-host so it runs via hosted NIM). Set NVIDIA_NIM_API_KEY
# (an nvapi-… key) to enable; without it, the chat/dojo prompt just relies on Tiny Aya.
NIM_KEY = os.environ.get("NVIDIA_NIM_API_KEY", "").strip()
_NIM_TEXT_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
_NIM_NEMOTRON_MODEL = os.environ.get("KIMODO_NEMOTRON_NIM_MODEL", "nvidia/nemotron-3-nano-30b-a3b")
# --- Pluggable side tabs ---------------------------------------------------
# A NEW side tab is added by DROPPING FILES into ./tabs — NO app.py edit needed,
# so multiple people can build tabs in parallel without touching shared code:
# tabs/<name>.tab.html -> the rail button (.kimodo-left-tab[data-drawer=…])
# tabs/<name>.drawer.html -> the slide-in <aside id="kimodo-<name>-drawer"> panel
# tabs/<name>.tab.css -> styles for that tab
# tabs/<name>.tab.js -> behavior; runs INSIDE the drawer-JS scope, so it can
# call the shared helpers (previewFrame, fetchManifest,
# fetchRecord, loadIntoViewer, setOpen, sendViewportInset…).
# All matching files are globbed + concatenated in sorted filename order at import.
# The drawer manager auto-discovers the new button+aside from the DOM; the tab hooks
# its "drawer opened" behavior WITHOUT editing setOpen via:
# window.addEventListener('kimodo-drawer-open', (e) => {
# if (e.detail === 'kimodo-<name>-drawer') { /* refresh / build */ }
# });
# Tabs needing a Python/@GPU backend are the only case that still edits app.py — at
# the "NEW-TAB BACKEND EXTENSION POINT" marker near the compose wiring.
# See tabs/README.md + tabs/templates/ for a complete copy-paste example.
_TABS_DIR = Path(__file__).parent / "tabs"
def _read_tabs(pattern: str) -> str:
if not _TABS_DIR.is_dir():
return ""
return "".join(p.read_text(encoding="utf-8") for p in sorted(_TABS_DIR.glob(pattern)))
def _load_b64(path: Path) -> str:
try:
return base64.b64encode(path.read_bytes()).decode("ascii")
except Exception as exc: # pragma: no cover - optional startup flourish
print(f"Could not load {path}: {exc}")
return ""
APP_CSS = """
#main-preview {
min-width: 0;
}
/* Viewer-only layout: the whole screen is the 3D viewer; every other Gradio control
is hidden (kept in the DOM so the drawer-driven handlers still read/fire them).
All interaction happens through the fixed drawers (ACTIONS / KATA / STANCES /
COMPOSE / CLOTHING).
IMPORTANT: never size the viewer with 100vh — on HF the gradio app sits in an
iframe-resizer that grows to content height, and vh-against-self feedback-loops
into an infinitely scrollable page. Instead bound the document to the iframe's
own height (height:100% chain + overflow:hidden) and fill it with a fixed-inset
viewer — the same out-of-flow approach tiny-army uses for its full-bleed stage. */
.kimodo-chrome { display: none !important; }
/* Bottom-right HF account widget + right-slide account drawer. */
.kimodo-acct-btn {
position: fixed; right: 14px; bottom: 14px; z-index: 90;
width: 44px; height: 44px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 22px; line-height: 1; cursor: pointer; padding: 0;
background: #2a2a31; border: 1px solid #4a4a52; color: #eee;
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
pointer-events: auto; /* drawer-root is pointer-events:none — re-enable on the widget */
}
.kimodo-acct-btn:hover { background: #34343c; }
.kimodo-acct-drawer {
position: fixed; top: 0; right: 0; height: 100%; width: 262px; max-width: 82vw;
z-index: 96; box-sizing: border-box; padding: 16px;
background: #1b1b20; border-left: 1px solid #3a3a40; box-shadow: -6px 0 20px rgba(0,0,0,0.55);
transform: translateX(100%); transition: transform .26s ease;
display: flex; flex-direction: column; gap: 14px; pointer-events: auto;
touch-action: pan-x pan-y; /* no pinch/double-tap zoom (keeps taps reliable on mobile) */
}
.kimodo-acct-drawer.open { transform: translateX(0); }
.kimodo-acct-head { display: flex; align-items: center; gap: 10px; }
.kimodo-acct-avatar { width: 46px; height: 46px; border-radius: 50%; object-fit: cover; background: #2a2a31; flex: 0 0 auto; }
.kimodo-acct-id { min-width: 0; }
.kimodo-acct-name { font-size: 14px; font-weight: 700; color: #f0f1f4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kimodo-acct-user { font-size: 11px; color: #9aa0aa; }
.kimodo-acct-close { margin-left: auto; align-self: flex-start; background: none !important; border: 0 !important; box-shadow: none !important; color: #999 !important; font-size: 16px; line-height: 1; cursor: pointer; padding: 2px; }
.kimodo-acct-close:hover { color: #ddd !important; }
.kimodo-acct-logout {
margin-top: auto; padding: 9px; border-radius: 8px; cursor: pointer;
background: #4a2626 !important; color: #ffb3b3 !important; border: 1px solid #6a3636 !important;
box-shadow: none !important; font-size: 12.5px; font-weight: 600;
}
.kimodo-acct-logout:hover { background: #5a2e2e !important; }
.kimodo-acct-tutorial {
margin-top: auto; padding: 9px; border-radius: 8px; cursor: pointer;
background: #2a2440 !important; color: #d9cffb !important; border: 1px solid #4a3a6a !important;
box-shadow: none !important; font-size: 12.5px; font-weight: 600;
}
.kimodo-acct-tutorial:hover { background: #342c52 !important; }
/* logout sits right under the tutorial button when both are present */
.kimodo-acct-tutorial + .kimodo-acct-logout { margin-top: 8px; }
/* Local-only "Publish to community" button on Mine kata cards (shown only when
KIMODO_BACKEND is set — on the Space katas already auto-publish). */
.kimodo-cz-katapub {
margin-top: 6px; width: 100%; padding: 6px 9px; border-radius: 6px; cursor: pointer;
font-size: 11.5px; font-weight: 600; color: #efe9ff;
background: #6b4fd0; border: 1px solid #8266e0; box-shadow: none;
}
.kimodo-cz-katapub:hover:not(:disabled) { background: #7a5ee0; }
.kimodo-cz-katapub:disabled { opacity: 0.55; cursor: default; }
.kimodo-cz-katapub.published { background: #2f5d3a; border-color: #3f7d4a; color: #cfeed6; cursor: default; }
.kimodo-cz-katafeat {
margin-top: 6px; width: 100%; padding: 6px 9px; border-radius: 6px; cursor: pointer;
font-size: 11.5px; font-weight: 600; color: #ffe9a8;
background: #6a5320 !important; border: 1px solid #a07d2e !important; box-shadow: none !important;
}
.kimodo-cz-katafeat:hover:not(:disabled) { background: #7c6226 !important; color: #fff1c4 !important; }
.kimodo-cz-katafeat:disabled { cursor: default; }
.kimodo-cz-katafeat.featured { background: #8a6d12 !important; border-color: #d9ad33 !important; color: #fff6d6 !important; cursor: default; }
/* Owner-only delete (sits just below ★ Set as featured). */
.kimodo-cz-katadel {
margin-top: 6px; width: 100%; padding: 6px 9px; border-radius: 6px; cursor: pointer;
font-size: 11.5px; font-weight: 600; color: #ffc9c9;
background: #5a2424 !important; border: 1px solid #8a3a3a !important; box-shadow: none !important;
}
.kimodo-cz-katadel:hover:not(:disabled) { background: #6e2c2c !important; color: #ffe0e0 !important; }
.kimodo-cz-katadel:disabled { cursor: default; opacity: .7; }
/* SHARED browse header (kata + dojo libraries): the Mine/Community pill + create/add
button + search/sort, anchored (sticky) at the top of the scrolling list so they don't
scroll away. Both libraries are their own scroll container, so `position:sticky` works. */
.kimodo-libhead { position: sticky; top: 0; z-index: 8; display: flex; flex-direction: column; gap: 8px; padding: 2px 0 9px; margin-bottom: 4px; background: #16161b; border-bottom: 1px solid #2a2233; }
.kimodo-libhead .kimodo-cz-seg, .kimodo-libhead .kimodo-cz-libctrls, .kimodo-libhead .kimodo-cz-createbtn, .kimodo-libhead .kimodo-dojo-addbtn { margin: 0 !important; }
/* Shared search input (kata + dojo). */
.kimodo-libsearch { width: 100%; box-sizing: border-box; padding: 8px 10px; font-size: 13px; color: #f2e6ff; background: #1c1622; border: 1px solid #4a3a60; border-radius: 8px; }
.kimodo-libsearch::placeholder { color: #8a7aa0; }
.kimodo-libsearch:focus { outline: none; border-color: #a86fe0; }
/* Library browse controls: search filter + sort select. */
.kimodo-cz-libctrls { display: flex; gap: 6px; }
.kimodo-cz-cfilter { flex: 1; margin-bottom: 0 !important; }
.kimodo-cz-csort { flex: 0 0 auto; padding: 8px 8px; font-size: 12px; color: #f2e6ff; background: #1c1622; border: 1px solid #4a3a60; border-radius: 8px; cursor: pointer; }
.kimodo-cz-csort:focus { outline: none; border-color: #a86fe0; }
/* Header "Create a kata" button (was an in-list card; now anchored, Mine only). */
.kimodo-cz-createbtn { display: flex; align-items: center; justify-content: center; gap: 7px; width: 100%; padding: 11px; border-radius: 9px; cursor: pointer; font-size: 13px; font-weight: 600; color: #e6c7ff !important; background: #3a2a55 !important; border: 1px dashed #a86fe0 !important; box-shadow: none !important; }
.kimodo-cz-createbtn:hover { background: #46356a !important; }
/* Card footer: upvote on the left, "★ Featured" badge pushed to the lower-right. */
.kimodo-cz-cardfoot { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
/* Per-card upvote: ▲ + count; filled when this browser has voted. */
.kimodo-cz-katavote { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 14px; cursor: pointer; font-size: 12px; font-weight: 700; color: #cbb6ff !important; background: #2c2140 !important; border: 1px solid #6a4a9a !important; box-shadow: none !important; }
.kimodo-cz-katavote:hover:not(:disabled) { background: #3a2a55 !important; color: #e6c7ff !important; }
.kimodo-cz-katavote:disabled { opacity: .6; cursor: default; }
.kimodo-cz-katavote .cz-arrow { font-size: 11px; line-height: 1; }
.kimodo-cz-katavote.voted { color: #1a1320 !important; background: #b79bff !important; border-color: #cdb6ff !important; }
/* "★ Featured" chip (orange) — sits in the footer, lower-right beside the votes. */
.kimodo-cz-featbadge { margin-left: auto; padding: 2px 9px; border-radius: 11px; font-size: 10px; font-weight: 800; letter-spacing: .3px; color: #2a1500; background: #f0a23c; border: 1px solid #ffc06a; white-space: nowrap; }
/* Featured kata card: orange-tinted. */
.kimodo-cz-katacard.cz-featured { background: #2e2316; border-color: #d98a2e; }
.kimodo-cz-katacard.cz-featured:hover { border-color: #f0a23c; background: #38291a; }
/* Currently-watching kata: bright ring so you know which one is playing. */
.kimodo-cz-katacard.cz-watching { border-color: #7dd3fc !important; box-shadow: 0 0 0 1px #7dd3fc inset; }
html, body, gradio-app, .gradio-container { height: 100%; margin: 0; overflow: hidden; }
.gradio-container { padding: 0 !important; max-width: 100% !important; }
.gradio-container > .main, .gradio-container .contain { gap: 0 !important; }
#main-preview { position: fixed; inset: 0; z-index: 1; overflow: hidden; }
#main-preview iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }
#main-preview > div, #main-preview > div > * { height: 100%; }
#clothing-state,
#dojo-payload,
#dojo-result,
#dojo-btn,
#dojo-suggest-payload,
#dojo-suggest-result,
#dojo-suggest-btn,
#dojo-save-payload,
#dojo-save-result,
#dojo-save-btn,
#kata-publish-payload,
#kata-publish-result,
#kata-publish-btn,
#kata-featured-payload,
#kata-featured-result,
#kata-featured-btn,
#kata-delete-payload,
#kata-delete-result,
#kata-delete-btn,
#kata-vote-payload,
#kata-vote-result,
#kata-vote-btn,
#wiener-chat-payload,
#wiener-chat-result,
#wiener-chat-btn,
#wiener-speak-payload,
#wiener-speak-result,
#wiener-speak-btn,
#anonymous-client-id,
#regen-prompt,
#regen-seconds,
#regen-btn,
#current-user,
#current-user-info,
#del-id,
#del-btn,
#cont-prompt,
#cont-seconds,
#cont-source-id,
#cont-source-frame,
#cont-btn,
#compose-payload,
#compose-btn {
display: none !important;
}
.kimodo-drawer-root {
position: fixed;
inset: 0 auto auto 0;
z-index: 80;
pointer-events: none;
}
.kimodo-left-tabs {
position: fixed;
left: 0;
/* Pinned to the top in every context: the centered rail collided with the
(vertically centered) resize grip on the direct .hf.space URL, and when the HF
iframe grows to content height a centered rail lands far down the page. */
top: 12px;
transform: none;
z-index: 82;
display: flex;
flex-direction: column;
gap: 6px;
transition: left .22s ease;
pointer-events: auto;
}
.kimodo-left-tabs.kimodo-embedded {
top: 12px;
transform: none;
}
.kimodo-left-tab {
writing-mode: vertical-rl;
padding: 14px 5px;
font-size: 12px;
letter-spacing: 2px;
cursor: pointer;
touch-action: manipulation; /* immediate tap, no double-tap zoom on the rail */
background: rgba(40,40,46,0.92) !important;
color: #cdd !important;
border: 1px solid #4a4a52 !important;
border-left: 0 !important;
border-radius: 0 8px 8px 0 !important;
box-shadow: none !important;
}
.kimodo-left-tab:hover {
background: rgba(62,62,70,0.95) !important;
}
.kimodo-left-tab.active {
background: #2d4a35 !important;
border-color: #3a6 !important;
color: #9fd !important;
}
/* Minimal mode: only the COMPOSE tab is exposed for now; the rest of the rail is
hidden (the drawers + their handlers still exist, just no tab to open them). */
.kimodo-left-tab { display: none !important; }
#kimodo-compose-toggle { display: block !important; }
.kimodo-drawer {
position: fixed;
top: 0;
left: 0;
/* Cap at half the viewport on narrow screens so the drawer doesn't obscure the
viewer on mobile; stays 330px on desktop where 50vw is larger. */
width: min(330px, 50vw);
height: 100%;
z-index: 81;
box-sizing: border-box;
background: rgba(20,20,24,0.97);
backdrop-filter: blur(6px);
border-right: 1px solid #3a3a40;
padding: 14px 12px;
overflow-y: auto;
transform: translateX(-100%);
transition: transform .22s ease;
pointer-events: auto;
color: #e3e3e8;
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
/* Mobile: allow scrolling/panning but disable pinch + double-tap zoom inside the drawer
(the zoom shifted the layout mid-tap, making buttons hard to hit). touch-action on the
container also constrains its descendants. */
touch-action: pan-x pan-y;
}
.kimodo-drawer.open {
transform: translateX(0);
}
/* Wider variant for the kata move-tree drawer (half-viewport cap on mobile). */
.kimodo-drawer--wide {
width: min(460px, 50vw);
}
/* On narrow screens, halve the drawers so they don't obscure the viewer. The JS
(layoutDrawerWidth) also sets an inline width from the measured window size,
which is more reliable than vw inside the HF embed iframe. */
@media (max-width: 700px) {
.kimodo-drawer { width: 50vw !important; }
.kimodo-drawer--wide { width: 55vw !important; }
/* Reclaim a few px for the cards on a thin mobile sidebar by slimming the scrub rail. */
.kimodo-cz-ptrack { padding-right: 20px; }
.kimodo-cz-prail { width: 18px; }
}
/* Drag handle on the OPEN drawer's right edge — drag it to trade panel width for more
of the 3D view. JS (positionResizer) parks it at the drawer's right edge; the grip is
a subtle pill so it's discoverable without cluttering the edge. */
#kimodo-drawer-resizer {
position: fixed; top: 0; height: 100%; width: 12px; z-index: 82;
display: none; cursor: ew-resize; touch-action: none; pointer-events: auto;
}
#kimodo-drawer-resizer .grip {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 4px; height: 30px; border-radius: 3px; background: #c9b6ea; /* plain bar (NOT dots) so it isn't confused with the duration handle */
transition: background .15s ease;
}
#kimodo-drawer-resizer:hover .grip,
#kimodo-drawer-resizer.dragging .grip { background: #6fd0a0; }
.kimodo-drawer h2 {
margin: 0 0 6px;
font-size: 14px;
color: #f1f5f9;
}
.kimodo-drawer-sub {
font-size: 11px;
color: #9aa3ad;
margin-bottom: 12px;
line-height: 1.45;
}
.kimodo-slot-title {
font-size: 11px;
color: #8c96a3;
text-transform: uppercase;
letter-spacing: .05em;
margin: 12px 0 5px;
}
.kimodo-drawer-option {
display: block;
width: 100%;
text-align: left;
margin-bottom: 6px;
padding: 9px 11px;
font-size: 13px;
cursor: pointer;
border-radius: 7px;
background: #26262b !important;
color: #e3e3e8 !important;
border: 1px solid #3a3a40 !important;
box-shadow: none !important;
}
.kimodo-drawer-option:hover {
background: #30303a !important;
}
.kimodo-drawer-option.on {
background: #2d4a35 !important;
border-color: #3a6 !important;
color: #9fd !important;
}
/* === MY KARATE (storyboard composer) — a thin left side drawer ===============
Palette = a horizontal swipe row sized to show ~1 pose card with the next
peeking. Sequence = a centered vertical column of the SAME-sized cards, each
with its transition prompt + duration bar stacked below it. */
#kimodo-compose-toggle { color: #d9b6ff; }
#kimodo-compose-toggle.active { background: #3a2a55 !important; border-color: #a86fe0 !important; color: #e6c7ff !important; }
.kimodo-cz-statusline { font-size: 11px; color: #b59be0; min-height: 0; margin: 6px 2px 0; }
.kimodo-cz-statusline:empty { display: none; }
/* Shared card dimensions for BOTH the palette (selection) and the sequence (added). */
.kimodo-cz-pcard, .kimodo-cz-card { position: relative; width: 150px; box-sizing: border-box; padding: 7px; border-radius: 9px; background: #241a30; border: 1px solid #4a3a60; display: flex; flex-direction: column; gap: 5px; cursor: pointer; }
.kimodo-cz-pcard.sel, .kimodo-cz-card.sel { border-color: #c79bff; box-shadow: 0 0 0 1px #c79bff; background: #2c2140; }
/* Role colors: the kata's START (green) and END (red) stances stand out from the
middle ones; their tag labels match. .sel still overrides while previewing. */
.kimodo-cz-card.start { border-color: #5fb98c; }
.kimodo-cz-card.start .kimodo-cz-tag { color: #6fd0a0; }
.kimodo-cz-card.end { border-color: #d9776a; }
.kimodo-cz-card.end .kimodo-cz-tag { color: #e8907f; }
/* "Done" state: a move whose clip has been generated (after Build & Play) — its
card + transition prompt take a muted green tint + a ✓, cleared on any edit. */
.kimodo-cz-move.built .kimodo-cz-card { background: #1b2620; }
.kimodo-cz-move.built .kimodo-cz-card::after { content: '✓'; position: absolute; bottom: 6px; right: 8px; font-size: 12px; color: #6fd0a0; }
.kimodo-cz-move.built .kimodo-cz-tprompt { background: #16201a; border-color: #3a5a44; color: #b8d8c4; }
.kimodo-cz-move.built .kimodo-cz-arrow { color: #5fb98c; }
.kimodo-cz-pthumb, .kimodo-cz-thumb { position: relative; height: 120px; border-radius: 6px; background: #15101d; display: flex; align-items: center; justify-content: center; overflow: hidden; color: #6a5a86; font-size: 22px; }
.kimodo-cz-thumbnum { position: absolute; top: 3px; left: 4px; z-index: 2; font-size: 9px; font-weight: 700; color: #d9c0ff; background: rgba(20,16,28,0.78); border: 1px solid #4a3a60; border-radius: 4px; padding: 0 4px; line-height: 1.5; }
.kimodo-cz-pthumb svg, .kimodo-cz-thumb svg { width: 100%; height: 100%; display: block; }
.kimodo-cz-pname, .kimodo-cz-cardname { font-size: 10.5px; color: #d9c8ef; line-height: 1.25; max-height: 26px; overflow: hidden; }
.kimodo-cz-cardtop { display: flex; align-items: center; gap: 4px; }
.kimodo-cz-tag { flex: 1; font-size: 8px; letter-spacing: 1px; color: #a86fe0; }
.kimodo-cz-padd { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; border-radius: 50%; background: rgba(58,42,85,0.92); color: #e6c7ff; border: 1px solid #6a4a9a; font-size: 13px; line-height: 1; cursor: pointer; padding: 0; }
.kimodo-cz-padd:hover { background: #4a3570; }
.kimodo-cz-x { background: none; border: none; color: #8a6a9a; font-size: 12px; cursor: pointer; padding: 0 2px; flex: 0 0 auto; }
.kimodo-cz-x:hover { color: #e88; }
/* Palette: horizontal swipe; ~one card + a peek of the next so it reads as scrollable. */
.kimodo-cz-palette { display: flex; flex-wrap: nowrap; gap: 8px; overflow-x: auto; overflow-y: hidden; padding: 2px 0 10px; margin-bottom: 8px; border-bottom: 1px solid #3a2a55; -webkit-overflow-scrolling: touch; }
.kimodo-cz-palette .kimodo-cz-pcard { flex: 0 0 auto; }
/* Sequence: centered vertical column. Each move = the transition (prompt+duration)
that flows INTO the card, then the centered card — so the prompt sits BETWEEN the
previous card and this one, with a ↓ arrow showing the direction of the move. */
.kimodo-cz-strip { display: flex; flex-direction: column; gap: 10px; }
.kimodo-cz-move { display: flex; flex-direction: column; align-items: stretch; gap: 8px; }
.kimodo-cz-move .kimodo-cz-card { align-self: center; }
.kimodo-cz-trans { display: flex; flex-direction: column; gap: 7px; }
.kimodo-cz-arrow { text-align: center; font-size: 18px; line-height: 1; color: #8a6ab0; margin-bottom: -1px; }
.kimodo-cz-tprompt { width: 100%; box-sizing: border-box; font: inherit; font-size: 11px; line-height: 1.35; resize: vertical; min-height: 48px; padding: 5px 7px; background: #1c1622; color: #e3d8ef; border: 1px solid #4a3a60; border-radius: 6px; }
.kimodo-cz-durrow { display: flex; flex-direction: column; gap: 4px; }
.kimodo-cz-durhead { display: flex; justify-content: space-between; align-items: baseline; }
.kimodo-cz-fieldlab { font-size: 10px; color: #9a8ab0; text-transform: uppercase; letter-spacing: .04em; }
.kimodo-cz-durval { font-size: 11px; color: #d9c8ef; }
.kimodo-cz-durbar { width: 100%; accent-color: #a86fe0; cursor: pointer; margin: 0; }
.kimodo-cz-ttrow { display: flex; gap: 8px; }
.kimodo-cz-ttfield { flex: 1; display: flex; flex-direction: column; gap: 3px; font-size: 9.5px; color: #9a8ab0; min-width: 0; }
.kimodo-cz-dial { width: 100%; min-width: 0; font-size: 11px; padding: 4px 5px; background: #1c1622; color: #d9c8ef; border: 1px solid #4a3a60; border-radius: 5px; }
.kimodo-cz-hint { font-size: 11.5px; color: #f0f1f4; text-align: center; padding: 8px 8px; border-radius: 8px; background: #2c2c33; border: 1px solid #4a4a52; font-weight: 500; line-height: 1.35; }
.kimodo-cz-hint.pulse, .kimodo-cz-pickhint.pulse { animation: kimodo-hint-pulse .55s ease; }
@keyframes kimodo-hint-pulse {
0% { box-shadow: 0 0 0 0 rgba(168,111,224,0); transform: scale(1); }
25% { box-shadow: 0 0 0 5px rgba(168,111,224,0.5); transform: scale(1.03); background: #3a3a44; }
100% { box-shadow: 0 0 0 0 rgba(168,111,224,0); transform: scale(1); }
}
/* Add / remove transitions so cards animate in/out instead of popping. */
@keyframes kimodo-cz-pop { from { opacity: 0; transform: translateY(-10px) scale(.95); } to { opacity: 1; transform: none; } }
.kimodo-cz-move.entering { animation: kimodo-cz-pop .22s ease both; }
.kimodo-cz-move.leaving { opacity: 0; transform: translateY(-8px) scale(.95); pointer-events: none; transition: opacity .18s ease, transform .18s ease; }
/* Fixed bottom-of-page launch button (only visible while a sequence exists). */
#kimodo-compose-launch { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 90;
display: none; padding: 11px 24px; font-size: 14px; font-weight: 600; border-radius: 24px; cursor: pointer; pointer-events: auto;
background: #2d4a35; color: #9fd; border: 1px solid #3a6; box-shadow: 0 4px 18px rgba(0,0,0,0.45); }
#kimodo-compose-launch.show { display: block; }
#kimodo-compose-launch:disabled { opacity: .6; cursor: default; }
/* Segmented toggles (My Katas | Create, and Mine | Community). */
.kimodo-cz-seg { display: flex; gap: 3px; padding: 3px; margin-bottom: 10px; border-radius: 9px; background: #1c1622; border: 1px solid #3a2a55; }
.kimodo-cz-seg--sub { background: #181420; margin-bottom: 8px; }
.kimodo-cz-segbtn { flex: 1; padding: 7px 4px; font-size: 11.5px; font-weight: 600; cursor: pointer; border-radius: 6px; background: transparent !important; color: #9a8ab0 !important; border: 0 !important; box-shadow: none !important; }
.kimodo-cz-segbtn.active { background: #3a2a55 !important; color: #e6c7ff !important; }
.kimodo-cz-seg--sub .kimodo-cz-segbtn { padding: 5px 4px; font-size: 10.5px; }
/* Move-mode pill (Normal | Advanced) in the picker header; disabled = locked to the
kata's mode once it has a move. */
.kimodo-cz-modeseg { margin-bottom: 8px; }
.kimodo-cz-modeseg .kimodo-cz-segbtn:disabled { opacity: .5; cursor: default; }
/* Normal-mode picker body: a prompt box + Add move button (replaces the stance grid). */
.kimodo-cz-promptbody { padding: 4px 2px 8px; }
.kimodo-cz-pprompt { width: 100%; box-sizing: border-box; min-height: 88px; resize: vertical; padding: 10px 11px; font: inherit; font-size: 13.5px; line-height: 1.4; color: #f2e6ff; background: #1c1622; border: 1px solid #4a3a60; border-radius: 8px; }
.kimodo-cz-pprompt:focus { outline: none; border-color: #a86fe0; }
/* Matches the timeline "+ Add a move" button: purple dashed outline. */
.kimodo-cz-paddmove { display: block; width: 100%; margin-top: 10px; padding: 11px; font-size: 13px; font-weight: 600; cursor: pointer; color: #e6c7ff !important; background: #3a2a55 !important; border: 1px dashed #a86fe0 !important; border-radius: 9px; box-shadow: none !important; }
.kimodo-cz-paddmove:hover:not(:disabled) { background: #46356a !important; }
.kimodo-cz-paddmove:disabled { opacity: .5; cursor: default; }
/* Prompt move: text-glyph thumb on the compact timeline card; thumbless duration region
on the edit card (no start/end stances). */
.kimodo-cz-tlsthumb.cz-prompt { display: flex; align-items: center; justify-content: center; font-size: 18px; color: #c79bff; background: #2a2140; }
.kimodo-cz-emregion.cz-promptregion { display: flex; align-items: center; justify-content: center; }
/* Saved-kata cards: a name/count header + a horizontal filmstrip of move poses. */
.kimodo-cz-katalist { display: flex; flex-direction: column; gap: 10px; }
.kimodo-cz-katacard { position: relative; padding: 9px; border-radius: 10px; background: #241a30; border: 1px solid #4a3a60; cursor: pointer; transition: border-color .12s, background .12s; }
.kimodo-cz-katacard:hover { border-color: #a86fe0; background: #2c2140; }
.kimodo-cz-katahead { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; padding-right: 30px; }
.kimodo-cz-kataname { flex: 1; font-size: 12.5px; font-weight: 600; color: #e6c7ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* circled trash delete in the card's top-right (clearer than a bare ✕) */
.kimodo-cz-katadel { position: absolute; top: 6px; right: 7px; background: none !important; border: 0 !important; box-shadow: none !important; color: #8a6a9a !important; font-size: 13px; line-height: 1; cursor: pointer; padding: 3px; z-index: 2; }
.kimodo-cz-katadel:hover { color: #e88 !important; }
/* Community: Fork button (top-right) + attribution / fork-lineage lines under the name. */
.kimodo-cz-katafork { position: absolute; top: 6px; right: 7px; z-index: 2; padding: 2px 7px; font-size: 10px; font-weight: 700; cursor: pointer; border-radius: 6px; background: #2c2140 !important; color: #cbb6ff !important; border: 1px solid #6a4a9a !important; box-shadow: none !important; }
.kimodo-cz-katafork:hover { background: #3a2a55 !important; color: #e6c7ff !important; }
.kimodo-cz-katameta { margin: -3px 0 7px; display: flex; flex-direction: column; gap: 2px; }
.kimodo-cz-kataby { font-size: 10px; color: #b39ddb; }
.kimodo-cz-katafrom { font-size: 9.5px; color: #8a7aa8; }
.kimodo-cz-vname-by { font-size: 11px; color: #b39ddb; opacity: .9; white-space: nowrap; margin-left: 6px; }
/* "+ Create a kata" card at the top of the list */
.kimodo-cz-createcard { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; border-radius: 10px; cursor: pointer; font-size: 13px; font-weight: 600; color: #9fe6c0 !important; background: #1f3326 !important; border: 1px dashed #3a6 !important; box-shadow: none !important; }
.kimodo-cz-createcard:hover { background: #25402f !important; }
/* "+ Add a stance" button + the stance-picker overlay */
.kimodo-cz-addbtn { display: block; width: 100%; padding: 11px; margin-bottom: 10px; border-radius: 9px; cursor: pointer; font-size: 13px; font-weight: 600; color: #e6c7ff !important; background: #3a2a55 !important; border: 1px dashed #a86fe0 !important; box-shadow: none !important; }
/* At the END of the timeline it needs breathing room from the last move card. */
#kimodo-cz-addstance { margin-top: 16px; }
.kimodo-cz-addbtn:hover { background: #46356a !important; }
@keyframes cz-slidedown { from { opacity: 0; transform: translateY(-16px); } to { opacity: 1; transform: none; } }
.kimodo-cz-addbtn.cz-slidedown { animation: cz-slidedown .26s ease both; }
/* switch a view in place (no horizontal slide) — used for view↔edit of the same kata */
.kimodo-cz-views.cz-noslide .kimodo-cz-view { transition: none !important; }
/* Stance picker — slides in from the right over the drawer. */
.kimodo-cz-picker { position: absolute; inset: 0; z-index: 5; background: rgba(20,16,28,0.99); padding: 0; overflow-y: auto; transform: translateX(100%); transition: transform .24s ease; touch-action: pan-x pan-y; }
.kimodo-cz-picker.open { transform: translateX(0); }
/* Sticky header (title/close + hint + filter) stays put while the grid scrolls. */
.kimodo-cz-pickhead { position: sticky; top: 0; z-index: 6; background: rgba(20,16,28,0.99); padding: 14px 12px 8px; border-bottom: 1px solid #2a2233; }
.kimodo-cz-pfilter { width: 100%; box-sizing: border-box; margin-bottom: 0; padding: 8px 10px; font-size: 13px; color: #f2e6ff; background: #1c1622; border: 1px solid #4a3a60; border-radius: 8px; }
.kimodo-cz-pfilter:focus { outline: none; border-color: #a86fe0; }
.kimodo-cz-picker .kimodo-cz-palette { display: flex; flex-wrap: wrap; gap: 8px; overflow: visible; border-bottom: 0; padding: 10px 12px 16px; }
.kimodo-cz-picker .kimodo-cz-pcard { width: calc(50% - 4px); }
.kimodo-cz-picker .kimodo-cz-padd { display: none; }
/* Two-tap first-move picker: the hint sits in the sticky header; a green START marker
appears on the first-tapped card while we wait for the END tap. */
.kimodo-cz-pickhint { font-size: 11.5px; color: #f0f1f4; text-align: center; padding: 8px 8px; border-radius: 8px; background: #2c2c33; border: 1px solid #4a4a52; font-weight: 500; line-height: 1.35; margin-bottom: 8px; }
.kimodo-cz-pickhint:empty { display: none; }
.kimodo-cz-pickhint .s { color: #6fd0a0; font-weight: 700; }
.kimodo-cz-pickhint .e { color: #e8907f; font-weight: 700; }
.kimodo-cz-pcard.cz-startpick { border-color: #5fb98c !important; box-shadow: 0 0 0 1px #5fb98c; }
.kimodo-cz-picktag { position: absolute; top: 4px; right: 5px; z-index: 3; font-size: 7.5px; font-weight: 700;
letter-spacing: 1px; color: #6fd0a0; background: rgba(20,16,28,.82); border: 1px solid #3a5a44; border-radius: 3px; padding: 0 4px; }
/* Tentative selection (tapped to preview; tap again to confirm) = thick colored outline
over the WHOLE card + a big ✓ overlay, so it reads as a confirm action. Green = START,
red = END. */
.kimodo-cz-pcard.cz-tentative.tent-start { border-color: #5fb98c !important; box-shadow: 0 0 0 3px #5fb98c; }
.kimodo-cz-pcard.cz-tentative.tent-end { border-color: #e8736a !important; box-shadow: 0 0 0 3px #e8736a; }
.kimodo-cz-pcheck { position: absolute; inset: 0; z-index: 4; display: flex; align-items: center; justify-content: center;
font-size: 46px; font-weight: 900; border-radius: 9px; pointer-events: none; }
.cz-tentative.tent-start .kimodo-cz-pcheck { color: #8ff0c0; background: rgba(95,185,140,0.22); }
.cz-tentative.tent-end .kimodo-cz-pcheck { color: #ffa295; background: rgba(232,115,106,0.22); }
.kimodo-cz-film { display: flex; gap: 4px; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; }
.kimodo-cz-frame { flex: 0 0 auto; width: 46px; height: 56px; border-radius: 5px; background: #15101d; display: flex; align-items: center; justify-content: center; overflow: hidden; color: #5a4a76; font-size: 15px; }
.kimodo-cz-frame svg { width: 100%; height: 100%; display: block; }
.kimodo-cz-frame.start { box-shadow: inset 0 -2px 0 #5fb98c; }
.kimodo-cz-frame.end { box-shadow: inset 0 -2px 0 #d9776a; }
.kimodo-cz-kataact { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; }
.kimodo-cz-katatag { font-size: 9.5px; color: #9a8ab0; }
.kimodo-cz-katatag.playonly { color: #b9905a; }
.kimodo-cz-kataedit { font-size: 12px; padding: 7px 13px; border-radius: 8px; cursor: pointer; background: #3a2a55 !important; color: #e6c7ff !important; border: 1px solid #6a4a9a !important; box-shadow: none !important; }
.kimodo-cz-kataedit:hover { background: #46356a !important; }
/* Sliding view stack (library ↔ create ↔ player). */
#kimodo-compose-drawer { display: flex; flex-direction: column; overflow: hidden; }
.kimodo-cz-views { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.kimodo-cz-view { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; transform: translateX(0); transition: transform .26s ease; }
.kimodo-cz-view.cz-active { transform: translateX(0); }
.kimodo-cz-view.cz-left { transform: translateX(-100%); }
.kimodo-cz-view.cz-right { transform: translateX(100%); }
/* Selected-kata name overlay — slides into the upper-left of the 3D view. */
.kimodo-cz-vname { position: fixed; top: 14px; left: 250px; z-index: 60; display: none; align-items: center; gap: 8px; max-width: min(60vw, 420px);
padding: 7px 12px; border-radius: 10px; background: rgba(28,22,40,0.86); border: 1px solid #4a3a60; backdrop-filter: blur(6px);
color: #f2e6ff; font-size: 14px; font-weight: 600; pointer-events: auto; opacity: 0; transform: translateX(-12px); transition: opacity .24s ease, transform .24s ease; }
.kimodo-cz-vname.show { display: flex; opacity: 1; transform: translateX(0); }
.kimodo-cz-vname-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kimodo-cz-vname-edit { background: none !important; border: 0 !important; box-shadow: none !important; color: #c79bff !important; font-size: 13px; cursor: pointer; padding: 2px 4px; flex: 0 0 auto; }
.kimodo-cz-vname-edit:hover { color: #e6c7ff !important; }
.kimodo-cz-vname input { font: inherit; font-size: 14px; background: #1c1622; color: #f2e6ff; border: 1px solid #6a4a9a; border-radius: 6px; padding: 3px 7px; width: 200px; max-width: 50vw; }
.kimodo-cz-libempty { font-size: 11.5px; color: #8a7aa0; text-align: center; padding: 20px 8px; line-height: 1.5; }
/* Kata PLAYER: a scrubbable vertical pose timeline. The pose cards sit in a column;
a shaded fill sweeps top→bottom as it plays; a handle on a side rail scrubs it. */
.kimodo-cz-phead { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.kimodo-cz-pback { background: none !important; border: 0 !important; box-shadow: none !important; color: #b8a8cc !important; font-size: 26px; line-height: 1; cursor: pointer; padding: 0 6px 0 2px; flex: 0 0 auto; }
.kimodo-cz-pback:hover { color: #e6c7ff !important; }
.kimodo-cz-ptitle { flex: 1; font-size: 12.5px; font-weight: 600; color: #e6c7ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* bigger, rounded play/pause */
.kimodo-cz-pplay { background: #2d4a35 !important; color: #9fe6c0 !important; border: 1px solid #3a6 !important; box-shadow: none !important; border-radius: 50%; width: 42px; height: 42px; font-size: 16px; line-height: 1; padding: 0; cursor: pointer; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; }
.kimodo-cz-pplay:hover { background: #34563f !important; }
/* Center + lock-on-character button (left of play/pause). */
.kimodo-cz-plock { background: #242a31 !important; color: #b8c4d0 !important; border: 1px solid #3a414b !important; box-shadow: none !important; border-radius: 50%; width: 42px; height: 42px; font-size: 19px; line-height: 1; padding: 0; cursor: pointer; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; }
.kimodo-cz-plock:hover { background: #2d3540 !important; }
.kimodo-cz-plock.on { background: #2d4a35 !important; color: #9fe6c0 !important; border-color: #3a6 !important; }
/* read-only transition prompt shown between the player's pose cards */
.kimodo-cz-ptrans { font-size: 10.5px; line-height: 1.35; color: #c7b8dd; background: #1c1622; border: 1px solid #3a2a55; border-radius: 6px; padding: 5px 7px; margin: 0 2px; }
.kimodo-cz-ptrans .a { color: #8a6ab0; margin-right: 4px; }
.kimodo-cz-ptrans.collapsed { cursor: pointer; }
.kimodo-cz-ptrans.collapsed:hover { border-color: #a86fe0; color: #e6d8ff; }
/* per-move editable control block */
.kimodo-cz-mblock { display: flex; flex-direction: column; gap: 6px; margin: 2px 2px 0; padding: 7px; border-radius: 8px; background: #1a1422; border: 1px solid #3a2a55; cursor: pointer; }
.kimodo-cz-mblock.cz-selected { border-color: #c79bff; box-shadow: 0 0 0 1px #c79bff; background: #211830; }
.kimodo-cz-placerow { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 4px 2px 0; }
.kimodo-cz-placerow .l { font-size: 10px; color: #9a8ab0; text-transform: uppercase; letter-spacing: .04em; }
.kimodo-cz-placerow .v { font-size: 11px; color: #d9c8ef; font-variant-numeric: tabular-nums; }
.kimodo-cz-mblock.cz-selected .kimodo-cz-placerow .v { color: #e6c7ff; }
.kimodo-cz-mblock .kimodo-cz-tprompt { margin: 0; }
/* GENERATE button — below move/turn; hidden until the move needs (re)generation, then slides in */
.kimodo-cz-genbtn { overflow: hidden; max-height: 0; opacity: 0; padding: 0 12px; border: 1px solid #4a90d9 !important; border-radius: 8px; background: #24405e !important; color: #aad4ff !important; box-shadow: none !important; font-size: 11px; font-weight: 700; letter-spacing: .02em; cursor: pointer; transition: max-height .24s ease, opacity .2s ease, padding .24s ease, margin .24s ease; }
.kimodo-cz-genbtn.show { max-height: 44px; opacity: 1; padding: 9px 12px; margin-top: 2px; }
.kimodo-cz-genbtn:hover { background: #2d4f72 !important; }
.kimodo-cz-genbtn:disabled { cursor: default; opacity: .85; }
.kimodo-cz-genbtn.loading { background: #243a2c !important; color: #8fcaa6 !important; display: flex; align-items: center; justify-content: center; gap: 8px; }
/* Refresh / regenerate = blue (vs green Generate). */
.kimodo-cz-genbtn.refresh { border-color: #4a90d9 !important; background: #24405e !important; color: #aad4ff !important; }
.kimodo-cz-genbtn.refresh:hover { background: #2d4f72 !important; }
.kimodo-cz-spin { width: 13px; height: 13px; border: 2px solid rgba(159,230,192,0.35); border-top-color: #9fe6c0; border-radius: 50%; display: inline-block; animation: kimodo-cz-spin .8s linear infinite; }
@keyframes kimodo-cz-spin { to { transform: rotate(360deg); } }
/* ungenerated ("being added") card + controls — gray until generated */
.kimodo-cz-card.cz-pending { background: #232026 !important; border-color: #4a474f !important; }
.kimodo-cz-mblock.cz-pending { background: #201e24; border-color: #44414a; }
.kimodo-cz-card, .kimodo-cz-mblock { transition: background-color .5s ease, border-color .5s ease; }
/* on generation: animate gray → the normal purple */
@keyframes kimodo-cz-fromgray-card { from { background: #232026; border-color: #4a474f; } to { background: #241a30; border-color: #4a3a60; } }
@keyframes kimodo-cz-fromgray-blk { from { background: #201e24; border-color: #44414a; } to { background: #1a1422; border-color: #3a2a55; } }
.kimodo-cz-card.cz-justgen { animation: kimodo-cz-fromgray-card .55s ease; }
.kimodo-cz-mblock.cz-justgen { animation: kimodo-cz-fromgray-blk .55s ease; }
/* delete: shrink the card (+ its move block) away */
.kimodo-cz-pcardrow.cz-removing, .kimodo-cz-mblock.cz-removing { overflow: hidden; opacity: 0; transform: scale(.85); margin: 0 !important; padding: 0 !important; transition: height .24s ease, opacity .24s ease, transform .24s ease, margin .24s ease, padding .24s ease; }
/* juicy "added to the kata" pop on a card */
@keyframes kimodo-cz-pop { 0% { transform: scale(.3) translateY(-18px); opacity: 0; } 55% { transform: scale(1.12) translateY(0); opacity: 1; } 72% { transform: scale(.94); } 86% { transform: scale(1.04); } 100% { transform: scale(1); } }
.kimodo-cz-card.cz-pop { animation: kimodo-cz-pop .5s cubic-bezier(.34,1.56,.64,1) both; }
/* track (cards) + a side rail with the draggable handle */
.kimodo-cz-pbody { position: relative; display: block; }
.kimodo-cz-ptrack { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0; }
/* the progress fill overlays the whole card column, height = played fraction */
.kimodo-cz-pfill { position: absolute; left: 0; right: 0; top: 0; bottom: auto; height: 0; background: linear-gradient(180deg, rgba(168,111,224,0.20), rgba(168,111,224,0.10)); border-bottom: 2px solid #a86fe0; border-radius: 8px 8px 0 0; pointer-events: none; z-index: 2; transition: height .05s linear; }
.kimodo-cz-pcardrow { position: relative; z-index: 1; }
.kimodo-cz-pcardrow.cur .kimodo-cz-card { border-color: #c79bff; box-shadow: 0 0 0 1px #c79bff; }
/* Sequence cards adapt to the (thin/resizable) drawer instead of a fixed 150px that
overflows a narrow sidebar. The palette keeps its fixed-width horizontal-scroll cards. */
.kimodo-cz-pcardrow .kimodo-cz-card { width: 100%; max-width: 190px; margin: 0 auto; }
/* Sequence thumbnail scales in BOTH dimensions (square) instead of a fixed 120px tall,
so on a thin sidebar the card stays proportioned, not a tall sliver. */
.kimodo-cz-pcardrow .kimodo-cz-thumb { height: auto; aspect-ratio: 1 / 1; }
/* Collapsed "Refine prompt" — keeps the prompt box out of the way on a thin sidebar;
the default prompt is used unless the user opens this to edit it. */
.kimodo-cz-refine { margin: 0; }
.kimodo-cz-refine > summary { list-style: none; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 5px; font-size: 11px; color: #b79be0; padding: 3px 2px; }
.kimodo-cz-refine > summary::-webkit-details-marker { display: none; }
.kimodo-cz-refine > summary::before { content: '✎'; font-size: 11px; opacity: .9; }
.kimodo-cz-refine > summary::after { content: '▸'; margin-left: auto; font-size: 9px; opacity: .6; }
.kimodo-cz-refine[open] > summary { color: #d9c8ef; margin-bottom: 5px; }
.kimodo-cz-refine[open] > summary::after { content: '▾'; }
.kimodo-cz-refine > summary:hover { color: #d9c8ef; }
/* side rail + handle (the scrubber the user drags up/down) */
.kimodo-cz-ptrack { padding-right: 30px; } /* gutter for the absolute rail on the right */
.kimodo-cz-prail { position: absolute; right: 0; top: 0; width: 26px; border-radius: 13px; background: #1c1622; border: 1px solid #3a2a55; cursor: grab; touch-action: none; z-index: 3; transition: top .35s ease, height .35s ease; }
.kimodo-cz-prail:active { cursor: grabbing; }
.kimodo-cz-prailfill { position: absolute; left: 0; right: 0; top: 0; bottom: auto; height: 0; background: rgba(168,111,224,0.25); border-radius: 13px 13px 0 0; pointer-events: none; }
.kimodo-cz-phandle { position: absolute; left: 50%; top: 0; width: 30px; height: 30px; margin-left: -15px; margin-top: -15px; border-radius: 50%; background: #a86fe0; border: 2px solid #2c2140; box-shadow: 0 2px 8px rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; pointer-events: none; }
.kimodo-cz-ptime { display: flex; justify-content: space-between; font-size: 10px; color: #9a8ab0; margin-top: 8px; }
/* Anchored top bar in the kata player: back/play + the instruction stay put while the
timeline scrolls underneath. */
.kimodo-cz-ptop { position: sticky; top: 0; z-index: 6; background: rgba(20,20,24,0.98); padding-bottom: 6px; margin: 0 -2px 4px; padding-left: 2px; padding-right: 2px; }
.kimodo-cz-ptop .kimodo-cz-phead { margin-bottom: 6px; }
/* ---- Timeline player: move cards stacked continuously, each card's HEIGHT = its
duration (drag the bottom grip to change). START at top, time flows downward. ---- */
.kimodo-cz-tlstart { position: relative; display: flex; align-items: center; gap: 8px; padding: 2px 8px 10px 24px; }
.kimodo-cz-tlstartlab { font-size: 10px; font-weight: 700; letter-spacing: .4px; color: #6fd0a0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kimodo-cz-tlsthumb { position: relative; flex: 0 0 auto; width: 34px; height: 34px; border-radius: 6px; background: #15101d; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; }
.kimodo-cz-tlsthumb svg { width: 100%; height: 100%; }
.kimodo-cz-tlsthumb .kimodo-cz-thumbnum { top: 1px; left: 1px; font-size: 8px; padding: 0 2px; }
.kimodo-cz-tlnode { position: absolute; width: 12px; height: 12px; border-radius: 50%; background: #15101d; border: 2px solid #a86fe0; z-index: 4; }
.kimodo-cz-tlstart .kimodo-cz-tlnode.start { left: 7px; top: 4px; border-color: #5fb98c; }
.kimodo-cz-tlcard { position: relative; height: 100%; box-sizing: border-box; display: flex; align-items: center; gap: 8px;
padding: 6px 8px 6px 24px; border-radius: 9px; background: #241a30; border: 1px solid #4a3a60; cursor: pointer; overflow: hidden;
transition: background-color .4s ease, border-color .4s ease; }
.kimodo-cz-tlcard::before { content: ''; position: absolute; left: 12px; top: 0; bottom: 0; width: 2px; background: #3a2a55; } /* spine through the card */
.kimodo-cz-tlcard .kimodo-cz-tlnode.endn { left: 7px; bottom: 5px; border-color: #c79bff; }
.kimodo-cz-tlcard.end { border-color: #d9776a; }
.kimodo-cz-tlcard.end .kimodo-cz-tlnode.endn { border-color: #d9776a; }
.kimodo-cz-tlcard.gen { background: #1d1730; border-color: #4a3a6a; } /* saved move = darker purple */
.kimodo-cz-tlcard.cz-pending { background: #232026; border-color: #4a474f; }
.kimodo-cz-tlcard.cz-active { border-color: #7dd3fc; box-shadow: 0 0 0 1px #7dd3fc; }
.kimodo-cz-tlcard.sel { box-shadow: 0 0 0 2px #7dd3fc; }
.kimodo-cz-tlcard.cz-justgen { animation: kimodo-cz-fromgray-card .55s ease; }
.kimodo-cz-pcardrow.cz-pop .kimodo-cz-tlcard { animation: kimodo-cz-pop .5s cubic-bezier(.34,1.56,.64,1) both; }
.kimodo-cz-tlmeta { flex: 1; min-width: 0; }
.kimodo-cz-tlname { font-size: 10.5px; color: #d9c8ef; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kimodo-cz-tldur { font-size: 15px; font-weight: 700; color: #ffd9b0; font-variant-numeric: tabular-nums; }
.kimodo-cz-tlcard.gen .kimodo-cz-tldur { color: #c0a8e8; }
.kimodo-cz-tlcheck { position: absolute; top: 5px; right: 8px; font-size: 12px; color: #c79bff; }
.kimodo-cz-tlx { position: absolute; top: 3px; right: 5px; }
.kimodo-cz-tlgrip { position: absolute; left: 0; right: 0; bottom: 0; height: 15px; display: flex; align-items: center; justify-content: center; cursor: ns-resize; touch-action: none; z-index: 5; }
.kimodo-cz-tlgrip span { width: 28px; height: 3px; border-radius: 2px; background: #6a5a86; transition: background .15s ease; }
.kimodo-cz-tlgrip:hover span, .kimodo-cz-tlcard.cz-active .kimodo-cz-tlgrip span { background: #7dd3fc; }
/* active-move controls below the timeline */
.kimodo-cz-controls { margin-top: 12px; padding-top: 10px; border-top: 1px solid #2a2233; }
.kimodo-cz-controlslab { font-size: 11px; font-weight: 600; color: #c9b6ea; margin-bottom: 6px; }
/* ---- Active-move editor: START stance pinned top, END stance pinned bottom (drag it
to set the length = the gap between them), GENERATE right under the end, then the
collapsed refine prompt + the move/rotation numbers. ---- */
.kimodo-cz-activerow { overflow: visible; }
.kimodo-cz-editcard { position: relative; border: 1px solid #7dd3fc; border-radius: 10px; background: #241a30; padding: 8px; box-shadow: 0 0 0 1px rgba(125,211,252,0.35); }
/* A move edited after it was generated → dirty (amber) so it's clearly "needs Refresh". */
.kimodo-cz-editcard.cz-dirty { border-color: #5a5a64; background: #26262c; box-shadow: 0 0 0 1px rgba(90,90,100,0.35); } /* unsaved edit = gray (like a new move) */
/* Not generated yet → gray. Once generated it pulses gray→purple; selecting a generated
move to edit pulses green→purple. */
.kimodo-cz-editcard.cz-new { border-color: #5a5a64; background: #26262c; box-shadow: 0 0 0 1px rgba(90,90,100,0.35); }
.kimodo-cz-editcard.cz-genpulse { animation: kimodo-cz-gray2purple .6s ease both; }
.kimodo-cz-editcard.cz-selpulse { animation: kimodo-cz-green2purple .6s ease both; }
@keyframes kimodo-cz-gray2purple {
0% { background: #26262c; border-color: #5a5a64; box-shadow: 0 0 0 0 rgba(125,211,252,0); }
45% { box-shadow: 0 0 0 5px rgba(125,211,252,0.5); transform: scale(1.02); }
100% { background: #241a30; border-color: #7dd3fc; box-shadow: 0 0 0 1px rgba(125,211,252,0.35); transform: scale(1); }
}
@keyframes kimodo-cz-green2purple {
0% { background: #1d1730; border-color: #4a3a6a; box-shadow: 0 0 0 0 rgba(125,211,252,0); } /* from the darker-purple saved card */
45% { box-shadow: 0 0 0 5px rgba(125,211,252,0.5); transform: scale(1.02); }
100% { background: #241a30; border-color: #7dd3fc; box-shadow: 0 0 0 1px rgba(125,211,252,0.35); transform: scale(1); }
}
.kimodo-cz-emmovelab { font-size: 10px; font-weight: 700; letter-spacing: .04em; color: #c9b6ea; margin: 0 0 5px 2px; }
.kimodo-cz-emregion { position: relative; margin: 2px 2px 8px; }
.kimodo-cz-emregion::before { content: ''; position: absolute; left: 18px; top: 18px; bottom: 18px; width: 2px; background: #6a4a9a; } /* spine: start → end */
/* The two pose thumbs are TOGGLES for the viewer skeletons (green start / red end); dim
when the skeleton is hidden, full + ringed when shown. */
.kimodo-cz-emthumb { position: relative; flex: 0 0 auto; width: 36px; height: 36px; border-radius: 7px; background: #15101d; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; border: 2px solid #5fb98c; box-sizing: border-box; cursor: pointer; opacity: 0.45; transition: opacity .15s ease, box-shadow .15s ease; }
.kimodo-cz-emthumb svg { width: 100%; height: 100%; }
.kimodo-cz-emthumb.start { border-color: #5fb98c; }
.kimodo-cz-emthumb.end { border-color: #e8736a; }
.kimodo-cz-emthumb.shown { opacity: 1; box-shadow: 0 0 0 2px rgba(255,255,255,0.16); }
.kimodo-cz-emthumb .kimodo-cz-thumbnum { top: 0; left: 0; font-size: 8px; padding: 0 2px; }
/* New move (2nd onward) slides in from the top so it's obvious it's the new one. */
.kimodo-cz-pcardrow.cz-slidein { animation: kimodo-cz-slidein .34s cubic-bezier(.22,1,.36,1) both; }
@keyframes kimodo-cz-slidein { from { transform: translateY(-26px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.kimodo-cz-emfrom { position: absolute; top: 0; left: 0; display: flex; align-items: center; gap: 8px; }
.kimodo-cz-emend { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; gap: 8px; } /* end stance at the bottom of the region */
.kimodo-cz-emlab { font-size: 10px; color: #9a8ab0; }
.kimodo-cz-emlab.end { font-size: 11px; color: #e8b89f; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kimodo-cz-emdur { position: absolute; left: 50px; top: 50%; transform: translateY(-50%); font-size: 20px; font-weight: 800; color: #ffd9b0; font-variant-numeric: tabular-nums; pointer-events: none; }
.kimodo-cz-emx { position: absolute; top: 5px; right: 6px; z-index: 5; flex: 0 0 auto; } /* delete at the move's top-right */
/* Drag handle (dots) — now spans the BOTTOM of the WHOLE card (resizes the move). */
.kimodo-cz-emhandle { position: relative; height: 20px; display: flex; align-items: center; justify-content: center;
cursor: ns-resize; touch-action: none; color: #b79be0; }
.kimodo-cz-emhandle:hover { color: #e6c7ff; }
.kimodo-cz-cardhandle { margin: 8px -8px -8px; border-top: 1px dashed rgba(168,111,224,0.4); border-radius: 0 0 10px 10px; }
.kimodo-cz-editcard.cz-dirty .kimodo-cz-cardhandle { border-top-color: rgba(120,120,130,0.5); }
/* Reusable "draggable" grip = exactly 3 dots (center dot + two via box-shadow).
Horizontal by default; .v for vertical handles. Colour follows the handle (currentColor). */
.kimodo-cz-dots { display: block; width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .85;
box-shadow: -7px 0 currentColor, 7px 0 currentColor; }
.kimodo-cz-dots.v { box-shadow: 0 -7px currentColor, 0 7px currentColor; }
/* --- Actions library: ported verbatim from the kata web UI (kata.html), scoped
under .kimodo-drawer and with !important on button visuals so Gradio's global
button styles can't bleed in. Same class names + structure as the reference. */
.kimodo-drawer .action-card { position: relative; background: #26262b; border: 1px solid #3a3a40; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
.kimodo-drawer .action-card .nm { font-size: 13px; font-weight: 600; color: #e3e3e8; padding-right: 18px; }
.kimodo-drawer .action-card .dur-badge { font-size: 10px; font-weight: 500; color: #9aa; background: #2f2f37; border-radius: 4px; padding: 1px 5px; margin-left: 4px; }
.kimodo-drawer .action-card .dur-tog { width: 100%; margin-top: 6px; padding: 4px; font-size: 11px; text-align: left; cursor: pointer; background: none !important; border: none !important; color: #9aa !important; box-shadow: none !important; }
.kimodo-drawer .action-card .kata-plus { margin-top: 6px; padding: 4px 8px; font-size: 11px; cursor: pointer; white-space: nowrap; border-radius: 5px; background: #2d4a35 !important; color: #9fd !important; border: 1px solid #3a6 !important; box-shadow: none !important; }
.kimodo-drawer .action-card .kata-plus:disabled { opacity: 0.4; cursor: default; }
.kimodo-drawer .action-card .dur-tog:hover { color: #cde !important; }
.kimodo-drawer .action-card .del-x { position: absolute; top: 5px; right: 7px; background: none !important; border: none !important; color: #888 !important; font-size: 13px; line-height: 1; cursor: pointer; padding: 2px; box-shadow: none !important; min-width: 0; }
.kimodo-drawer .action-card .del-x:hover:not(:disabled) { color: #e88 !important; }
.kimodo-drawer .action-card .play-row { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
.kimodo-drawer .action-card .play-row .pp { min-width: 32px; padding: 4px 8px; }
.kimodo-drawer .action-card .frame-lbl { font-size: 11px; color: #bbb; min-width: 44px; text-align: right; }
.kimodo-drawer .play-scrub { -webkit-appearance: none; appearance: none; flex: 1; height: 6px; border-radius: 3px; background: #34343c; outline: none; cursor: pointer; }
.kimodo-drawer .play-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 2px solid #5ad1ff; box-shadow: 0 0 4px #0009; cursor: pointer; }
.kimodo-drawer .play-scrub::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #fff; border: 2px solid #5ad1ff; cursor: pointer; }
.kimodo-drawer .action-card .prev-btn { width: 100%; padding: 6px; margin-bottom: 6px; font-size: 12px; cursor: pointer; border-radius: 6px; background: #34343a !important; color: #ddd !important; border: 1px solid #4a4a52 !important; box-shadow: none !important; }
.kimodo-drawer .action-card .prev-btn:hover:not(:disabled) { background: #3f3f48 !important; }
.kimodo-drawer .action-card .prev-btn:disabled { opacity: .5; cursor: default; }
.kimodo-drawer .action-card .regen-row { display: flex; gap: 6px; }
.kimodo-drawer .action-card .rg { flex: 1; padding: 6px; font-size: 11.5px; cursor: pointer; border-radius: 5px; background: #34343a !important; color: #ddd !important; border: 1px solid #444 !important; box-shadow: none !important; }
.kimodo-drawer .action-card .rg:hover:not(:disabled) { background: #3f3f48 !important; }
.kimodo-drawer .action-card .rg:disabled { opacity: .45; cursor: default; }
.kimodo-drawer .action-card .pr { margin: 2px 0 7px; font-size: 11px; line-height: 1.4; }
.kimodo-drawer .action-card .pr-txt { color: #888; }
.kimodo-drawer .action-card .pr-edit-btn { background: none !important; border: none !important; color: #8ab !important; cursor: pointer; font-size: 12px; padding: 0 0 0 5px; vertical-align: baseline; box-shadow: none !important; min-width: 0; }
.kimodo-drawer .action-card .pr-edit-btn:hover:not(:disabled) { color: #cde !important; }
.kimodo-drawer .action-card textarea.pr-edit { width: 100%; box-sizing: border-box; margin: 2px 0 6px; padding: 5px 6px; font: inherit; font-size: 12px; resize: vertical; background: #1c1c22; color: #e3e3e8; border: 1px solid #4a6; border-radius: 5px; }
.kimodo-drawer .action-card .edit-bar { display: flex; gap: 6px; }
.kimodo-drawer .dur { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; }
.kimodo-drawer .dur input[type=range] { flex: 1; cursor: pointer; }
.kimodo-drawer .dur-tag { font-size: 11px; color: #888; }
.kimodo-drawer .dur-lbl { font-size: 11px; color: #bbb; min-width: 28px; text-align: right; }
.kimodo-drawer .action-card .rot-row { display: flex; align-items: center; gap: 6px; }
.kimodo-drawer .action-card .rot-row input[type=range] { flex: 1; cursor: pointer; }
.kimodo-drawer .action-card .rot-save { padding: 3px 9px; font-size: 11px; border-radius: 5px; cursor: pointer; background: #2d4a35 !important; color: #9fd !important; border: 1px solid #4a6 !important; box-shadow: none !important; }
.kimodo-drawer .action-card .rot-save:hover:not(:disabled) { background: #34563f !important; }
.kimodo-drawer .action-card .rot-save:disabled { opacity: .4; cursor: default; }
.kimodo-drawer .action-card .row { display: flex; gap: 6px; }
.kimodo-drawer .action-card button { padding: 5px 8px; font-size: 12px; border-radius: 5px; cursor: pointer; background: #34343a !important; color: #ddd !important; border: 1px solid #444 !important; box-shadow: none !important; }
.kimodo-drawer .action-card button.prev { flex: 1; }
.kimodo-drawer .action-card button:hover:not(:disabled) { background: #3f3f48 !important; }
.kimodo-drawer .action-card button:disabled { opacity: .5; cursor: default; }
.kimodo-drawer .action-card.playing { border-color: #5fb98c; background: #21302a; }
.kimodo-drawer .action-card .badge { font-size: 10px; font-weight: 500; margin-left: 5px; color: #7ec77e; }
.kimodo-drawer .action-card .badge.gen { color: #e0b060; }
.kimodo-drawer .action-card.playing button.prev { background: #2d4a35 !important; border-color: #3a6 !important; color: #7ec77e !important; }
@keyframes kimodo-spin { to { transform: rotate(360deg); } }
.kimodo-drawer .spin { display: inline-block; animation: kimodo-spin .9s linear infinite; }
.kimodo-drawer .add-form { background: #222228; border: 1px solid #3a3a40; border-radius: 8px; padding: 9px; margin-bottom: 10px; }
.kimodo-drawer .add-input { width: 100%; box-sizing: border-box; margin-bottom: 6px; padding: 5px 6px; font: inherit; font-size: 12px; resize: vertical; background: #1c1c22; color: #e3e3e8; border: 1px solid #3a3a40; border-radius: 5px; }
.kimodo-drawer .add-row { display: flex; gap: 6px; margin-bottom: 6px; }
.kimodo-drawer .add-action { flex: 1; padding: 7px; font-size: 11.5px; cursor: pointer; border-radius: 6px; background: #2d4a35 !important; color: #9fd !important; border: 1px solid #4a6 !important; box-shadow: none !important; }
.kimodo-drawer .add-action:hover:not(:disabled) { background: #34563f !important; }
.kimodo-drawer .add-action:disabled { opacity: .45; cursor: default; }
.kimodo-drawer .actions-empty { font-size: 12px; color: #8c96a3; font-style: italic; padding: 8px 2px; }
/* --- Kata move tree (d3 SVG), ported from kata.html. --- */
.kimodo-drawer .kimodo-kata-bar { display: flex; gap: 6px; margin-bottom: 6px; }
.kimodo-drawer #kimodo-kata-select { width: 100%; background: #26262b; color: #e3e3e8; border: 1px solid #4a4a52; border-radius: 5px; padding: 6px 8px; font-size: 13px; }
.kimodo-drawer #kimodo-tree-svg { width: 100%; height: 58vh; background: #1c1c1f; border: 1px solid #333; border-radius: 6px; cursor: grab; display: block; }
.kimodo-drawer #kimodo-tree-svg:active { cursor: grabbing; }
.kimodo-drawer #kimodo-tree-svg text { user-select: none; }
.kimodo-drawer .kata-empty { font-size: 12px; color: #8c96a3; font-style: italic; padding: 10px 2px; }
@media (max-width: 768px) {
.kimodo-drawer {
width: min(330px, 86vw);
}
.kimodo-drawer--wide {
width: min(460px, 94vw);
}
.kimodo-left-tabs.drawer-open {
left: min(330px, 86vw);
}
}
"""
# Keep the 8B text encoder on CPU. Kimodo's README calls this out as the small-VRAM path,
# and it also fits ZeroGPU's GPU-per-function-call model better.
os.environ.setdefault("TEXT_ENCODER_DEVICE", "cpu")
os.environ.setdefault("TEXT_ENCODER_MODE", "local")
_models: dict[str, object] = {}
_model_lock = threading.Lock()
def _duration(_prompt: str, seconds: float, steps: int, _seed: int, *_args, **_kwargs) -> int:
# **_kwargs so any optional kwarg on a @GPU(duration=_duration) function (e.g.
# _generate_between_uncached's root_extra) is tolerated — ZeroGPU forwards them here.
# First call includes checkpoint/text-encoder lazy load, so leave headroom.
seconds = max(0.5, min(10.0, float(seconds or 4.0)))
steps = max(2, min(100, int(steps or 20)))
return int(min(240, max(90, 70 + seconds * steps * 0.35)))
def _passthrough(iterable, *args, **kwargs):
return iterable
def _ensure_model_cpu(model_name: str = DEFAULT_GENERATION_MODEL):
model_name = model_name or DEFAULT_GENERATION_MODEL
if model_name not in _models:
with _model_lock:
if model_name not in _models:
t0 = time.time()
print(f"Loading {model_name} on CPU with TEXT_ENCODER_DEVICE={os.environ.get('TEXT_ENCODER_DEVICE')}")
from kimodo.model.load_model import load_model
model = load_model(model_name, device="cpu")
model.eval()
_models[model_name] = model
print(f"Loaded {model_name} in {time.time() - t0:.1f}s")
return _models[model_name]
def _write_npz(record_id: str, prompt: str, seed: int, fps: float, out: dict) -> str:
path = Path(tempfile.gettempdir()) / f"kimodo_{record_id}.npz"
np.savez_compressed(
path,
prompt=np.array(prompt),
seed=np.array(seed, dtype=np.int64),
fps=np.array(fps, dtype=np.float32),
posed_joints=out["posed_joints"][0].detach().cpu().numpy(),
global_rot_mats=out["global_rot_mats"][0].detach().cpu().numpy(),
local_rot_mats=out["local_rot_mats"][0].detach().cpu().numpy(),
root_positions=out["root_positions"][0].detach().cpu().numpy(),
foot_contacts=out.get("foot_contacts", torch.empty(0))[0].detach().cpu().numpy()
if out.get("foot_contacts") is not None
else np.empty((0,), dtype=np.float32),
)
return str(path)
def _skeleton_metadata(model) -> tuple[list[str], list[int]]:
skeleton = getattr(model, "output_skeleton", model.motion_rep.skeleton)
names_with_parents = list(skeleton.bone_order_names_with_parents)
bone_names = [name for name, _parent in names_with_parents]
name_to_idx = {name: i for i, name in enumerate(bone_names)}
parents = [name_to_idx.get(parent, -1) if parent is not None else -1 for _name, parent in names_with_parents]
return bone_names, parents
def _arrays_from_output(out: dict) -> dict:
local_rot_mats = out["local_rot_mats"][0].detach().cpu().numpy()
global_rot_mats = out["global_rot_mats"][0].detach().cpu().numpy()
local_xyzw = Rotation.from_matrix(local_rot_mats.reshape(-1, 3, 3)).as_quat().reshape(*local_rot_mats.shape[:2], 4)
global_xyzw = Rotation.from_matrix(global_rot_mats.reshape(-1, 3, 3)).as_quat().reshape(*global_rot_mats.shape[:2], 4)
return {
"local_quats_wxyz": local_xyzw[..., [3, 0, 1, 2]],
"global_quats_xyzw": global_xyzw,
"root_positions": out["root_positions"][0].detach().cpu().numpy(),
"posed_joints": out["posed_joints"][0].detach().cpu().numpy(),
}
def _normalized_generation_inputs(
prompt: str, seconds: float, steps: int, seed: int, model_name: str = DEFAULT_GENERATION_MODEL
) -> dict:
return {
"prompt": " ".join((prompt or "").strip().split()),
"seconds": round(float(seconds), 3),
"steps": int(steps),
"seed": int(seed),
"model": model_name or DEFAULT_GENERATION_MODEL,
"post_processing": False,
"schema": "kimodo-root-v1",
}
def _animation_id(prompt: str, seconds: float, steps: int, seed: int, model_name: str = DEFAULT_GENERATION_MODEL) -> str:
payload = json.dumps(
_normalized_generation_inputs(prompt, seconds, steps, seed, model_name),
sort_keys=True,
separators=(",", ":"),
)
return "kmd_" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def _label_animation(meta: dict) -> str:
# Kept short for the narrow mobile dropdown: truncated prompt + duration.
prompt = (meta.get("prompt") or meta.get("id") or "").strip()
if len(prompt) > 40:
prompt = prompt[:37] + "..."
seconds = meta.get("seconds")
return f"{prompt} ({float(seconds):.1f}s)" if seconds is not None else prompt
def _dropdown_choices(limit: int = 200) -> list[tuple[str, str]]:
return [(_label_animation(meta), meta["id"]) for meta in store.list(limit) if meta.get("id")]
def _save_record(
record_id: str,
prompt: str,
seconds: float,
fps: float,
steps: int,
seed: int,
model,
out: dict,
model_name: str = DEFAULT_GENERATION_MODEL,
extra: dict | None = None,
) -> dict:
arr = _arrays_from_output(out)
bone_names, parents = _skeleton_metadata(model)
record = {
"id": record_id,
"prompt": prompt,
"seconds": float(seconds),
"fps": float(fps),
"num_frames": int(arr["posed_joints"].shape[0]),
"model": model_name or DEFAULT_GENERATION_MODEL,
"seed": int(seed),
"denoising_steps": int(steps),
"generation_key": _normalized_generation_inputs(prompt, seconds, steps, seed, model_name),
"bone_names": bone_names,
"parents": parents,
"local_quats_wxyz": arr["local_quats_wxyz"].tolist(),
"global_quats_xyzw": arr["global_quats_xyzw"].tolist(),
"root_positions": arr["root_positions"].tolist(),
"posed_joints": arr["posed_joints"].tolist(),
}
if extra:
record.update(extra)
return record
def _b64f32(arr: np.ndarray) -> str:
return base64.b64encode(np.ascontiguousarray(arr, dtype="<f4").tobytes()).decode("ascii")
def _preview_payload(model, out: dict, fps: float, max_frames: int = 120) -> dict:
arr = _arrays_from_output(out)
posed = arr["posed_joints"].astype(np.float32) # [T, J, 3]
quats = arr["global_quats_xyzw"].astype(np.float32) # [T, J, 4] xyzw
root = arr["root_positions"].astype(np.float32) # [T, 3]
total_frames = int(posed.shape[0])
if total_frames > max_frames:
idx = np.linspace(0, total_frames - 1, max_frames).round().astype(np.int32)
posed, quats, root = posed[idx], quats[idx], root[idx]
bone_names, parents = _skeleton_metadata(model)
preview_frames = int(posed.shape[0])
return {
"fps": float(fps),
"preview_fps": float(fps) * preview_frames / max(total_frames, 1),
"num_frames": total_frames,
"preview_frames": preview_frames,
"num_joints": int(posed.shape[1]),
"bone_names": bone_names,
"parents": parents,
"joint_data_b64": _b64f32(posed),
# World-space joint rotations (xyzw) + root trajectory let the viewer
# retarget the clip onto a skinned display character, not just dots.
"quat_data_b64": _b64f32(quats),
"root_data_b64": _b64f32(root),
}
def _preview_payload_from_record(record: dict, max_frames: int = 120) -> dict:
posed = np.asarray(record["posed_joints"], dtype=np.float32)
total_frames = int(posed.shape[0])
quats = record.get("global_quats_xyzw")
quats = np.asarray(quats, dtype=np.float32) if quats is not None else None
root = record.get("root_positions")
root = np.asarray(root, dtype=np.float32) if root is not None else None
if total_frames > max_frames:
idx = np.linspace(0, total_frames - 1, max_frames).round().astype(np.int32)
posed = posed[idx]
if quats is not None:
quats = quats[idx]
if root is not None:
root = root[idx]
preview_frames = int(posed.shape[0])
payload = {
"fps": float(record.get("fps", 30.0)),
"preview_fps": float(record.get("fps", 30.0)) * preview_frames / max(total_frames, 1),
"num_frames": total_frames,
"preview_frames": preview_frames,
"num_joints": int(posed.shape[1]),
"bone_names": record.get("bone_names", []),
"parents": record.get("parents", []),
"joint_data_b64": _b64f32(posed),
}
if record.get("prompt"):
payload["prompt"] = record["prompt"]
if quats is not None:
payload["quat_data_b64"] = _b64f32(quats)
if root is not None:
payload["root_data_b64"] = _b64f32(root)
for key in ("created_by", "created_by_name", "created_by_avatar"):
if record.get(key):
payload[key] = record[key]
# Seeded clips carry the target pose they were generated to start from
# (flat [nJoints*3], re-rooted to XZ origin) -> drawn as the red reference skeleton.
if record.get("reference_pose"):
payload["reference_pose"] = record["reference_pose"]
return payload
def _empty_preview_html(message: str = "Generate a motion to preview it.") -> str:
safe = html.escape(message)
return (
"<div style=\"height:100%;min-height:240px;display:grid;place-items:center;"
"background:#151719;color:#d9dde3;border:0;"
"font:14px system-ui, sans-serif;\">"
f"{safe}</div>"
)
def _dataset_base() -> str:
"""Public dataset resolve URL base (no trailing slash), or "" for the local
store. The parent-page drawers read manifest.json + previews/ from here."""
if isinstance(store, HFDatasetAnimationStore):
return f"https://huggingface.co/datasets/{store.repo_id}/resolve/main"
return ""
def _preview_url_base() -> str | None:
"""Public dataset URL the viewer fetches other clips' previews from, or None
for the local store (where the in-viewer picker can't fetch by URL)."""
base = _dataset_base()
return f"{base}/previews" if base else None
def _hf_token() -> str | None:
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
if token:
return token.strip()
for p in (Path(__file__).parent / ".env", Path(__file__).parent.parent / ".env"):
try:
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith("HF_TOKEN="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return None
def _render_preview_html(
payload: dict,
character: str = DEFAULT_CHARACTER,
clothing: dict | None = None,
animations: list[dict] | None = None,
current_id: str | None = None,
) -> str:
payload_json = json.dumps(payload, separators=(",", ":")).replace("</", "<\\/")
# Display-character catalog (GLB + mapping) is injected per-render, never
# stored in preview records. Only offer characters whose GLB actually loaded.
catalog = []
for c in _CHARACTER_CATALOG:
if not (c.get("id") == "skeleton" or c.get("glb_b64")):
continue
c = dict(c)
# The s&box citizen GLB ships untextured (its UV atlas path doesn't resolve);
# supply the decompiled skin atlas (served from the dataset like clothing) so
# the viewer can map it onto the body. UVs are preserved through UniRig.
if c.get("id") == "citizen":
c["texture"] = f"{_VIEWER_ASSET_BASE}/textures/citizen_skin_color.png"
# Serve the mesh by URL (browser-cached across renders, like the clothing
# GLBs) instead of embedding ~1.6MB of base64 in EVERY srcdoc — the viewer
# iframe is rebuilt on each (re)render, so the inline blob was re-shipped
# and re-parsed every time. Drop the inline copy from the injected catalog.
c["glb_url"] = f"{_VIEWER_ASSET_BASE}/characters/unirig_citizen.glb"
c.pop("glb_b64", None)
catalog.append(c)
char_json = json.dumps(catalog, separators=(",", ":")).replace("</", "<\\/")
cfg = {
"clothing": CLOTHING_CATALOG,
"slots": CLOTHING_SLOTS,
# Character + worn clothing are driven from the page-level controls;
# the viewer applies them and re-applies on each (re)render.
"defaultClothing": clothing if clothing is not None else DEFAULT_CLOTHING,
"defaultCharacter": character or DEFAULT_CHARACTER,
# In-viewer animation picker: list of {id,label} + the URL base to fetch
# any clip's compact preview from, so switching clips never reloads the page.
"animations": animations or [],
# Created katas (continues_from chains) the picker can play as one stitched
# sequence: [{root,label,count,ids:[spine path]}]. Stitch happens in the
# parent drawer (which has stitchPath) on a 'play-kata' message.
"katas": _kata_list(),
"previewBase": _preview_url_base(),
"currentId": current_id or payload.get("id"),
"createdBy": payload.get("created_by"),
"createdByName": payload.get("created_by_name"),
"prompt": payload.get("prompt"),
}
cfg_json = json.dumps(cfg, separators=(",", ":")).replace("</", "<\\/")
srcdoc = f"""<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://unpkg.com" crossorigin />
<!-- NOTE: do NOT add a modulepreload link here. A modulepreload counts as a module load,
and the spec requires the importmap below to be registered BEFORE any module load. iOS
Safari enforces this strictly — a module preload above the importmap makes the bare
`import 'three'` fail ("Failed to resolve module specifier"). -->
<script>
// Watchdog: if the ESM module never boots (three.js CDN slow/blocked), tell the
// parent so it can drop the boot splash instead of trapping the user on a blank
// canvas, and surface a hint here. The module sets __viewerBooted=true once its
// core imports resolve.
window.__viewerBooted = false;
setTimeout(function () {{
if (window.__viewerBooted) return;
try {{ parent.postMessage({{ kind: 'kimodo-viewer-error' }}, '*'); }} catch (e) {{}}
var l = document.getElementById('loading');
if (l) {{ l.classList.add('on'); var b = l.querySelector('.box'); if (b) b.innerHTML = '3D viewer is taking longer than usual to load…'; }}
}}, 12000);
</script>
<style>
html, body {{ margin: 0; width: 100%; height: 100%; overflow: hidden; background: #151719; color: #e8eaed; font-family: system-ui, -apple-system, Segoe UI, sans-serif; }}
#app {{ position: fixed; inset: 0; }}
/* Minimal viewer: hide all in-viewer chrome (transport, menu, camera lock,
creator/prompt label) so the character has the whole canvas. Playback still
auto-plays and the follow-camera still tracks; these are just the visible controls. */
#hud, #menu-btn, #menu, #creator-label, #camera-lock {{ display: none !important; }}
/* Bottom transport: scrubber only (mobile-friendly). */
#hud {{ position: fixed; left: 12px; right: 12px; bottom: 12px; display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 10px; background: rgba(18, 20, 23, 0.82); border: 1px solid rgba(255,255,255,0.14); border-radius: 6px; backdrop-filter: blur(8px); }}
button {{ height: 38px; min-width: 44px; border: 1px solid #48515c; border-radius: 4px; background: #242a31; color: #f2f5f8; cursor: pointer; font-size: 16px; }}
button:hover {{ background: #2d3540; }}
input[type="range"] {{ width: 100%; }}
select {{ height: 38px; border: 1px solid #48515c; border-radius: 4px; background: #242a31; color: #f2f5f8; font-size: 14px; }}
#frame {{ font-size: 13px; color: #c3c9d1; white-space: nowrap; }}
/* Top-left collapsed options menu (Model / Speed / Clothing). */
#menu-btn {{ position: fixed; top: 12px; left: 12px; z-index: 5; width: 44px; height: 44px; font-size: 20px; line-height: 1; }}
#creator-label {{ position: fixed; top: 19px; left: 66px; z-index: 5; display: none; max-width: min(560px, calc(100vw - 128px)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 8px; border: 1px solid rgba(255,255,255,0.14); border-radius: 4px; background: rgba(18,20,23,0.78); color: #c3c9d1; font-size: 12px; backdrop-filter: blur(8px); }}
#creator-label .creator-id {{ color: #9fe6c0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; letter-spacing: 0.2px; }}
#creator-label .creator-id:hover {{ color: #c9ffe4; text-decoration: underline; }}
#creator-label .creator-id.copied {{ color: #fff; }}
#creator-label .creator-by {{ color: #8fd3ff; }}
#creator-label .creator-name {{ color: #d8f78f; font-weight: 650; }}
#creator-label .creator-prompt {{ color: #ffc36e; }}
#creator-label .creator-sep {{ color: #69727e; margin: 0 6px; }}
#menu {{ position: fixed; top: 64px; left: 12px; z-index: 5; display: none; flex-direction: column; gap: 8px; padding: 12px; min-width: 230px; max-width: 80vw; max-height: 72vh; overflow-y: auto; background: rgba(18,20,23,0.94); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; backdrop-filter: blur(8px); }}
#menu.open {{ display: flex; }}
#menu .mrow {{ display: grid; grid-template-columns: 72px 1fr; gap: 8px; align-items: center; }}
#menu .mlab {{ font-size: 12px; color: #b8bec6; }}
#menu .mhdr {{ font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: #7e8590; margin-top: 6px; }}
#menu select {{ width: 100%; }}
#menu #anim-filter {{ width: 100%; box-sizing: border-box; padding: 4px 6px; font-size: 12px; color: #e6eaef; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.18); border-radius: 4px; }}
#menu #anim-filter::placeholder {{ color: #7e8590; }}
#menu #anim-filter:focus {{ outline: none; border-color: #8fd3ff; }}
#camera-lock {{ position: fixed; top: 12px; right: 12px; z-index: 5; width: 44px; height: 44px; font-size: 19px; line-height: 1; }}
/* Centered loading indicator shown while a character + its clothing load. */
#loading {{ position: fixed; inset: 0; z-index: 4; display: none; place-items: center; pointer-events: none; }}
#loading.on {{ display: grid; }}
#loading .box {{ display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: rgba(18,20,23,0.9); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; font-size: 14px; color: #d9dde3; }}
#loading .spinner {{ width: 18px; height: 18px; border: 2px solid #3a4250; border-top-color: #7dd3fc; border-radius: 50%; animation: spin .8s linear infinite; }}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
/* Transient notice (e.g. the character couldn't finish loading — suggest a refresh). */
#viewer-toast {{ position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(8px); z-index: 7; max-width: 82vw; padding: 10px 14px; background: rgba(42,28,28,0.96); border: 1px solid #6a3636; border-radius: 8px; color: #ffd9d9; font-size: 13px; line-height: 1.35; box-shadow: 0 4px 16px rgba(0,0,0,0.5); opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease; }}
#viewer-toast.on {{ opacity: 1; transform: translateX(-50%) translateY(0); }}
</style>
<script type="importmap">
{{
"imports": {{
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
"@sparkjsdev/spark": "https://unpkg.com/@sparkjsdev/spark@2.0.0/dist/spark.module.js"
}}
}}
</script>
</head>
<body>
<div id="app"></div>
<div id="loading"><div class="box"><span class="spinner"></span></div></div>
<div id="viewer-toast" role="status" aria-live="polite"></div>
<button id="menu-btn" title="Options" aria-label="Options">&#9776;</button>
<div id="creator-label"></div>
<button id="camera-lock" title="Camera locked to character" aria-label="Camera locked to character">&#128274;</button>
<div id="menu">
<div class="mrow"><span class="mlab">Filter</span><input id="anim-filter" type="text" placeholder="type id or text…" title="Filter animations by id or text" autocomplete="off" /></div>
<div class="mrow"><span class="mlab">Animation</span><select id="animation" title="Saved animation"></select></div>
<div class="mrow"><span class="mlab">Speed</span>
<select id="speed" title="Playback speed">
<option value="0.5">0.5x</option>
<option value="1">1x</option>
<option value="1.5" selected>1.5x</option>
<option value="2">2x</option>
</select>
</div>
<div class="mrow"><span class="mlab">Skeleton</span>
<label style="font-size:12px;color:#c3c9d1;display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="skel-overlay" /> overlay bones (x-ray)
</label>
</div>
</div>
<!-- Model + clothing are chosen in the page-level controls and pushed in
via postMessage; these hidden controls reuse the in-viewer apply logic. -->
<div id="hidden-controls" style="display:none">
<select id="character"></select>
<div id="clothing-rows"></div>
</div>
<div id="hud">
<button id="play" title="Play or pause">Pause</button>
<input id="scrub" type="range" min="0" max="0" value="0" step="1" />
<span id="frame">0 / 0</span>
</div>
<script id="motion-data" type="application/json">{payload_json}</script>
<script id="char-data" type="application/json">{char_json}</script>
<script id="viewer-cfg" type="application/json">{cfg_json}</script>
<script type="module">
import * as THREE from 'three';
import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js';
import {{ GLTFLoader }} from 'three/addons/loaders/GLTFLoader.js';
// Spark (gaussian-splat renderer) is loaded lazily via ensureSpark() — only the
// dojo-scene splat path needs it, so its unpkg module stays off the boot path.
window.__viewerBooted = true; // core ESM imports resolved; the head watchdog stands down
const SMPLX_HEIGHT = {SMPLX_HEIGHT};
const data = JSON.parse(document.getElementById('motion-data').textContent);
const characters = JSON.parse(document.getElementById('char-data').textContent);
const cfg = JSON.parse(document.getElementById('viewer-cfg').textContent);
// The id of the clip currently on screen (kept current on every switch) so
// the on-screen label can show it. Hoisted here (above setCreatorLabel) to
// avoid a temporal-dead-zone throw when the boot label renders.
let curId = (cfg.currentId && cfg.currentId.indexOf('kata:') !== 0 ? cfg.currentId : '')
|| (data ? data.id : '') || '';
function decodeFloat32(b64) {{
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Float32Array(bytes.buffer);
}}
function decodeBytes(b64) {{
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}}
const app = document.getElementById('app');
const scene = new THREE.Scene();
const defaultBackground = new THREE.Color(0x151719);
scene.background = defaultBackground.clone();
let dojoTexture = null;
let dojoEnv = null;
function makePanoramaFloorMaterial(tex) {{
return new THREE.ShaderMaterial({{
uniforms: {{
pano: {{ value: tex }},
origin: {{ value: new THREE.Vector3(0.0, 1.35, 0.0) }},
}},
vertexShader: `
varying vec3 vWorldPos;
void main() {{
vec4 world = modelMatrix * vec4(position, 1.0);
vWorldPos = world.xyz;
gl_Position = projectionMatrix * viewMatrix * world;
}}
`,
fragmentShader: `
precision highp float;
uniform sampler2D pano;
uniform vec3 origin;
varying vec3 vWorldPos;
const float PI = 3.141592653589793;
void main() {{
vec3 dir = normalize(vWorldPos - origin);
float u = atan(dir.z, dir.x) / (2.0 * PI) + 0.5;
float v = asin(clamp(dir.y, -1.0, 1.0)) / PI + 0.5;
vec3 color = texture2D(pano, vec2(u, v)).rgb;
gl_FragColor = vec4(color * 1.08, 1.0);
}}
`,
side: THREE.DoubleSide,
}});
}}
function clearDojoScene() {{
if (dojoEnv) {{
scene.remove(dojoEnv);
dojoEnv.traverse((o) => {{
if (o.geometry) o.geometry.dispose();
if (o.material) {{
if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
else o.material.dispose();
}}
}});
dojoEnv = null;
}}
if (dojoTexture) {{
dojoTexture.dispose();
dojoTexture = null;
}}
scene.background = defaultBackground.clone();
if (typeof floor !== 'undefined') floor.visible = true;
}}
function applyDojoScene(src, settings) {{
if (!src) {{ clearDojoScene(); return; }}
applyDojoRoomSettings(settings);
new THREE.TextureLoader().load(src, (tex) => {{
clearDojoScene();
applyDojoRoomSettings(settings);
tex.colorSpace = THREE.SRGBColorSpace;
dojoTexture = tex;
scene.background = defaultBackground.clone();
if (typeof floor !== 'undefined') floor.visible = false;
dojoEnv = new THREE.Group();
dojoEnv.name = 'dojo-environment';
// Finite world-space panorama shell. Unlike scene.background, this is real
// geometry, so camera translation/dolly creates visible scale/parallax.
const shellGeo = new THREE.SphereGeometry(5.8, 64, 32);
shellGeo.scale(-1, 1, 1);
const shellMat = new THREE.MeshBasicMaterial({{ map: tex, side: THREE.FrontSide, depthWrite: false }});
const shell = new THREE.Mesh(shellGeo, shellMat);
shell.position.set(0, 1.35, 0);
shell.renderOrder = -10;
dojoEnv.add(shell);
const floorGeo = new THREE.PlaneGeometry(8.5, 8.5, 24, 24);
const floorMat = makePanoramaFloorMaterial(tex);
const roomFloor = new THREE.Mesh(floorGeo, floorMat);
roomFloor.rotation.x = -Math.PI / 2;
roomFloor.position.y = -0.012;
dojoEnv.add(roomFloor);
const wallMat = new THREE.MeshStandardMaterial({{
color: 0x3b3028,
roughness: 0.9,
metalness: 0.0,
transparent: true,
opacity: 0.42,
side: THREE.DoubleSide,
}});
const backWall = new THREE.Mesh(new THREE.PlaneGeometry(8.5, 3.7), wallMat.clone());
backWall.position.set(0, 1.85, -4.15);
dojoEnv.add(backWall);
const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(8.2, 3.7), wallMat.clone());
leftWall.position.set(-4.15, 1.85, 0);
leftWall.rotation.y = Math.PI / 2;
dojoEnv.add(leftWall);
const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(8.2, 3.7), wallMat.clone());
rightWall.position.set(4.15, 1.85, 0);
rightWall.rotation.y = -Math.PI / 2;
dojoEnv.add(rightWall);
wallMat.dispose();
scene.add(dojoEnv);
applyDojoRoomSettings(settings);
}}, undefined, (err) => {{
console.error('dojo scene load failed', err);
}});
}}
let disposed = false; // set on teardown to stop the loop + free the GL context
const renderer = new THREE.WebGLRenderer({{ antialias: false, powerPreference: 'high-performance' }});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
renderer.outputColorSpace = THREE.SRGBColorSpace;
app.appendChild(renderer.domElement);
// Spark renderer is created on first splat use (dojo scenes only) — see ensureSpark().
// This keeps the heavy unpkg spark module off the viewer's critical boot path.
let spark = null;
async function ensureSpark() {{
const mod = await import('@sparkjsdev/spark');
if (!spark) {{ spark = new mod.SparkRenderer({{ renderer }}); scene.add(spark); }}
return mod;
}}
let dojoSplatRoot = null;
let dojoSplatHelper = null;
let dojoSplatObjectUrl = null;
// Room size is the dojo FLOOR FOOTPRINT in world metres — the same units as the
// ~1.7m character (SMPLX_HEIGHT). fitDojoSplat normalises every splat to this span,
// so the character/room proportion is fixed: 6.5m ≈ 3.8 character-heights across
// (the character spans ~26% of the floor — a believable small dojo).
let dojoRoomSize = 6.5;
let dojoRoomHeight = 3.2;
let dojoSplatBaseBounds = null;
const dojoFloorSurfaceY = 0.0;
const dojoFloorThickness = 0.025;
function postDojoStatus(text) {{
try {{ parent.postMessage({{ kind: 'dojo-status', text }}, '*'); }} catch (e) {{}}
}}
function applyDojoRoomSettings(settings) {{
settings = settings || {{}};
dojoRoomSize = Math.max(1.0, Number(settings.room_size || settings.roomSize || dojoRoomSize) || dojoRoomSize);
dojoRoomHeight = Math.max(1.0, Number(settings.room_height || settings.roomHeight || dojoRoomHeight) || dojoRoomHeight);
if (dojoSplatRoot) fitDojoSplat();
if (dojoEnv) dojoEnv.scale.set(dojoRoomSize / 8.5, dojoRoomHeight / 3.2, dojoRoomSize / 8.5);
if (!dojoSplatRoot) resizeFloorGrid(dojoRoomSize);
}}
function percentile(values, q) {{
if (!values || !values.length) return 0;
const sorted = values.slice().sort((a, b) => a - b);
const idx = Math.min(sorted.length - 1, Math.max(0, (sorted.length - 1) * q));
const lo = Math.floor(idx);
const hi = Math.ceil(idx);
const t = idx - lo;
return sorted[lo] * (1 - t) + sorted[hi] * t;
}}
function resizeFloorGrid(size) {{
if (typeof floor === 'undefined') return;
const gridSize = Math.max(1.0, Number(size) || dojoRoomSize);
floor.scale.set(gridSize, 1, gridSize);
}}
function estimateSplatFloor(localX, localY, localZ, robust) {{
if (!localY || !localY.length || !robust) return percentile(localY || [0], 0.02);
const yLow = percentile(localY, 0.02);
const yHigh = percentile(localY, 0.55);
const span = Math.max(0.001, yHigh - yLow);
const bins = 56;
const stats = Array.from({{ length: bins }}, () => ({{
count: 0,
minX: Infinity,
maxX: -Infinity,
minZ: Infinity,
maxZ: -Infinity,
sumY: 0,
}}));
const padX = robust.sizeX * 0.15;
const padZ = robust.sizeZ * 0.15;
for (let i = 0; i < localY.length; i++) {{
const y = localY[i];
const x = localX[i];
const z = localZ[i];
if (y < yLow || y > yHigh) continue;
if (x < robust.minX - padX || x > robust.maxX + padX || z < robust.minZ - padZ || z > robust.maxZ + padZ) continue;
const idx = Math.min(bins - 1, Math.max(0, Math.floor(((y - yLow) / span) * bins)));
const s = stats[idx];
s.count += 1;
s.sumY += y;
s.minX = Math.min(s.minX, x);
s.maxX = Math.max(s.maxX, x);
s.minZ = Math.min(s.minZ, z);
s.maxZ = Math.max(s.maxZ, z);
}}
let best = null;
let bestScore = -Infinity;
for (let i = 0; i < stats.length; i++) {{
const s = stats[i];
if (s.count < 12) continue;
const coverX = Math.min(1, Math.max(0, (s.maxX - s.minX) / robust.sizeX));
const coverZ = Math.min(1, Math.max(0, (s.maxZ - s.minZ) / robust.sizeZ));
const coverage = Math.sqrt(Math.max(0.001, coverX * coverZ));
// Prefer dense, broad, lower horizontal bands. This rejects small outlier
// clusters while avoiding upper wall/ceiling bands with similar density.
const lowerBias = 1.0 - (i / bins) * 0.35;
const score = s.count * coverage * lowerBias;
if (score > bestScore) {{
bestScore = score;
best = {{ y: s.sumY / s.count, count: s.count, coverage, index: i }};
}}
}}
if (!best) return percentile(localY, 0.08);
return best.y;
}}
async function splatPayloadBuffer(value) {{
if (!value) return '';
const response = await fetch(value);
if (!response.ok) throw new Error('PLY fetch failed: ' + response.status + ' ' + response.statusText);
return await response.arrayBuffer();
}}
function parsePlyBounds(buffer) {{
const bytes = new Uint8Array(buffer);
const scanLimit = Math.min(bytes.length, 16384);
let headerEnd = -1;
for (let i = 0; i < scanLimit - 10; i++) {{
if (bytes[i] === 10 && bytes[i + 1] === 101 && bytes[i + 2] === 110 && bytes[i + 3] === 100 && bytes[i + 4] === 95 && bytes[i + 5] === 104 && bytes[i + 6] === 101 && bytes[i + 7] === 97 && bytes[i + 8] === 100 && bytes[i + 9] === 101 && bytes[i + 10] === 114) {{
headerEnd = i + 11;
if (bytes[headerEnd] === 13 && bytes[headerEnd + 1] === 10) headerEnd += 2;
else if (bytes[headerEnd] === 10) headerEnd += 1;
break;
}}
}}
if (headerEnd < 0) return null;
const header = new TextDecoder('ascii').decode(bytes.slice(0, headerEnd));
const lines = header.split(/\\r?\\n/);
const formatLine = lines.find((line) => line.startsWith('format ')) || '';
const isAscii = formatLine.indexOf('ascii') >= 0;
const isBinaryLittle = formatLine.indexOf('binary_little_endian') >= 0;
let vertexCount = 0;
const props = [];
let inVertex = false;
for (const line of lines) {{
const parts = line.trim().split(/\\s+/);
if (parts[0] === 'element') {{
inVertex = parts[1] === 'vertex';
if (inVertex) vertexCount = Number(parts[2]) || 0;
}} else if (inVertex && parts[0] === 'property' && parts.length >= 3) {{
props.push({{ type: parts[1], name: parts[2] }});
}}
}}
const xIndex = props.findIndex((p) => p.name === 'x');
const yIndex = props.findIndex((p) => p.name === 'y');
const zIndex = props.findIndex((p) => p.name === 'z');
if (!vertexCount || xIndex < 0 || yIndex < 0 || zIndex < 0) return null;
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
const localX = [];
const localY = [];
const localZ = [];
const add = (x, y, z) => {{
if (![x, y, z].every(Number.isFinite)) return;
min[0] = Math.min(min[0], x); min[1] = Math.min(min[1], y); min[2] = Math.min(min[2], z);
max[0] = Math.max(max[0], x); max[1] = Math.max(max[1], y); max[2] = Math.max(max[2], z);
// Match the viewer transforms below: splat yaw Y=PI/2, root X=PI.
localX.push(z);
localY.push(-y);
localZ.push(x);
}};
if (isAscii) {{
const body = new TextDecoder('utf-8').decode(bytes.slice(headerEnd));
const rows = body.split(/\\r?\\n/);
for (let i = 0; i < Math.min(vertexCount, rows.length); i++) {{
const vals = rows[i].trim().split(/\\s+/).map(Number);
add(vals[xIndex], vals[yIndex], vals[zIndex]);
}}
}} else if (isBinaryLittle) {{
const sizeOf = (type) => (type === 'double' || type === 'float64' ? 8 : type === 'uchar' || type === 'uint8' || type === 'char' || type === 'int8' ? 1 : type === 'ushort' || type === 'uint16' || type === 'short' || type === 'int16' ? 2 : 4);
const readers = {{
float: (dv, o) => dv.getFloat32(o, true),
float32: (dv, o) => dv.getFloat32(o, true),
double: (dv, o) => dv.getFloat64(o, true),
float64: (dv, o) => dv.getFloat64(o, true),
uchar: (dv, o) => dv.getUint8(o),
uint8: (dv, o) => dv.getUint8(o),
char: (dv, o) => dv.getInt8(o),
int8: (dv, o) => dv.getInt8(o),
ushort: (dv, o) => dv.getUint16(o, true),
uint16: (dv, o) => dv.getUint16(o, true),
short: (dv, o) => dv.getInt16(o, true),
int16: (dv, o) => dv.getInt16(o, true),
uint: (dv, o) => dv.getUint32(o, true),
uint32: (dv, o) => dv.getUint32(o, true),
int: (dv, o) => dv.getInt32(o, true),
int32: (dv, o) => dv.getInt32(o, true),
}};
const stride = props.reduce((n, p) => n + sizeOf(p.type), 0);
const offsets = [];
let off = 0;
for (const p of props) {{ offsets.push(off); off += sizeOf(p.type); }}
const dv = new DataView(buffer);
for (let i = 0; i < vertexCount; i++) {{
const base = headerEnd + i * stride;
if (base + stride > buffer.byteLength) break;
const read = (idx) => (readers[props[idx].type] || readers.float)(dv, base + offsets[idx]);
add(read(xIndex), read(yIndex), read(zIndex));
}}
}} else {{
return null;
}}
if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) return null;
const size = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
const robust = {{
minX: percentile(localX, 0.05),
maxX: percentile(localX, 0.95),
minZ: percentile(localZ, 0.05),
maxZ: percentile(localZ, 0.95),
}};
robust.sizeX = Math.max(0.001, robust.maxX - robust.minX);
robust.sizeZ = Math.max(0.001, robust.maxZ - robust.minZ);
robust.footprint = Math.max(robust.sizeX, robust.sizeZ);
robust.floorY = estimateSplatFloor(localX, localY, localZ, robust);
robust.floorP02 = percentile(localY, 0.02);
return {{ min, max, size, maxDim: Math.max(size[0], size[1], size[2]), robust }};
}}
function clearDojoSplat() {{
if (dojoSplatHelper) {{
scene.remove(dojoSplatHelper);
if (dojoSplatHelper.geometry) dojoSplatHelper.geometry.dispose();
if (dojoSplatHelper.material) dojoSplatHelper.material.dispose();
dojoSplatHelper = null;
}}
if (dojoSplatRoot) {{
scene.remove(dojoSplatRoot);
_dojoSplatGrow = null;
dojoSplatRoot.traverse((o) => {{
// SplatMesh.dispose() frees its packed splats so the Spark renderer stops
// drawing it (removing it from the scene graph alone doesn't — Spark caches).
if (typeof o.dispose === 'function') o.dispose();
if (o.geometry) o.geometry.dispose();
if (o.material) {{
if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
else o.material.dispose();
}}
}});
dojoSplatRoot = null;
}}
if (dojoSplatObjectUrl) {{
URL.revokeObjectURL(dojoSplatObjectUrl);
dojoSplatObjectUrl = null;
}}
dojoSplatBaseBounds = null;
// Tear the Spark renderer down so it stops drawing the removed splat (it caches
// accumulated splats independently of the scene graph). ensureSpark() rebuilds it
// for the next dojo splat.
if (spark) {{
try {{ scene.remove(spark); if (typeof spark.dispose === 'function') spark.dispose(); }} catch (e) {{}}
spark = null;
}}
}}
// Animate the splat scaling up from ~0 to its fit scale on first load, so it grows
// in instead of popping. Ticked each frame by _dojoSplatGrowTick.
let _dojoSplatGrow = null;
function _dojoSplatGrowTick(dt) {{
if (!_dojoSplatGrow || !dojoSplatRoot) return;
_dojoSplatGrow.t += dt;
const k = Math.min(1, _dojoSplatGrow.t / _dojoSplatGrow.dur);
const e = 1 - Math.pow(1 - k, 3); // easeOutCubic
dojoSplatRoot.scale.setScalar(Math.max(0.0001, _dojoSplatGrow.to * e));
if (k >= 1) _dojoSplatGrow = null;
}}
function fitDojoSplat(attempt = 0, grow = false) {{
if (!dojoSplatRoot) return;
const robust = dojoSplatBaseBounds && dojoSplatBaseBounds.robust ? dojoSplatBaseBounds.robust : null;
const rawMax = robust && robust.footprint > 0 ? robust.footprint : (dojoSplatBaseBounds && dojoSplatBaseBounds.maxDim > 0 ? dojoSplatBaseBounds.maxDim : 1.0);
// Normalise the splat footprint to `dojoRoomSize` metres so it stays proportional
// to the ~SMPLX_HEIGHT-tall character (room span ≈ dojoRoomSize / 1.7 body-heights).
const target = Math.max(SMPLX_HEIGHT * 1.5, dojoRoomSize);
const scale = target / rawMax;
const localFloorY = robust ? robust.floorY : 0;
const localCenterX = robust ? (robust.minX + robust.maxX) * 0.5 : 0;
const localCenterZ = robust ? (robust.minZ + robust.maxZ) * 0.5 : 0;
const floorY = (dojoFloorSurfaceY - dojoFloorThickness) - localFloorY * scale;
if (grow) {{ _dojoSplatGrow = {{ t: 0, dur: 0.7, to: scale }}; dojoSplatRoot.scale.setScalar(0.0001); }}
else {{ _dojoSplatGrow = null; dojoSplatRoot.scale.setScalar(scale); }}
dojoSplatRoot.position.set(-localCenterX * scale, floorY, -localCenterZ * scale);
dojoSplatRoot.updateMatrixWorld(true);
resizeFloorGrid(rawMax * scale);
const raw = dojoSplatBaseBounds && dojoSplatBaseBounds.size ? dojoSplatBaseBounds.size : [rawMax, rawMax, rawMax];
const footprint = robust ? (robust.sizeX.toFixed(2) + ' x ' + robust.sizeZ.toFixed(2)) : rawMax.toFixed(2);
const floorP02 = robust && robust.floorP02 != null ? robust.floorP02.toFixed(2) : 'n/a';
postDojoStatus('Splat loaded in viewer. Raw bounds ' + raw.map((v) => v.toFixed(2)).join(' x ') + 'm, robust footprint ' + footprint + 'm, floor band ' + localFloorY.toFixed(2) + 'm (p02 ' + floorP02 + 'm), scale ' + scale.toFixed(2) + 'x.');
}}
async function applyDojoSplat(url, settings) {{
if (!url) {{ clearDojoSplat(); postDojoStatus('Splat cleared.'); return; }}
clearDojoSplat();
applyDojoRoomSettings(settings);
postDojoStatus('Loading splat in viewer...');
try {{
const buffer = await splatPayloadBuffer(url);
dojoSplatBaseBounds = parsePlyBounds(buffer);
if (dojoSplatObjectUrl) URL.revokeObjectURL(dojoSplatObjectUrl);
dojoSplatObjectUrl = URL.createObjectURL(new Blob([buffer], {{ type: 'application/octet-stream' }}));
const loadUrl = dojoSplatObjectUrl;
const sparkMod = await ensureSpark();
const packedSplats = await new sparkMod.SplatLoader().loadAsync(loadUrl);
const splat = new sparkMod.SplatMesh({{ packedSplats }});
dojoSplatRoot = new THREE.Group();
dojoSplatRoot.name = 'dojo-splat';
dojoSplatRoot.add(splat);
splat.rotation.y = Math.PI / 2;
dojoSplatRoot.rotation.x = Math.PI;
dojoSplatRoot.scale.setScalar(1.0);
dojoSplatRoot.position.set(0.95, 0.05, -0.85);
scene.add(dojoSplatRoot);
fitDojoSplat(0, true); // grow in from ~0 so the splat doesn't pop
}} catch (err) {{
console.error('dojo splat load failed', err);
postDojoStatus('Splat load failed: ' + (err && err.message ? err.message : err));
}}
}}
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 200);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// --- Move placement: overlay a move's END pose as a draggable figure. Tap the
// ground to move it (sets the 2D root), swipe across it to rotate. Reports
// {{kind:'place-update', tx, tz, rot}} so the composer updates the move. ---
let placeGroup = null, placePosed = null, placeParents = null, placeTx = 0, placeTz = 0, placeRot = 0, placeHitMesh = null, placeMoveHit = null, placeRotHit = null;
let placeBaseTx = 0, placeBaseTz = 0, placeBaseHeading = 0; // world transform of the move's START
let placeStartPosed = null, placeStartGroup = null, placeStartVis = false, placeEndVis = true, placeEndSkel = null;
const _placeRay = new THREE.Raycaster();
const _placePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const _placeNDC = new THREE.Vector2(), _placeHit = new THREE.Vector3();
let _placeDrag = null;
function placeApply() {{
if (!placeGroup) return;
// Compose the move's local (tx,tz,rot) onto the START-of-move world transform, so
// the overlay sits where the move ends in the kata (not at the origin).
const bh = placeBaseHeading, c = Math.cos(bh), s = Math.sin(bh);
placeGroup.position.set(placeTx * c + placeTz * s + placeBaseTx, 0, -placeTx * s + placeTz * c + placeBaseTz);
placeGroup.rotation.y = bh + placeRot * Math.PI / 180;
}}
// Build a colored skeleton (lines + joint dots) group from a posed-joint array.
function _placeSkel(posed, color) {{
const grp = new THREE.Group();
const segs = [];
for (let i = 0; i < posed.length; i++) {{ const p = placeParents[i]; if (p < 0) continue; segs.push(posed[p], posed[i]); }}
const arr = new Float32Array(segs.length * 3);
for (let i = 0; i < segs.length; i++) {{ arr[i*3] = segs[i][0]; arr[i*3+1] = segs[i][1]; arr[i*3+2] = segs[i][2]; }}
const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(arr, 3));
const lines = new THREE.LineSegments(g, new THREE.LineBasicMaterial({{ color, depthTest: false, transparent: true, opacity: 0.96 }})); lines.renderOrder = 999; grp.add(lines);
const dotG = new THREE.SphereGeometry(0.022, 6, 6), dotM = new THREE.MeshBasicMaterial({{ color, depthTest: false }});
for (const j of posed) {{ const d = new THREE.Mesh(dotG, dotM); d.position.set(j[0], j[1], j[2]); d.renderOrder = 999; grp.add(d); }}
return grp;
}}
// Animate a group's scale toward show/hide so skeletons grow/shrink (no pop).
const _placeAnims = new Map(); // obj -> target scale
function placeSetVisible(obj, show) {{ if (!obj) return; if (show) obj.visible = true; _placeAnims.set(obj, {{ target: show ? 1 : 0 }}); }}
function _placeAnimTick() {{
if (!_placeAnims.size) return;
for (const [obj, st] of _placeAnims) {{
if (!obj.parent) {{ _placeAnims.delete(obj); continue; }}
const cur = obj.scale.x, next = cur + (st.target - cur) * 0.28;
if (Math.abs(next - st.target) < 0.012) {{ obj.scale.setScalar(st.target || 0.0001); if (st.target === 0) obj.visible = false; _placeAnims.delete(obj); }}
else obj.scale.setScalar(next);
}}
}}
function placeBuild() {{
if (placeGroup) {{ scene.remove(placeGroup); placeGroup = null; }}
if (placeStartGroup) {{ scene.remove(placeStartGroup); placeStartGroup = null; }}
_placeAnims.clear();
// Container holding the END skeleton + the gizmos. The container stays visible so
// the pose CONTROLS (ring/knob) remain even when the skeleton is hidden.
placeGroup = new THREE.Group();
placeEndSkel = _placeSkel(placePosed, 0xe8736a); // RED end-pose skeleton (toggle)
placeEndSkel.scale.setScalar(0.0001); placeEndSkel.visible = placeEndVis; placeGroup.add(placeEndSkel);
// Ground gizmos: green ring at the feet = drag to MOVE; red knob out front = ROTATE.
const moveDisc = new THREE.Mesh(new THREE.CircleGeometry(0.30, 36), new THREE.MeshBasicMaterial({{ color: 0x5fb98c, side: THREE.DoubleSide, depthTest: false, transparent: true, opacity: 0.38 }}));
moveDisc.rotation.x = -Math.PI / 2; moveDisc.position.y = 0.012; moveDisc.renderOrder = 997; placeGroup.add(moveDisc); placeMoveHit = moveDisc;
const moveRing = new THREE.Mesh(new THREE.RingGeometry(0.30, 0.35, 36), new THREE.MeshBasicMaterial({{ color: 0x9fe6c0, side: THREE.DoubleSide, depthTest: false, transparent: true, opacity: 0.95 }}));
moveRing.rotation.x = -Math.PI / 2; moveRing.position.y = 0.013; moveRing.renderOrder = 998; placeGroup.add(moveRing);
const stemG = new THREE.BufferGeometry(); stemG.setAttribute('position', new THREE.BufferAttribute(new Float32Array([0, 0.013, 0, 0, 0.013, 0.52]), 3));
placeGroup.add(new THREE.LineSegments(stemG, new THREE.LineBasicMaterial({{ color: 0xe8736a, depthTest: false, transparent: true, opacity: 0.85 }})));
const rotKnob = new THREE.Mesh(new THREE.SphereGeometry(0.09, 16, 12), new THREE.MeshBasicMaterial({{ color: 0xe8736a, depthTest: false }}));
rotKnob.position.set(0, 0.013, 0.52); rotKnob.renderOrder = 999; placeGroup.add(rotKnob); placeRotHit = rotKnob; // y on the stem line so it hits the sphere center
scene.add(placeGroup);
placeSetVisible(placeEndSkel, placeEndVis); // grow the skeleton in
// START pose skeleton (GREEN) at the move's start (the base transform), hidden by default.
if (placeStartPosed && placeStartPosed.length) {{
placeStartGroup = _placeSkel(placeStartPosed, 0x5fb98c);
placeStartGroup.position.set(placeBaseTx, 0, placeBaseTz);
placeStartGroup.rotation.y = placeBaseHeading;
placeStartGroup.scale.setScalar(0.0001); placeStartGroup.visible = placeStartVis;
scene.add(placeStartGroup);
placeSetVisible(placeStartGroup, placeStartVis);
}}
placeApply();
}}
function placeShow(posed, startPosed, parents, tx, tz, rot, base, startVis, endVis) {{
placePosed = posed; placeStartPosed = startPosed || null; placeParents = parents; placeTx = tx; placeTz = tz; placeRot = rot;
placeBaseTx = (base && base.x) || 0; placeBaseTz = (base && base.z) || 0; placeBaseHeading = (base && base.heading) || 0;
placeStartVis = !!startVis; placeEndVis = (endVis !== false);
placeBuild();
}}
function placeHide() {{
if (placeGroup) {{ scene.remove(placeGroup); placeGroup = null; }}
if (placeStartGroup) {{ scene.remove(placeStartGroup); placeStartGroup = null; }}
placePosed = null; placeStartPosed = null; placeHitMesh = null; placeMoveHit = null; placeRotHit = null; controls.enabled = true;
}}
function _placeNDCfrom(cx, cy) {{ const r = renderer.domElement.getBoundingClientRect(); _placeNDC.x = ((cx - r.left) / r.width) * 2 - 1; _placeNDC.y = -((cy - r.top) / r.height) * 2 + 1; _placeRay.setFromCamera(_placeNDC, camera); }}
function placeOverFigure(cx, cy) {{ if (!placeHitMesh) return false; _placeNDCfrom(cx, cy); return _placeRay.intersectObject(placeHitMesh, false).length > 0; }}
function placeGround(cx, cy) {{ _placeNDCfrom(cx, cy); return _placeRay.ray.intersectPlane(_placePlane, _placeHit) ? _placeHit : null; }}
// Place gizmos: pointerdown on the MOVE ring → drag to translate; on the ROTATE knob
// → drag around to turn. Anywhere else the camera orbits (the figure body is inert).
renderer.domElement.addEventListener('pointerdown', (e) => {{
if (!placeGroup) return;
_placeNDCfrom(e.clientX, e.clientY);
const onRot = placeRotHit && _placeRay.intersectObject(placeRotHit, false).length > 0;
const onMove = !onRot && placeMoveHit && _placeRay.intersectObject(placeMoveHit, false).length > 0;
if (onRot || onMove) {{
const g = placeGround(e.clientX, e.clientY); const wp = placeGroup.position;
_placeDrag = {{ mode: onRot ? 'rot' : 'move', ox: g ? (wp.x - g.x) : 0, oz: g ? (wp.z - g.z) : 0 }}; // world offset
controls.enabled = false; // dragging a gizmo, not the camera
}} else {{ _placeDrag = null; }}
}});
renderer.domElement.addEventListener('pointermove', (e) => {{
if (!placeGroup || !_placeDrag) return;
const hit = placeGround(e.clientX, e.clientY); if (!hit) return;
const bh = placeBaseHeading, c = Math.cos(bh), s = Math.sin(bh);
if (_placeDrag.mode === 'move') {{
const dx = (hit.x + _placeDrag.ox) - placeBaseTx, dz = (hit.z + _placeDrag.oz) - placeBaseTz; // desired world → local
placeTx = dx * c - dz * s; placeTz = dx * s + dz * c;
}} else {{
const cx = placeGroup.position.x, cz = placeGroup.position.z; // face the figure toward the pointer
placeRot = (Math.atan2(hit.x - cx, hit.z - cz) - bh) * 180 / Math.PI;
}}
placeApply(); postParent({{ kind: 'place-update', tx: placeTx, tz: placeTz, rot: placeRot }});
}});
function _placeEnd() {{ controls.enabled = true; _placeDrag = null; }}
renderer.domElement.addEventListener('pointerup', _placeEnd);
renderer.domElement.addEventListener('pointercancel', _placeEnd);
// Brighter, lighter-bottomed hemisphere so downward-facing surfaces (a shadowed
// face in a guard pose) aren't near-black; key + a front fill so faces read.
scene.add(new THREE.HemisphereLight(0xffffff, 0x6a7078, 2.7));
const dir = new THREE.DirectionalLight(0xffffff, 2.1);
dir.position.set(3, 5, 2);
scene.add(dir);
const fill = new THREE.DirectionalLight(0xffffff, 0.85); // front fill toward the camera
fill.position.set(-2, 3, 5);
scene.add(fill);
const floor = new THREE.GridHelper(1, 20, 0x42f6ff, 0x1aa6b8);
floor.position.y = dojoFloorSurfaceY;
floor.scale.set(dojoRoomSize, 1, dojoRoomSize);
scene.add(floor);
// Motion arrays are swapped in only for clips with this viewer's joint count.
let jointData = decodeFloat32(data.joint_data_b64);
let quatData = data.quat_data_b64 ? decodeFloat32(data.quat_data_b64) : null;
let rootData = data.root_data_b64 ? decodeFloat32(data.root_data_b64) : null;
let nFrames = data.preview_frames || 0;
let fps = data.preview_fps || data.fps || 30;
const parents = data.parents;
const boneNames = data.bone_names || [];
const nJoints = data.num_joints || 0;
// --- Procedural skeleton (joints + bones + root trail), grouped for toggling.
const skel = new THREE.Group();
scene.add(skel);
// Camera framing + root-trail geometry, recomputed from the CURRENT clip
// (frame count and extents change between clips).
const rootPathGeo = new THREE.BufferGeometry();
skel.add(new THREE.Line(rootPathGeo, new THREE.LineBasicMaterial({{ color: 0x7dd3fc }})));
function frameCamera() {{
let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < jointData.length; i += 3) {{
const x = jointData[i], y = jointData[i + 1], z = jointData[i + 2];
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
}}
const box = new THREE.Box3(new THREE.Vector3(minX, minY, minZ), new THREE.Vector3(maxX, maxY, maxZ));
const center = new THREE.Vector3(), size = new THREE.Vector3();
box.getCenter(center); box.getSize(size);
const radius = Math.max(size.x, size.y, size.z, 1);
controls.target.copy(center);
camera.position.set(center.x + radius * 0.9, center.y + radius * 0.55, center.z + radius * 1.8);
camera.near = Math.max(radius / 1000, 0.01);
camera.far = radius * 20;
camera.updateProjectionMatrix();
const rp = new Float32Array(nFrames * 3);
for (let f = 0; f < nFrames; f++) {{
const src = f * nJoints * 3;
rp[f * 3] = jointData[src]; rp[f * 3 + 1] = 0.02; rp[f * 3 + 2] = jointData[src + 2];
}}
rootPathGeo.setAttribute('position', new THREE.BufferAttribute(rp, 3));
rootPathGeo.computeBoundingSphere();
}}
const cameraLockBtn = document.getElementById('camera-lock');
let cameraLocked = true;
const cameraFocus = new THREE.Vector3();
const previousCameraFocus = new THREE.Vector3();
const cameraFocusDelta = new THREE.Vector3();
const lockedCameraOffset = new THREE.Vector3(1.55, 0.72, 2.85);
function frameFocus(f, out) {{
const safeFrame = Math.max(0, Math.min(nFrames - 1, f || 0));
const o = safeFrame * nJoints * 3;
out.set(jointData[o] || 0, jointData[o + 1] || 0.9, jointData[o + 2] || 0);
return out;
}}
function setLockedCameraView() {{
frameFocus(frame, cameraFocus);
controls.target.copy(cameraFocus);
camera.position.copy(cameraFocus).add(lockedCameraOffset);
previousCameraFocus.copy(cameraFocus);
controls.update();
}}
function syncCameraLock(jump = false) {{
if (!cameraLocked || !nFrames || !nJoints) return;
if (_camAnim) {{ frameFocus(frame, previousCameraFocus); return; }} // a fly is in progress — don't fight it
frameFocus(frame, cameraFocus);
if (jump) {{
setLockedCameraView();
return;
}}
cameraFocusDelta.copy(cameraFocus).sub(previousCameraFocus);
camera.position.add(cameraFocusDelta);
controls.target.add(cameraFocusDelta);
previousCameraFocus.copy(cameraFocus);
}}
function updateCameraLockButton() {{
cameraLockBtn.innerHTML = cameraLocked ? '&#128274;' : '&#128275;';
cameraLockBtn.title = cameraLocked ? 'Camera locked to character' : 'Camera unlocked';
cameraLockBtn.setAttribute('aria-label', cameraLockBtn.title);
}}
cameraLockBtn.onclick = () => {{
cameraLocked = !cameraLocked;
if (cameraLocked) setLockedCameraView();
updateCameraLockButton();
}};
updateCameraLockButton();
// Center + frame the FULL body (head→feet) at the current frame. The open sidebar is
// accounted for by the existing view offset (applyViewOffset), so the body is centered
// in the still-visible area. Driven by the parent's center/lock button.
// Eased camera fly so loading a stance/move GLIDES into frame instead of popping.
let _camAnim = null;
function flyCameraTo(pos, look, dur) {{ _camAnim = {{ p0: camera.position.clone(), p1: pos.clone(), t0: controls.target.clone(), t1: look.clone(), t: 0, dur: dur || 0.5 }}; }}
function _camAnimTick(dt) {{
if (!_camAnim) return;
_camAnim.t += dt;
const k = Math.min(1, _camAnim.t / _camAnim.dur);
const e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; // easeInOutQuad
camera.position.lerpVectors(_camAnim.p0, _camAnim.p1, e);
controls.target.lerpVectors(_camAnim.t0, _camAnim.t1, e);
if (k >= 1) _camAnim = null;
}}
const _frameDir = new THREE.Vector3(0.04, 0.22, 2.9).normalize(); // near-frontal, camera further left + lower
// Frame the WHOLE body (head→feet) with a roomy margin, centered in the visible area
// (the open drawer is accounted for by applyViewOffset). smooth=true glides there.
function centerFullBody(smooth) {{
if (!nFrames || !nJoints) return;
let minY = Infinity, maxY = -Infinity;
const base = Math.max(0, Math.min(nFrames - 1, frame)) * nJoints * 3;
for (let i = 0; i < nJoints; i++) {{ const y = jointData[base + i * 3 + 1]; if (y < minY) minY = y; if (y > maxY) maxY = y; }}
const bh = Math.max((maxY - minY) || 1.6, 1.2);
frameFocus(frame, previousCameraFocus); // pelvis → smooth follow deltas
const look = previousCameraFocus.clone(); look.y = (minY + maxY) / 2; // focus = body center
const fov = (camera.fov || 45) * Math.PI / 180;
const dist = (bh / (2 * Math.tan(fov / 2))) * 2.9 + 1.0; // fit full height + extra margin (zoomed out more)
const pos = look.clone().addScaledVector(_frameDir, dist);
if (smooth) flyCameraTo(pos, look, 0.5);
else {{ camera.position.copy(pos); controls.target.copy(look); controls.update(); }}
applyViewOffset();
}}
const childCounts = parents.map(() => 0);
parents.forEach(p => {{ if (p >= 0) childCounts[p] += 1; }});
const jointGeo = new THREE.SphereGeometry(0.018, 10, 6);
const jointMat = new THREE.MeshStandardMaterial({{ color: 0xd7dde5, roughness: 0.55 }});
const jointMesh = new THREE.InstancedMesh(jointGeo, jointMat, nJoints);
jointMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
jointMesh.frustumCulled = false;
for (let i = 0; i < nJoints; i++) {{
const color = i === 0 ? 0x7dd3fc : (childCounts[i] === 0 ? 0xfacc15 : 0xd7dde5);
jointMesh.setColorAt(i, new THREE.Color(color));
}}
if (jointMesh.instanceColor) jointMesh.instanceColor.needsUpdate = true;
skel.add(jointMesh);
const edges = [];
for (let i = 0; i < nJoints; i++) {{
if (parents[i] < 0) continue;
edges.push([i, parents[i]]);
}}
const edgePositions = new Float32Array(edges.length * 2 * 3);
const edgeGeo = new THREE.BufferGeometry();
edgeGeo.setAttribute('position', new THREE.BufferAttribute(edgePositions, 3));
edgeGeo.attributes.position.setUsage(THREE.DynamicDrawUsage);
const edgeLines = new THREE.LineSegments(edgeGeo, new THREE.LineBasicMaterial({{ color: 0xaeb7c2 }}));
edgeLines.frustumCulled = false;
skel.add(edgeLines);
const dummy = new THREE.Object3D();
// Skeleton OVERLAY: draw the true joint bones on top of the mesh character
// (x-ray) so the actual pose is readable as it plays. Toggled in the menu.
let skelOverlay = false;
function applySkelOverlayStyle() {{
// Bright + depth-tested-off so bones show THROUGH the character when overlaying.
const xray = skelOverlay && mode !== 'skeleton';
edgeLines.material.color.set(xray ? 0x39ff14 : 0xaeb7c2);
edgeLines.material.depthTest = !xray;
jointMat.depthTest = !xray;
jointMat.color.set(xray ? 0x39ff14 : 0xd7dde5);
edgeLines.renderOrder = jointMesh.renderOrder = xray ? 999 : 0;
jointMat.needsUpdate = true;
}}
// RED reference-pose skeleton (viser-style constraint skeleton): the STATIC
// target pose a seeded clip was generated to start from. Drawn alongside the
// green overlay so you can see the generated frame 0 land on it (and peel away).
const refEdgePositions = new Float32Array(edges.length * 2 * 3);
const refEdgeGeo = new THREE.BufferGeometry();
refEdgeGeo.setAttribute('position', new THREE.BufferAttribute(refEdgePositions, 3));
const refEdges = new THREE.LineSegments(refEdgeGeo, new THREE.LineBasicMaterial({{
color: 0xff3b3b, depthTest: false, transparent: true, opacity: 0.9 }}));
refEdges.renderOrder = 998; refEdges.frustumCulled = false; refEdges.visible = false;
scene.add(refEdges);
let hasReference = false;
function setReferencePose(ref) {{
// ref: flat [nJoints*3] array (re-rooted to XZ origin, same frame as clip f0).
hasReference = Array.isArray(ref) && ref.length === nJoints * 3;
if (hasReference) {{
let k = 0;
for (const [i, p] of edges) {{
refEdgePositions[k++] = ref[p*3]; refEdgePositions[k++] = ref[p*3+1]; refEdgePositions[k++] = ref[p*3+2];
refEdgePositions[k++] = ref[i*3]; refEdgePositions[k++] = ref[i*3+1]; refEdgePositions[k++] = ref[i*3+2];
}}
refEdgeGeo.attributes.position.needsUpdate = true;
}}
refEdges.visible = hasReference && skelOverlay;
}}
setReferencePose((typeof data !== 'undefined' && data && data.reference_pose) || null);
// RED NEXT-TARGET skeleton for a KATA: a single static red pose at the NEXT
// upcoming checkpoint (the most-expressive frame of the upcoming move). As the
// green skeleton advances past a checkpoint, the red jumps to the next one —
// less noisy than showing every target at once.
let targetFramesArr = [];
const targetPos = new Float32Array(edges.length * 2 * 3);
const targetGeo = new THREE.BufferGeometry();
targetGeo.setAttribute('position', new THREE.BufferAttribute(targetPos, 3));
const targetEdges = new THREE.LineSegments(targetGeo, new THREE.LineBasicMaterial({{
color: 0xff3b3b, depthTest: false, transparent: true, opacity: 0.75 }}));
targetEdges.renderOrder = 997; targetEdges.frustumCulled = false; targetEdges.visible = false;
scene.add(targetEdges);
function setTargetPoses(frames) {{
targetFramesArr = (Array.isArray(frames) ? frames.filter((f) => f >= 0 && f < nFrames) : []).sort((a, b) => a - b);
updateNextTarget(frame);
}}
function updateNextTarget(curFrame) {{
if (!targetFramesArr.length || !skelOverlay || !jointData) {{ targetEdges.visible = false; return; }}
// next checkpoint strictly ahead; wrap to the first when past the last (loop).
let f = targetFramesArr.find((t) => t > curFrame);
if (f === undefined) f = targetFramesArr[0];
const base = f * nJoints * 3; let k = 0;
for (const [i, p] of edges) {{
targetPos[k++] = jointData[base + p*3]; targetPos[k++] = jointData[base + p*3+1]; targetPos[k++] = jointData[base + p*3+2];
targetPos[k++] = jointData[base + i*3]; targetPos[k++] = jointData[base + i*3+1]; targetPos[k++] = jointData[base + i*3+2];
}}
targetGeo.attributes.position.needsUpdate = true;
targetEdges.visible = true;
}}
const skelOverlayBox = document.getElementById('skel-overlay');
if (skelOverlayBox) skelOverlayBox.addEventListener('change', () => {{
skelOverlay = skelOverlayBox.checked;
skel.visible = skelOverlay || mode === 'skeleton';
refEdges.visible = hasReference && skelOverlay;
updateNextTarget(frame);
applySkelOverlayStyle();
setSkeletonFrame(frame); // populate bone positions immediately
}});
// --- Rest-mode retargeter: drive a skinned rig from the clip's world quats.
// Ported from kimodo-motion-api web/src/animator.js (alignMode='rest'):
// Q_target_world = Q_kimodo_world . Q_bone_rest_world
// Q_target_local = Q_parent_world^-1 . Q_target_world
class Animator {{
constructor(skinned, mapping, opts) {{
this.mapping = mapping;
this.scale = opts.scale;
this.groundOffsetY = opts.groundOffsetY;
this.norm = (n) => n.replace(/[.:]/g, '');
this.bonesByName = {{}};
for (const b of skinned.skeleton.bones) this.bonesByName[this.norm(b.name)] = b;
skinned.updateMatrixWorld(true);
this.restQ = new Map();
const p = new THREE.Vector3(), q = new THREE.Quaternion(), s = new THREE.Vector3();
for (const b of skinned.skeleton.bones) {{ b.matrixWorld.decompose(p, q, s); this.restQ.set(b.name, q.clone()); }}
this.pelvis = this.bonesByName[this.norm(mapping.pelvis || '')] || null;
this.pairs = [];
this._qk = new THREE.Quaternion(); this._qw = new THREE.Quaternion();
this._qp = new THREE.Quaternion(); this._tp = new THREE.Vector3(); this._ts = new THREE.Vector3();
}}
resolve(names) {{
this.pairs = [];
for (let i = 0; i < names.length; i++) {{
const tName = this.mapping[names[i]];
if (!tName) continue;
const bone = this.bonesByName[this.norm(tName)];
if (!bone) continue;
this.pairs.push({{ idx: i, bone, restQ: this.restQ.get(bone.name) }});
}}
console.log('[retarget] resolved ' + this.pairs.length + ' of ' + names.length + ' joints');
}}
applyFrame(quats, root, f) {{
if (!quats) return;
for (const {{ idx, bone, restQ }} of this.pairs) {{
const o = f * nJoints * 4 + idx * 4;
this._qk.set(quats[o], quats[o + 1], quats[o + 2], quats[o + 3]);
this._qw.copy(this._qk).multiply(restQ);
if (bone.parent) {{
bone.parent.matrixWorld.decompose(this._tp, this._qp, this._ts);
this._qp.invert();
bone.quaternion.copy(this._qp).multiply(this._qw);
}} else {{
bone.quaternion.copy(this._qw);
}}
bone.updateMatrixWorld(true);
}}
if (this.pelvis && root) {{
const s = this.scale;
this.pelvis.position.set(root[f * 3] * s, root[f * 3 + 1] * s - this.groundOffsetY, root[f * 3 + 2] * s);
}}
}}
}}
const charCache = {{}};
let character = null; // {{ root, anim }}
let mode = 'skeleton';
// Map a skin atlas (e.g. the decompiled s&box citizen skin) onto a character's
// body materials. One shared UV atlas covers the whole body; skip eye/mouth
// sub-materials (their own UVs). flipY=false = glTF UV convention.
// The material color multiplies the texture — a warm pinkish "wiener" tint pushes
// the pale skin toward sausage. Set to 0xffffff to disable the tint.
const SAUSAGE_TINT = 0x8f6549;
const _charTexLoader = new THREE.TextureLoader();
_charTexLoader.setCrossOrigin('anonymous');
function applyCharSkin(root, url) {{
const tex = _charTexLoader.load(url);
tex.colorSpace = THREE.SRGBColorSpace;
tex.flipY = false;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
root.traverse(o => {{
if (!o.isMesh && !o.isSkinnedMesh) return;
const mats = Array.isArray(o.material) ? o.material : [o.material];
for (const m of mats) {{
if (!m) continue;
const name = (m.name || o.name || '').toLowerCase();
if (/eye|mouth|teeth|tongue|brow|lash|cornea/.test(name)) continue;
m.map = tex;
if (m.color) m.color.setHex(SAUSAGE_TINT); // warm sausage tint multiplies the skin atlas
if (m.metalness !== undefined) m.metalness = 0; // skin is non-metal; metallic PBR w/o envMap renders black
if (m.roughness !== undefined) m.roughness = Math.max(m.roughness, 0.7);
m.needsUpdate = true;
}}
}});
}}
// Network loads on the boot/reveal path must never HANG: a stalled CDN request
// (socket opens, no response) never rejects, so a bare `await loadAsync` would leave
// the viewer stuck on the spinner with NO character. Bound each GLB fetch with a
// timeout (+ optional retry) so a stall fails fast and we fall back gracefully
// (skeleton for the body, skip-the-garment for clothing) instead of blanking forever.
function loadGltfTimed(loader, url, ms, tries) {{
ms = ms || 12000; tries = Math.max(1, tries || 1);
const once = () => new Promise((resolve, reject) => {{
let settled = false;
const timer = setTimeout(() => {{ if (!settled) {{ settled = true; reject(new Error('gltf timeout: ' + url)); }} }}, ms);
loader.loadAsync(url).then(
(g) => {{ if (!settled) {{ settled = true; clearTimeout(timer); resolve(g); }} }},
(e) => {{ if (!settled) {{ settled = true; clearTimeout(timer); reject(e); }} }}
);
}});
let p = once();
for (let i = 1; i < tries; i++) p = p.catch(() => once()); // retry a transient stall/error
return p;
}}
async function buildCharacter(entry) {{
const loader = new GLTFLoader();
// Prefer the hosted GLB URL — the browser caches it across iframe rebuilds, so
// re-rendering the viewer doesn't re-download or re-ship the mesh. Fall back to an
// inline base64 blob if a character still embeds one. Timed + retried so a stalled
// CDN fetch fails fast (-> skeleton) rather than hanging the viewer blank.
const gltf = entry.glb_url
? await loadGltfTimed(loader, entry.glb_url, 12000, 2)
: await loader.parseAsync(decodeBytes(entry.glb_b64).buffer, '');
const root = gltf.scene;
root.scale.setScalar(entry.scale || 1.0);
let skinned = null;
root.traverse(o => {{ if (o.isSkinnedMesh) {{ if (!skinned) skinned = o; o.frustumCulled = false; }} }});
if (!skinned) throw new Error('GLB has no skinned mesh');
if (entry.texture) applyCharSkin(root, entry.texture); // map the s&box skin atlas onto the body
root.visible = false;
scene.add(root);
root.updateMatrixWorld(true);
const bb = new THREE.Box3().setFromObject(root);
const groundOffsetY = -bb.min.y;
root.position.y += groundOffsetY;
const charHeight = (bb.max.y - bb.min.y) || 1;
const anim = new Animator(skinned, entry.mapping, {{ scale: charHeight / SMPLX_HEIGHT, groundOffsetY }});
anim.resolve(boneNames);
return {{ root, anim, skinned, dressed: false }};
}}
// --- Clothing: garments are skinned GLBs rigged to the SAME citizen bone_N
// skeleton. Wear one by name-matching its bones to the citizen's and copying
// the citizen's bone transforms onto them each frame (syncClothing), so the
// garment deforms with the body. Ported from kata.js attach/detach/sync.
const clothLoader = new GLTFLoader();
const worn = new Map(); // slot -> garment id (intent, persists)
const wornAttached = new Map(); // slot -> {{ scene, pairs }}
const slotSelects = {{}};
for (const [slot, id] of Object.entries(cfg.defaultClothing || {{}})) worn.set(slot, id);
function detachGarment(slot) {{
const a = wornAttached.get(slot);
if (!a) return;
if (character) character.root.remove(a.scene);
a.scene.traverse(o => {{ if (o.isMesh && o.geometry) o.geometry.dispose(); }});
wornAttached.delete(slot);
}}
async function attachGarment(slot, item) {{
detachGarment(slot);
if (!character || !character.skinned) return;
const sc = (await loadGltfTimed(clothLoader, item.url, 9000, 1)).scene;
let mesh = null;
sc.traverse(o => {{ if (o.isSkinnedMesh) {{ if (!mesh) mesh = o; o.frustumCulled = false; }} }});
if (!mesh) return;
mesh.renderOrder = item.layer || 1;
const cit = new Map(character.skinned.skeleton.bones.map(b => [b.name, b]));
const pairs = mesh.skeleton.bones.map(jb => ({{ j: jb, c: cit.get(jb.name) }})).filter(p => p.c);
character.root.add(sc);
wornAttached.set(slot, {{ scene: sc, pairs }});
}}
// Returns true only if EVERY worn garment attached. A stalled/failed garment
// returns false so the caller can decline to reveal a half-dressed character.
async function applyWorn() {{
for (const s of [...wornAttached.keys()]) detachGarment(s);
let allOk = true;
for (const [slot, id] of worn) {{
const item = cfg.clothing.find(c => c.id === id);
if (!item) continue;
try {{ await attachGarment(slot, item); }}
catch (e) {{ console.warn('clothing load failed', id, e); allOk = false; }}
}}
return allOk;
}}
function syncClothing() {{
for (const {{ pairs }} of wornAttached.values())
for (const {{ j, c }} of pairs) {{ j.quaternion.copy(c.quaternion); j.position.copy(c.position); j.scale.copy(c.scale); }}
}}
const playBtn = document.getElementById('play');
const scrub = document.getElementById('scrub');
const frameLabel = document.getElementById('frame');
const speed = document.getElementById('speed');
const characterSel = document.getElementById('character');
const menuBtn = document.getElementById('menu-btn');
const menu = document.getElementById('menu');
menuBtn.onclick = () => menu.classList.toggle('open');
const creatorLabel = document.getElementById('creator-label');
function truncateText(value, maxLen) {{
const text = String(value || '').replace(/\\s+/g, ' ').trim();
return text.length > maxLen ? text.slice(0, Math.max(0, maxLen - 1)).trimEnd() + '...' : text;
}}
function appendSpan(parent, cls, text) {{
const span = document.createElement('span');
span.className = cls;
span.textContent = text;
parent.appendChild(span);
}}
// Always reflect the clip currently on screen. Do NOT fall back to cfg.*
// (the initially-rendered clip's creator) -- on a picker switch that would
// leave a named author stuck on a subsequently-loaded anonymous clip.
function setCreatorLabel(source) {{
const username = (source && source.created_by) || '';
const name = (source && source.created_by_name) || username;
const prompt = truncateText((source && source.prompt) || '', 72);
// The clip id lets you point precisely at the animation on screen. Prefer
// the payload's own id, else the canonically-tracked curId.
const cid = (source && source.id) || (typeof curId !== 'undefined' ? curId : '') || '';
if (!username && !prompt && !cid) {{
creatorLabel.style.display = 'none';
creatorLabel.textContent = '';
return;
}}
creatorLabel.textContent = '';
if (cid) {{
const idSpan = document.createElement('span');
idSpan.className = 'creator-id';
idSpan.textContent = cid;
idSpan.title = 'Click to copy clip id';
idSpan.style.cursor = 'pointer';
idSpan.onclick = () => {{ try {{ navigator.clipboard.writeText(cid); idSpan.classList.add('copied'); setTimeout(() => idSpan.classList.remove('copied'), 900); }} catch (e) {{}} }};
creatorLabel.appendChild(idSpan);
}}
if (username) {{
if (cid) appendSpan(creatorLabel, 'creator-sep', '/');
appendSpan(creatorLabel, 'creator-by', 'made by ');
appendSpan(creatorLabel, 'creator-name', name || username);
}}
if (prompt) {{
if (username || cid) appendSpan(creatorLabel, 'creator-sep', '/');
appendSpan(creatorLabel, 'creator-prompt', prompt);
}}
creatorLabel.title = `${{cid ? `[${{cid}}] ` : ''}}${{username ? `Made by @${{username}}` : 'Anonymous animation'}}${{prompt ? `: ${{prompt}}` : ''}}`;
creatorLabel.style.display = 'block';
}}
setCreatorLabel(data);
const loadingEl = document.getElementById('loading');
const showLoading = () => loadingEl.classList.add('on');
const hideLoading = () => loadingEl.classList.remove('on');
const toastEl = document.getElementById('viewer-toast');
let _toastTimer = null;
function showToast(msg, ms) {{
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add('on');
if (_toastTimer) clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => toastEl.classList.remove('on'), ms || 7000);
}}
// Status text was removed from the UI; this sink keeps existing status
// assignments harmless (still visible in the console).
const info = {{ set textContent(v) {{ try {{ console.log('[viewer]', v); }} catch (e) {{}} }} }};
scrub.max = Math.max(0, nFrames - 1);
// Populate the display-model picker. The Citizen mapping is SMPL-X-22 only;
// SOMA-77 clips still render as the procedural skeleton so finger chains are visible.
for (const c of characters) {{
if (c.id !== 'skeleton' && (!quatData || nJoints !== 22)) continue;
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.label;
characterSel.appendChild(opt);
}}
characterSel.value = 'skeleton';
let frame = 0;
let playing = true;
let elapsedFrames = 0;
let last = performance.now();
function setSkeletonFrame(f) {{
const base = f * nJoints * 3;
for (let i = 0; i < nJoints; i++) {{
const o = base + i * 3;
dummy.position.set(jointData[o], jointData[o + 1], jointData[o + 2]);
dummy.updateMatrix();
jointMesh.setMatrixAt(i, dummy.matrix);
}}
jointMesh.instanceMatrix.needsUpdate = true;
let k = 0;
for (const [i, p] of edges) {{
const po = base + p * 3;
const io = base + i * 3;
edgePositions[k++] = jointData[po];
edgePositions[k++] = jointData[po + 1];
edgePositions[k++] = jointData[po + 2];
edgePositions[k++] = jointData[io];
edgePositions[k++] = jointData[io + 1];
edgePositions[k++] = jointData[io + 2];
}}
edgeGeo.attributes.position.needsUpdate = true;
}}
function showFrame(idx) {{
frame = ((idx % nFrames) + nFrames) % nFrames;
if (mode === 'skeleton' || skelOverlay) setSkeletonFrame(frame);
if (skelOverlay) updateNextTarget(frame); // advance the red next-target skeleton
if (mode !== 'skeleton' && character) {{
character.anim.applyFrame(quatData, rootData, frame);
syncClothing();
}}
syncCameraLock();
scrub.value = String(frame);
frameLabel.textContent = `${{frame + 1}} / ${{nFrames}}`;
}}
const clothingRows = document.getElementById('clothing-rows');
characterSel.onchange = async () => {{
const id = characterSel.value;
if (id === 'skeleton') {{
mode = 'skeleton';
skel.visible = true;
applySkelOverlayStyle();
if (character) character.root.visible = false;
clothingRows.style.display = 'none';
showFrame(frame);
return;
}}
const entry = characters.find(c => c.id === id);
if (!entry) {{
characterSel.value = 'skeleton';
mode = 'skeleton';
skel.visible = true;
if (character) character.root.visible = false;
clothingRows.style.display = 'none';
showFrame(frame);
return;
}}
info.textContent = `Loading ${{entry.label}}...`;
if (character) character.root.visible = false;
skel.visible = skelOverlay;
showLoading();
try {{
if (!charCache[id]) charCache[id] = await buildCharacter(entry);
character = charCache[id];
mode = id;
showFrame(frame); // pose body + clothing while still HIDDEN
// Dress FULLY before revealing — we never show a half-dressed citizen. If the
// mesh or any garment stalls/fails (buildCharacter throws, or applyWorn returns
// false), fall through to the catch: keep the skeleton and toast the user that a
// refresh may be needed, rather than revealing an undressed/broken character.
const dressed = character.dressed || await applyWorn();
if (!dressed) throw new Error('clothing did not finish loading');
character.dressed = true;
character.root.visible = true; // reveal the finished, fully-dressed character
skel.visible = skelOverlay;
applySkelOverlayStyle(); // x-ray the overlay bones over the mesh
clothingRows.style.display = Object.keys(slotSelects).length ? '' : 'none';
info.textContent = `${{entry.label}} - ${{character.anim.pairs.length}} joints retargeted`;
}} catch (e) {{
console.error(e);
// Don't show the partially-loaded character — fall back to the skeleton (motion
// still plays) and notify that a refresh may be needed to load the character.
if (character) character.root.visible = false;
info.textContent = `Could not load ${{entry.label}} — refresh to retry`;
characterSel.value = 'skeleton';
mode = 'skeleton';
skel.visible = true;
clothingRows.style.display = 'none';
showFrame(frame);
showToast('Couldn’t fully load the character — refresh the page to try again.');
}} finally {{
hideLoading();
}}
}};
// Wardrobe dropdowns: one per clothing slot (None + the garments in that slot).
// Changing one attaches/detaches live when a character is shown.
async function onWardrobeChange(slot, id) {{
if (id) worn.set(slot, id); else worn.delete(slot);
if (mode === 'skeleton' || !character) return;
const item = id ? cfg.clothing.find(c => c.id === id) : null;
if (item) await attachGarment(slot, item); else detachGarment(slot);
}}
(function buildWardrobe() {{
const bySlot = {{}};
for (const g of (cfg.clothing || [])) (bySlot[g.slot] = bySlot[g.slot] || []).push(g);
let any = false;
for (const [slot, label] of Object.entries(cfg.slots || {{}})) {{
const items = bySlot[slot];
if (!items || !items.length) continue;
if (!any) {{
const hdr = document.createElement('div');
hdr.className = 'mhdr';
hdr.textContent = 'Clothing';
clothingRows.appendChild(hdr);
any = true;
}}
const row = document.createElement('div');
row.className = 'mrow';
const lab = document.createElement('span');
lab.className = 'mlab';
lab.textContent = label;
const sel = document.createElement('select');
const none = document.createElement('option');
none.value = ''; none.textContent = 'None';
sel.appendChild(none);
for (const g of items) {{
const o = document.createElement('option');
o.value = g.id; o.textContent = g.label;
sel.appendChild(o);
}}
sel.value = worn.get(slot) || '';
sel.onchange = () => onWardrobeChange(slot, sel.value);
slotSelects[slot] = sel;
row.appendChild(lab); row.appendChild(sel);
clothingRows.appendChild(row);
}}
}})();
clothingRows.style.display = 'none';
// --- In-viewer animation picker: fetch any clip's compact preview by URL and
// swap the motion arrays without reloading the page when the joint count matches.
const animSel = document.getElementById('animation');
const animLabels = new Map((cfg.animations || []).map((a) => [a.id, a.label]));
// currentId may be a 'kata:<root>' selector (auto-play a kata); curId (hoisted
// above) stays a real clip id (or '' for a composite kata) for messages/label.
function postParent(payload) {{ try {{ parent.postMessage(payload, '*'); }} catch (e) {{}} }}
// The camera is framed ONCE per editing session (the first motion that loads), then
// left alone as the user browses stances / edits moves. `keepCamera` payloads ask to
// preserve the current view; a `camera-reframe` message re-arms the one-shot centering.
let _didInitialFrame = false;
function loadMotion(p) {{
// Skip clips with a different joint count rather than render them garbled.
if ((p.num_joints || 0) !== nJoints) {{
console.warn('skipping clip: ' + (p.num_joints || 0) + ' joints, viewer expects ' + nJoints);
return;
}}
jointData = decodeFloat32(p.joint_data_b64);
quatData = p.quat_data_b64 ? decodeFloat32(p.quat_data_b64) : null;
rootData = p.root_data_b64 ? decodeFloat32(p.root_data_b64) : null;
nFrames = p.preview_frames || 0;
fps = p.preview_fps || p.fps || 30;
scrub.max = Math.max(0, nFrames - 1);
frame = 0; elapsedFrames = 0; last = performance.now();
const _wasPos = camera.position.clone(), _wasTgt = controls.target.clone(); // previous view
frameCamera(); // near/far + root-path geometry
camera.position.copy(_wasPos); controls.target.copy(_wasTgt); // restore the prior view
if (p.keepCamera && _didInitialFrame) {{
// Keep the existing (already-framed) camera — just re-apply the drawer offset.
controls.update(); applyViewOffset();
}} else {{
centerFullBody(true); // one-shot full-body framing (accounts for the drawer)
_didInitialFrame = true;
}}
showFrame(0);
setCreatorLabel(p);
setReferencePose(p.reference_pose || null); // red target-pose skeleton (seeded clips)
setTargetPoses(p.target_frames || null); // red target skeletons at each kata seam
}}
// Katas first (play the whole stitched sequence), then single moves. Kata
// option values are prefixed 'kata:' and carry their spine path of clip ids.
const kataIds = new Map();
const kataMeta = new Map();
for (const k of (cfg.katas || [])) {{ kataIds.set(k.root, k.ids || []); kataMeta.set(k.root, {{ label: k.label || '', contributor: k.contributor || '' }}); }}
// (Re)build the picker options, optionally filtered by a query that matches
// either the clip id or the visible label (case-insensitive). Native <select>
// can't hide <option>s reliably, so we rebuild the list on each keystroke.
function renderAnimOptions(q) {{
const query = (q || '').trim().toLowerCase();
const match = (id, label) => !query
|| (id || '').toLowerCase().includes(query)
|| (label || '').toLowerCase().includes(query);
const prev = animSel.value;
animSel.textContent = '';
let shown = 0;
const katas = (cfg.katas || []).filter(k => match('kata:' + k.root, k.label));
if (katas.length) {{
const kg = document.createElement('optgroup');
kg.label = '▶ Katas (full sequence)';
for (const k of katas) {{
const opt = document.createElement('option');
opt.value = 'kata:' + k.root;
opt.textContent = k.label + ' · ' + k.count + ' moves';
kg.appendChild(opt); shown++;
}}
animSel.appendChild(kg);
}}
const anims = (cfg.animations || []).filter(a => match(a.id, a.label));
if (anims.length) {{
const ag = document.createElement('optgroup');
ag.label = 'Single moves';
for (const a of anims) {{
const opt = document.createElement('option');
// Lead with a short id chunk (the 6 hex chars after 'kmd_') so a picker
// entry can be matched to a specific clip id; full id shows in the label.
const shortId = (a.id || '').replace(/^kmd_/, '').slice(0, 6);
opt.value = a.id; opt.textContent = (shortId ? shortId + ' · ' : '') + a.label;
ag.appendChild(opt); shown++;
}}
animSel.appendChild(ag);
}}
if (!shown) {{
const opt = document.createElement('option');
opt.value = ''; opt.disabled = true; opt.textContent = 'no matches';
animSel.appendChild(opt);
}}
// Keep the current selection if it's still visible after filtering.
if (prev && [...animSel.options].some(o => o.value === prev)) animSel.value = prev;
}}
renderAnimOptions('');
const animFilter = document.getElementById('anim-filter');
if (animFilter) animFilter.addEventListener('input', () => renderAnimOptions(animFilter.value));
if (cfg.currentId) animSel.value = cfg.currentId;
// Default-to-kata: if the picker is pointed at a kata, auto-play the whole
// stitched sequence once the viewer has booted.
if (animSel.value && animSel.value.indexOf('kata:') === 0) {{
setTimeout(() => {{ if (animSel.value.indexOf('kata:') === 0) animSel.onchange(); }}, 60);
}}
animSel.onchange = async () => {{
const id = animSel.value;
if (!id) return;
if (id.startsWith('kata:')) {{
// Whole kata: the parent drawer stitches the path client-side and posts
// the result back as a 'load-payload'. Just request it here.
const root = id.slice(5);
const ids = kataIds.get(root) || [];
const km = kataMeta.get(root) || {{}};
if (ids.length) {{ showLoading(); postParent({{ kind: 'play-kata', ids, root, label: km.label || animSel.options[animSel.selectedIndex].textContent, contributor: km.contributor || '' }}); }}
return;
}}
if (!cfg.previewBase) return;
showLoading();
try {{
const r = await fetch(`${{cfg.previewBase}}/${{encodeURIComponent(id)}}.json`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const payload = await r.json();
if (!payload.prompt) payload.prompt = animLabels.get(id) || '';
// Set the id BEFORE rendering so the on-screen label shows this clip's id
// (not the previously-loaded one).
payload.id = payload.id || id;
curId = id;
loadMotion(payload);
// In-place clip switch (same iframe): only the highlight needs updating.
// NOT 'kimodo-preview-ready' -- that would re-trigger clothing/actions work.
postParent({{ kind: 'kimodo-clip-changed', id: curId }});
}} catch (err) {{
console.error('animation load failed', err);
}} finally {{
hideLoading();
}}
}};
// Model + clothing are chosen in the page-level controls, which post
// {{kind:'model'|'clothing', ...}} to drive the hidden controls' apply logic.
window.addEventListener('message', (e) => {{
const m = e.data || {{}};
if (m.kind === 'model') {{
const nextModel = [...characterSel.options].some(o => o.value === m.value) ? m.value : 'skeleton';
characterSel.value = nextModel || 'skeleton';
characterSel.onchange();
}} else if (m.kind === 'clothing') {{
const sel = slotSelects[m.slot];
if (sel) sel.value = m.value || '';
onWardrobeChange(m.slot, m.value || '');
}} else if (m.kind === 'load-animation' && m.id) {{
// Actions drawer asked to play a specific clip: reuse the picker path.
animSel.value = m.id;
animSel.onchange();
}} else if (m.kind === 'load-payload' && m.payload) {{
// Kata drawer / picker stitched a path client-side: play the arrays directly.
curId = m.payload.id || '';
loadMotion(m.payload);
hideLoading();
postParent({{ kind: 'kimodo-clip-changed', id: curId }});
}} else if (m.kind === 'viewport-inset') {{
// A left drawer is covering the left `left` px of the canvas — shift the
// rendered scene right so the character stays centered in the visible area.
viewportInsetLeft = Math.max(0, Number(m.left) || 0);
applyViewOffset();
}} else if (m.kind === 'dojo-scene') {{
const scenePayload = m.scene || {{}};
const img = m.image || scenePayload.image || scenePayload.panorama_image || '';
const splat = m.splat || scenePayload.splat || scenePayload.splat_url || '';
const settings = {{ room_size: scenePayload.room_size, room_height: scenePayload.room_height }};
const renderMode = scenePayload.render_mode || scenePayload.mode || (splat ? 'splat' : 'panorama');
if (renderMode === 'splat' && splat) {{
if (scenePayload.use_panorama_skybox && img) applyDojoScene(img, settings);
else clearDojoScene();
applyDojoSplat(splat, settings);
}} else if (img) {{
clearDojoSplat();
applyDojoScene(img, settings);
}}
}} else if (m.kind === 'dojo-room-settings') {{
applyDojoRoomSettings(m.settings || {{}});
}} else if (m.kind === 'dojo-clear') {{
clearDojoScene();
clearDojoSplat();
}} else if (m.kind === 'place-pose') {{
placeShow(m.posed, m.startPosed, m.parents, m.tx || 0, m.tz || 0, m.rot || 0, m.base, m.startVis, m.endVis);
}} else if (m.kind === 'place-vis') {{
// Grow/shrink the SKELETONS only; the gizmos (pose controls) stay put.
if (typeof m.start === 'boolean') {{ placeStartVis = m.start; placeSetVisible(placeStartGroup, m.start); }}
if (typeof m.end === 'boolean') {{ placeEndVis = m.end; placeSetVisible(placeEndSkel, m.end); }}
}} else if (m.kind === 'place-clear') {{
placeHide();
}} else if (m.kind === 'transport') {{
// Action card play-row drives the viewer's playback.
if (m.action === 'play' || (m.action === 'toggle' && !playing)) {{
playing = true; playBtn.textContent = 'Pause'; elapsedFrames = frame; last = performance.now();
}} else if (m.action === 'pause' || (m.action === 'toggle' && playing)) {{
playing = false; playBtn.textContent = 'Play';
}} else if (m.action === 'seek' && typeof m.frame === 'number') {{
playing = false; playBtn.textContent = 'Play'; elapsedFrames = m.frame; showFrame(m.frame);
}}
}} else if (m.kind === 'camera-lock') {{
// Parent center/lock button: center + frame the full body and follow, or free.
cameraLocked = !!m.on;
if (cameraLocked) centerFullBody(true);
updateCameraLockButton();
}} else if (m.kind === 'camera-reframe') {{
// Re-arm the one-shot framing so the NEXT motion load re-centers (new session).
_didInitialFrame = false;
}}
}});
playBtn.onclick = () => {{
playing = !playing;
playBtn.textContent = playing ? 'Pause' : 'Play';
elapsedFrames = frame;
last = performance.now();
}};
scrub.oninput = () => {{
playing = false;
playBtn.textContent = 'Play';
elapsedFrames = Number(scrub.value);
showFrame(elapsedFrames);
}};
// When a left drawer covers `viewportInsetLeft` px, offset the projection so the
// world center maps to the middle of the still-visible region (right of the
// drawer) — the character appears centered again. 0 = no drawer = clear offset.
let viewportInsetLeft = 0;
function applyViewOffset() {{
// Do NOT compensate for the sidebar — just center the character in the full canvas
// (looks fine with the bar shown or hidden, and avoids the off-center artifacts a
// projection shift introduced).
camera.clearViewOffset();
camera.updateProjectionMatrix();
}}
function resize() {{
const w = app.clientWidth || 1;
const h = app.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / h;
applyViewOffset(); // re-applies the drawer offset (or clears) + updateProjectionMatrix
}}
window.addEventListener('resize', resize);
resize();
frameCamera();
centerFullBody(false); // initial full-body framing (immediate, no fly)
// Decide the starting model BEFORE the first render so the skeleton never
// flashes when the citizen is the default: hide the skeleton up front and
// let the citizen appear once it's built (clothing streams in after).
const startCharacter = (quatData && nJoints === 22 && cfg.defaultCharacter && characters.some(c => c.id === cfg.defaultCharacter))
? cfg.defaultCharacter : 'skeleton';
if (startCharacter !== 'skeleton') {{
skel.visible = false;
characterSel.value = startCharacter;
characterSel.onchange();
}} else {{
showFrame(0);
}}
// Tell the parent the viewer is ready (with the clip id) so the Actions
// drawer can refresh + know which clip is showing.
postParent({{ kind: 'kimodo-preview-ready', id: curId }});
let lastPost = 0;
function tick(now) {{
if (disposed) return;
const dt = Math.min(0.1, (now - last) / 1000);
last = now;
if (playing && nFrames > 1) {{
elapsedFrames += dt * fps * Number(speed.value);
const target = Math.floor(elapsedFrames) % nFrames;
if (target !== frame) showFrame(target);
}}
// Stream playback state to the parent so a card's play-row can track it.
if (now - lastPost > 120) {{
lastPost = now;
postParent({{ kind: 'kimodo-frame', id: curId, frame, playing, num_frames: nFrames }});
}}
_camAnimTick(dt); // glide the camera into a new framing
_placeAnimTick(); // grow/shrink place skeletons
_dojoSplatGrowTick(dt); // grow the dojo splat in on first load
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(tick);
}}
requestAnimationFrame(tick);
// Gradio replaces this whole iframe on each (re)render. Without releasing the
// WebGL context the browser accumulates them and eventually blocks new ones
// ("Web page caused context loss and was blocked"). Stop the loop and free the
// GPU context the moment this iframe is torn down.
function teardownViewer() {{
if (disposed) return;
disposed = true;
try {{ controls.dispose(); }} catch (e) {{}}
try {{ renderer.forceContextLoss(); }} catch (e) {{}}
try {{ renderer.dispose(); }} catch (e) {{}}
}}
window.addEventListener('pagehide', teardownViewer);
window.addEventListener('beforeunload', teardownViewer);
</script>
</body>
</html>"""
return (
'<iframe title="Kimodo 3D motion preview" '
'style="width:100%;height:100%;border:0;background:#151719;" '
f'srcdoc="{html.escape(srcdoc, quote=True)}"></iframe>'
)
# --- Remote kimodo-motion-api backend (used when KIMODO_REMOTE_URL is set) ------
def _remote_post(path: str, body: dict, timeout: int = 180) -> dict:
"""POST JSON to the remote kimodo server and return the parsed response."""
req = urllib.request.Request(
KIMODO_REMOTE_URL + path,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def _record_from_remote(record_id, prompt, seconds, steps, seed, model_name, resp, extra=None) -> dict:
"""Build a store record from a remote /generate(_continue) response. The remote
returns the same arrays the store keeps (posed_joints / quats / root), so we
just relabel them, add the fixed SMPL-X parents, and keep the remote clip id."""
posed = np.asarray(resp["posed_joints"], dtype=np.float32)
bone_names = resp.get("bone_names", [])
record = {
"id": record_id,
"prompt": prompt,
"seconds": float(seconds),
"fps": float(resp.get("fps", 30.0)),
"num_frames": int(posed.shape[0]),
"model": model_name or DEFAULT_GENERATION_MODEL,
"seed": int(seed),
"denoising_steps": int(steps),
"generation_key": _normalized_generation_inputs(prompt, seconds, steps, seed, model_name),
"bone_names": bone_names,
"parents": _SMPLX22_PARENTS if len(bone_names) == 22 else [],
"local_quats_wxyz": resp["local_quats_wxyz"],
"global_quats_xyzw": resp["global_quats_xyzw"],
"root_positions": resp["root_positions"],
"posed_joints": resp["posed_joints"],
# The remote store's id for this clip — needed to continue from it later.
"remote_id": resp.get("id"),
}
if extra:
record.update(extra)
return record
def _save_remote_record(record_id, prompt, seconds, steps, seed, model_name, resp, creator, elapsed, extra=None):
"""Persist a remote-generated record + preview to the store; return (meta, None, preview)."""
record = _record_from_remote(record_id, prompt, seconds, steps, seed, model_name, resp, extra=extra)
if creator:
record.update(creator)
preview = _preview_payload_from_record(record)
preview["id"] = record_id
preview["prompt"] = prompt
if creator:
preview.update(creator)
store.save(record, preview, None)
meta = {
"id": record_id, "model": model_name, "prompt": prompt, "seconds": seconds,
"fps": record["fps"], "num_frames": record["num_frames"], "denoising_steps": steps,
"seed": seed, "device": "remote", "elapsed_seconds": round(elapsed, 2),
"joints": int(preview.get("num_joints", 0)), "cached": False,
}
if extra and extra.get("continues_from"):
meta["continues_from"] = extra["continues_from"]
if creator:
meta.update(creator)
return meta, None, preview
def _generate_uncached_remote(prompt, seconds, steps, seed, record_id, model_name=DEFAULT_GENERATION_MODEL, creator=None):
try:
t0 = time.time()
resp = _remote_post("/generate", {"prompt": prompt, "seconds": float(seconds), "num_steps": int(steps)})
return _save_remote_record(record_id, prompt, seconds, steps, seed, model_name, resp, creator or {}, time.time() - t0)
except Exception as exc:
return {"error_type": type(exc).__name__, "error": str(exc), "traceback": traceback.format_exc()}, None, None
def _generate_continue_uncached_remote(prompt, seconds, steps, seed, record_id, source_id, source_frame, src_record,
model_name=DEFAULT_GENERATION_MODEL, creator=None):
try:
remote_src = (src_record or {}).get("remote_id")
if not remote_src:
raise gr.Error("This move wasn't generated on the remote backend, so it can't be continued there. "
"Regenerate the source move first.")
t0 = time.time()
resp = _remote_post("/generate_continue", {
"prompt": prompt, "seconds": float(seconds), "num_steps": int(steps),
"source_id": remote_src, "source_frame": int(source_frame), "stitch": False,
})
extra = {"continues_from": {"source_id": source_id, "frame": int(source_frame)}}
return _save_remote_record(record_id, prompt, seconds, steps, seed, model_name, resp, creator or {}, time.time() - t0, extra=extra)
except Exception as exc:
if isinstance(exc, gr.Error):
raise
return {"error_type": type(exc).__name__, "error": str(exc), "traceback": traceback.format_exc()}, None, None
@GPU(duration=_duration)
def _generate_uncached(
prompt: str,
seconds: float,
steps: int,
seed: int,
record_id: str,
model_name: str = DEFAULT_GENERATION_MODEL,
creator: dict | None = None,
root_extra: dict | None = None,
):
try:
model_name = model_name or DEFAULT_GENERATION_MODEL
model = _ensure_model_cpu(model_name)
fps = float(model.motion_rep.fps)
num_frames = int(round(seconds * fps))
dev = "cuda:0" if torch.cuda.is_available() else "cpu"
torch.manual_seed(seed)
if dev.startswith("cuda"):
torch.cuda.manual_seed_all(seed)
try:
model.to(dev)
model.device = dev
t0 = time.time()
with torch.no_grad():
out = model(
[prompt],
num_frames,
steps,
post_processing=False,
progress_bar=_passthrough,
)
elapsed = time.time() - t0
npz_path = _write_npz(record_id, prompt, seed, fps, out)
preview_payload = _preview_payload(model, out, fps)
preview_payload["id"] = record_id
preview_payload["prompt"] = prompt
if creator:
preview_payload.update(creator)
# root_extra carries kata stamping (compose_recipe/owner/forked_from) for a
# NORMAL kata's first move — merged with the creator fields, like _generate_between.
extra = dict(creator or {})
if root_extra:
extra.update(root_extra)
record = _save_record(record_id, prompt, seconds, fps, steps, seed, model, out, model_name, extra=extra)
store.save(record, preview_payload, npz_path)
meta = {
"id": record["id"],
"model": model_name,
"prompt": prompt,
"seconds": seconds,
"fps": fps,
"num_frames": num_frames,
"denoising_steps": steps,
"seed": seed,
"device": dev,
"elapsed_seconds": round(elapsed, 2),
"joints": int(out["posed_joints"].shape[2]),
"cached": False,
}
if creator:
meta.update(creator)
# Return the payload (not rendered HTML) so the caller can render with
# the page-selected character/clothing + current animation list.
return meta, npz_path, preview_payload
finally:
model.to("cpu")
model.device = "cpu"
if dev.startswith("cuda"):
torch.cuda.empty_cache()
except Exception as exc:
return {
"error_type": type(exc).__name__,
"error": str(exc),
"traceback": traceback.format_exc(),
}, None, None
def _resolve_frame(rec: dict, frame_idx: int) -> int:
"""Clamp/normalize a (possibly negative) source frame to [0, T). Ported from
run_motion_api.py."""
total = int(rec.get("num_frames") or len(rec.get("posed_joints", [])))
f = int(frame_idx) if int(frame_idx) >= 0 else total + int(frame_idx)
if not 0 <= f < total:
raise gr.Error(f"source_frame {frame_idx} out of range for clip of {total} frames")
return f
def _continue_animation_id(source_id, frame, prompt, seconds, steps, seed, model_name):
"""Deterministic id for a continuation so re-requests hit the cache and never
collide with a from-scratch clip of the same prompt."""
payload = json.dumps(
{"continue_from": {"source_id": source_id, "frame": int(frame)},
**_normalized_generation_inputs(prompt, seconds, steps, seed, model_name)},
sort_keys=True, separators=(",", ":"),
)
return "kmd_" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def _build_start_constraint(model, src_record: dict, frame_idx: int, device):
"""Pin ONLY frame 0 of the new motion to a source clip's frame pose (re-rooted
to XZ origin) -> a continuation. Ported from run_motion_api._build_start_constraint,
with scipy's Rotation in place of viser.transforms (the Space has scipy, not viser)."""
from kimodo.constraints import FullBodyConstraintSet
if "posed_joints" not in src_record or "global_quats_xyzw" not in src_record:
raise gr.Error("source clip has no posed_joints/global_quats — regenerate it")
skeleton = model.motion_rep.skeleton
f = _resolve_frame(src_record, frame_idx)
joints = torch.tensor(np.asarray(src_record["posed_joints"][f], dtype=np.float32), device=device) # [J,3]
quats_xyzw = np.asarray(src_record["global_quats_xyzw"][f], dtype=np.float32) # [J,4]
rot_mats = Rotation.from_quat(quats_xyzw).as_matrix().astype(np.float32) # xyzw -> [J,3,3]
rots = torch.tensor(rot_mats, device=device)
root_idx = skeleton.root_idx
joints_at_origin = joints.clone()
joints_at_origin[:, 0] -= joints[root_idx, 0]
joints_at_origin[:, 2] -= joints[root_idx, 2]
return FullBodyConstraintSet(
skeleton=skeleton,
frame_indices=torch.tensor([0], device=device, dtype=torch.long),
global_joints_positions=joints_at_origin.unsqueeze(0), # [1, J, 3]
global_joints_rots=rots.unsqueeze(0), # [1, J, 3, 3]
)
@GPU(duration=_duration)
def _generate_continue_uncached(
prompt: str,
seconds: float,
steps: int,
seed: int,
record_id: str,
source_id: str,
source_frame: int,
src_record: dict,
model_name: str = DEFAULT_GENERATION_MODEL,
creator: dict | None = None,
root_extra: dict | None = None,
):
"""Generate a move that flows on from a frame of an existing clip (frame 0
pinned to that pose). Saves with continues_from so the kata tree links it."""
try:
model_name = model_name or DEFAULT_GENERATION_MODEL
model = _ensure_model_cpu(model_name)
fps = float(model.motion_rep.fps)
num_frames = int(round(seconds * fps))
dev = "cuda:0" if torch.cuda.is_available() else "cpu"
f = _resolve_frame(src_record, source_frame)
torch.manual_seed(seed)
if dev.startswith("cuda"):
torch.cuda.manual_seed_all(seed)
try:
model.to(dev)
model.device = dev
constraint = _build_start_constraint(model, src_record, f, dev)
t0 = time.time()
with torch.no_grad():
out = model(
[prompt],
num_frames,
steps,
constraint_lst=[[constraint]],
# The frame-0 pin is enforced by the constraint itself. We keep
# post_processing=False to match the working generate path: the
# post_processing=True branch needs the motion_correction package
# (not installed on this Space).
post_processing=False,
progress_bar=_passthrough,
)
elapsed = time.time() - t0
extra = {"continues_from": {"source_id": source_id, "frame": int(f)}}
if creator:
extra.update(creator)
# root_extra carries compose_recipe stamping for NORMAL kata continuation moves
# (so the chain's last move holds the full recipe), like _generate_between.
if root_extra:
extra.update(root_extra)
npz_path = _write_npz(record_id, prompt, seed, fps, out)
preview_payload = _preview_payload(model, out, fps)
preview_payload["id"] = record_id
preview_payload["prompt"] = prompt
if creator:
preview_payload.update(creator)
record = _save_record(record_id, prompt, seconds, fps, steps, seed, model, out, model_name, extra=extra)
store.save(record, preview_payload, npz_path)
meta = {
"id": record["id"], "model": model_name, "prompt": prompt, "seconds": seconds,
"fps": fps, "num_frames": num_frames, "denoising_steps": steps, "seed": seed,
"device": dev, "elapsed_seconds": round(elapsed, 2),
"joints": int(out["posed_joints"].shape[2]), "cached": False,
"continues_from": extra["continues_from"],
}
if creator:
meta.update(creator)
return meta, npz_path, preview_payload
finally:
model.to("cpu")
model.device = "cpu"
if dev.startswith("cuda"):
torch.cuda.empty_cache()
except Exception as exc:
if isinstance(exc, gr.Error):
raise
tb = traceback.format_exc()
print(f"[continue] FAILED: {type(exc).__name__}: {exc}\n{tb}", flush=True)
return {
"error_type": type(exc).__name__,
"error": str(exc),
"traceback": tb,
}, None, None
def _place_pose_np(posed, quats_xyzw, turn_deg: float, advance: float):
"""Rotate a canonical pose about Y by turn_deg + translate forward by `advance`
(in the turned facing). Mirrors run_motion_api._place_pose / the kata assembler.
Returns (positions [J,3], rotation matrices [J,3,3]) for a FullBody constraint."""
th = np.radians(float(turn_deg))
c, s = float(np.cos(th)), float(np.sin(th))
Ry = np.array([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]], dtype=np.float32)
P = (np.asarray(posed, np.float32) @ Ry.T).astype(np.float32)
P[:, 0] += np.sin(th) * float(advance) # forward = (sinθ, cosθ) in (x,z)
P[:, 2] += np.cos(th) * float(advance)
Rq = Rotation.from_quat(np.asarray(quats_xyzw, np.float32)).as_matrix().astype(np.float32) # [J,3,3]
Rnew = (Ry[None] @ Rq).astype(np.float32)
return P, Rnew
def _build_between_constraint(model, keyframes: list, num_frames: int, device):
"""Pin >=2 keyframe poses (start + optional apex(es) + end) of the new motion,
each placed by its own turn/advance. Multi-frame FullBodyConstraintSet — the
in-process twin of run_motion_api's /generate_between constraint."""
from kimodo.constraints import FullBodyConstraintSet
skeleton = model.motion_rep.skeleton
last = max(0, int(num_frames) - 1)
fis, pos, rots = [], [], []
for kf in keyframes:
if "posed_joints" not in kf or "global_quats_xyzw" not in kf:
raise gr.Error("keyframe missing posed_joints/global_quats_xyzw")
P, Rm = _place_pose_np(kf["posed_joints"], kf["global_quats_xyzw"],
kf.get("turn_deg", 0), kf.get("advance", 0))
frac = max(0.0, min(1.0, float(kf.get("frac", 0.0))))
fis.append(int(round(frac * last)))
pos.append(P)
rots.append(Rm)
return FullBodyConstraintSet(
skeleton=skeleton,
frame_indices=torch.tensor(fis, device=device, dtype=torch.long),
global_joints_positions=torch.tensor(np.stack(pos), device=device), # [K, J, 3]
global_joints_rots=torch.tensor(np.stack(rots), device=device), # [K, J, 3, 3]
)
@GPU(duration=_duration)
def _generate_between_uncached(
prompt: str,
seconds: float,
steps: int,
seed: int,
record_id: str,
keyframes: list,
continues_from_id: str | None = None,
model_name: str = DEFAULT_GENERATION_MODEL,
creator: dict | None = None,
root_extra: dict | None = None,
):
"""Generate one move pinned to >=2 keyframe poses (both ends → 0-drift seams).
The in-process twin of run_motion_api /generate_between. Saves with continues_from
so chained moves form a kata. NOTE: post_processing=False on this Space (the
motion_correction IK package isn't installed), so pins are guided, not IK-snapped."""
try:
model_name = model_name or DEFAULT_GENERATION_MODEL
model = _ensure_model_cpu(model_name)
fps = float(model.motion_rep.fps)
num_frames = int(round(seconds * fps))
dev = "cuda:0" if torch.cuda.is_available() else "cpu"
torch.manual_seed(seed)
if dev.startswith("cuda"):
torch.cuda.manual_seed_all(seed)
try:
model.to(dev)
model.device = dev
constraint = _build_between_constraint(model, keyframes, num_frames, dev)
t0 = time.time()
with torch.no_grad():
out = model(
[prompt], num_frames, steps,
constraint_lst=[[constraint]],
post_processing=False,
progress_bar=_passthrough,
)
elapsed = time.time() - t0
extra = {}
if continues_from_id:
extra["continues_from"] = {"source_id": continues_from_id, "frame": -1}
if creator:
extra.update(creator)
if root_extra: # recipe/owner — only the kata's ROOT move carries this
extra.update(root_extra)
npz_path = _write_npz(record_id, prompt, seed, fps, out)
preview_payload = _preview_payload(model, out, fps)
preview_payload["id"] = record_id
preview_payload["prompt"] = prompt
if creator:
preview_payload.update(creator)
record = _save_record(record_id, prompt, seconds, fps, steps, seed, model, out, model_name, extra=extra)
store.save(record, preview_payload, npz_path)
meta = {
"id": record["id"], "model": model_name, "prompt": prompt, "seconds": seconds,
"fps": fps, "num_frames": num_frames, "denoising_steps": steps, "seed": seed,
"device": dev, "elapsed_seconds": round(elapsed, 2),
"joints": int(out["posed_joints"].shape[2]), "cached": False,
"continues_from": extra.get("continues_from"),
}
if creator:
meta.update(creator)
return meta, npz_path, preview_payload, record
finally:
model.to("cpu")
model.device = "cpu"
if dev.startswith("cuda"):
torch.cuda.empty_cache()
except Exception as exc:
if isinstance(exc, gr.Error):
raise
tb = traceback.format_exc()
print(f"[between] FAILED: {type(exc).__name__}: {exc}\n{tb}", flush=True)
return {"error_type": type(exc).__name__, "error": str(exc), "traceback": tb}, None, None, None
def _pose_heading_np(posed_frame) -> float:
d = posed_frame[2] - posed_frame[1] # right_hip(2) - left_hip(1)
return float(np.arctan2(float(d[2]), float(-d[0])))
def _stitch_records(recs: list) -> dict:
"""Concatenate chained clips into one continuous motion (port of run_motion_api
stitch_path): rotate+translate each clip so its frame 0 lands on the running
world pose. Returns a record dict ready for _preview_payload_from_record."""
outG, outR, outP = [], [], []
alpha = Tx = Tz = 0.0
n = len(recs)
for idx, r in enumerate(recs):
G = np.asarray(r["global_quats_xyzw"], np.float32)
R = np.asarray(r["root_positions"], np.float32)
P = np.asarray(r["posed_joints"], np.float32)
if idx > 0:
beta = alpha - _pose_heading_np(P[0]) + np.radians(float(r.get("heading_offset", 0) or 0))
c, s = float(np.cos(beta)), float(np.sin(beta))
px, pz = float(R[0, 0]), float(R[0, 2])
R = R.copy(); P = P.copy()
R[:, 0], R[:, 2] = (R[:, 0] - px) * c + (R[:, 2] - pz) * s + Tx, -(R[:, 0] - px) * s + (R[:, 2] - pz) * c + Tz
P[..., 0], P[..., 2] = (P[..., 0] - px) * c + (P[..., 2] - pz) * s + Tx, -(P[..., 0] - px) * s + (P[..., 2] - pz) * c + Tz
qy, qw = float(np.sin(beta / 2.0)), float(np.cos(beta / 2.0))
gx, gy, gz, gw = G[..., 0], G[..., 1], G[..., 2], G[..., 3]
G = np.stack([qw * gx + qy * gz, qw * gy + qy * gw, qw * gz - qy * gx, qw * gw - qy * gy], axis=-1)
lo = 0 if idx == 0 else 1
hi = len(R)
if idx < n - 1:
cf = recs[idx + 1].get("continues_from")
bf = cf.get("frame") if cf else None
if bf is not None:
bf = int(bf if bf >= 0 else len(R) + bf)
if 0 <= bf < len(R):
hi = bf + 1
outG.append(G[lo:hi]); outR.append(R[lo:hi]); outP.append(P[lo:hi])
k = hi - 1
alpha = _pose_heading_np(P[k]); Tx = float(R[k, 0]); Tz = float(R[k, 2])
P = np.concatenate(outP, 0)
s0 = recs[0]
return {
"num_frames": int(P.shape[0]), "fps": float(s0.get("fps", 30.0)),
"bone_names": s0.get("bone_names", []), "parents": s0.get("parents", []),
"global_quats_xyzw": np.concatenate(outG, 0),
"root_positions": np.concatenate(outR, 0),
"posed_joints": P,
}
def _coerce_generation_inputs(prompt: str, seconds: float, steps: int, seed: int) -> tuple[str, float, int, int]:
prompt = " ".join((prompt or "").strip().split())
if not prompt:
raise gr.Error("Prompt is required.")
seconds = max(0.5, min(10.0, float(seconds or 4.0)))
steps = max(2, min(100, int(steps or 20)))
if seed is None or int(seed) < 0:
seed = random.randint(0, MAX_SEED)
seed = int(seed)
return prompt, seconds, steps, seed
def _creator_from_profile(profile: gr.OAuthProfile | None = None) -> dict:
if profile is None:
return {}
nested_raw = getattr(profile, "profile", None) or {}
nested = nested_raw if isinstance(nested_raw, dict) else {}
if isinstance(profile, dict):
nested = {**profile, **nested}
data = {
**nested,
"preferred_username": getattr(profile, "username", None) or nested.get("preferred_username"),
"name": getattr(profile, "name", None) or nested.get("name"),
"picture": getattr(profile, "picture", None) or nested.get("picture"),
}
username = (
data.get("preferred_username")
or data.get("username")
or data.get("login")
or data.get("name")
)
if not username:
return {}
out = {"created_by": str(username)}
name = data.get("name") or data.get("fullname")
avatar = data.get("picture") or data.get("avatar_url") or data.get("avatar")
if name:
out["created_by_name"] = str(name)
if avatar:
out["created_by_avatar"] = str(avatar)
return out
def _generate_button_label(
anonymous_client_id: str | None = None,
profile: gr.OAuthProfile | None = None,
request: gr.Request | None = None,
) -> str:
"""Generate-button text carrying the live quota: generations left today (or
an active cooldown) and, when signed in, who you are -- so the login/quota
state is checkable without a separate component."""
creator = _creator_from_profile(profile)
bucket = bucket_for_request(creator, request, anonymous_client_id)
st = quota_store.status(bucket)
who = f" · @{creator['created_by']}" if creator.get("created_by") else ""
if getattr(st, "unlimited", False):
return f"Generate{who}"
if st.cooldown_remaining > 0:
tail = f"cooldown {_format_remaining(st.cooldown_remaining)}"
else:
tail = f"{st.remaining}/{st.limit} left today"
return f"Generate · {tail}{who}"
def _meta_subset(meta: dict) -> dict:
return {k: meta.get(k) for k in (
"id", "prompt", "seconds", "fps", "num_frames", "model", "seed", "denoising_steps",
"created_at", "created_by", "created_by_name"
)}
def _animation_list(limit: int = 200) -> list[dict]:
# [{id, label}] for the in-viewer animation picker (from the manifest — cheap).
return [{"id": v, "label": label} for label, v in _dropdown_choices(limit)]
def _kata_list(limit: int = 400, max_katas: int = 40) -> list[dict]:
"""Created katas = continues_from chains, so the viewer picker can play a whole
sequence. Returns [{root, label, count, ids:[spine path]}] newest-first. A kata
is any root (no continues_from) that has >= 1 continuation; the spine path
follows the end-frame continuation at each step (the main line)."""
metas = store.list(limit)
by_id = {m["id"]: m for m in metas if m.get("id")}
children: dict[str, list[str]] = {}
for m in metas:
src = (m.get("continues_from") or {}).get("source_id")
if src and src in by_id and m.get("id"):
children.setdefault(src, []).append(m["id"])
def branch_frame(cid: str) -> int:
f = (by_id[cid].get("continues_from") or {}).get("frame")
return -1 if f is None else int(f)
def descendants(root: str) -> set[str]:
out: set[str] = set()
stack = [root]
while stack:
x = stack.pop()
for c in children.get(x, []):
if c not in out:
out.add(c)
stack.append(c)
return out
out = []
for m in metas:
rid = m.get("id")
if not rid or (m.get("continues_from") or {}).get("source_id"):
continue # must be a root (no parent)
# A kata = a root that EITHER starts a chain OR was explicitly built/published as a
# kata (carries a compose_recipe/owner stamp) — the latter surfaces single-move katas.
if rid not in children and not (m.get("compose_recipe") or m.get("compose_owner")):
continue
# Spine path: at each node follow the latest-branch-frame child (end continuation).
path, cur, seen = [rid], rid, {rid}
while children.get(cur):
nxt = max(children[cur], key=branch_frame)
if nxt in seen:
break
path.append(nxt); seen.add(nxt); cur = nxt
prompt = (by_id[rid].get("prompt") or rid).strip()
rootm = by_id[rid]
# Attribution for the name overlay / featured pointer: prefer the root's signed-in
# name, then any spine move's name (older roots are anonymous → '').
contributor = (rootm.get("created_by_name") or rootm.get("created_by") or "").strip()
if not contributor:
for pid in path:
pm = by_id.get(pid) or {}
cand = (pm.get("created_by_name") or pm.get("created_by") or "").strip()
if cand:
contributor = cand
break
out.append({
"root": rid,
"label": (prompt[:34] + "…") if len(prompt) > 34 else prompt,
"count": len(descendants(rid)) + 1,
"ids": path,
"contributor": contributor,
})
out.sort(key=lambda k: by_id[k["root"]].get("created_at", 0), reverse=True)
return out[: max(0, int(max_katas))]
def _clothing_from(hat: str, glasses: str, jacket: str, trousers: str) -> dict:
pairs = {"head": hat, "face": glasses, "torso_over": jacket, "legs": trousers}
return {slot: gid for slot, gid in pairs.items() if gid}
def _clothing_from_json(value: str | dict | None) -> dict:
if isinstance(value, dict):
raw = value
else:
try:
raw = json.loads(value or "{}")
except Exception:
raw = {}
valid_slots = set(CLOTHING_SLOTS)
valid_ids = {g["id"] for g in CLOTHING_CATALOG}
return {
str(slot): str(gid)
for slot, gid in raw.items()
if slot in valid_slots and gid in valid_ids
}
def _file_data_url(path: str | Path) -> str:
p = Path(path)
mime = "image/webp" if p.suffix.lower() == ".webp" else (mimetypes.guess_type(str(p))[0] or "application/octet-stream")
return f"data:{mime};base64,{base64.b64encode(p.read_bytes()).decode('ascii')}"
def _pil_data_url(image) -> str:
buf = io.BytesIO()
image.save(buf, format="PNG")
return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"
def _result_path_or_url(value) -> str:
if isinstance(value, (str, os.PathLike)):
return str(value)
if isinstance(value, dict):
return str(value.get("path") or value.get("url") or value.get("name") or "")
return ""
def _result_data_url(value, fallback_mime: str = "application/octet-stream") -> str:
if isinstance(value, str) and (value.startswith("data:") or value.startswith("http://") or value.startswith("https://")):
return value
path = _result_path_or_url(value)
if path.startswith("data:") or path.startswith("http://") or path.startswith("https://"):
return path
if path and Path(path).exists():
p = Path(path)
mime = mimetypes.guess_type(str(p))[0] or fallback_mime
return f"data:{mime};base64,{base64.b64encode(p.read_bytes()).decode('ascii')}"
if hasattr(value, "save"):
return _pil_data_url(value)
return ""
def _default_scene_payload(reason: str = "") -> dict:
if not _DEFAULT_SCENE_PATH.exists():
return {
"ok": False,
"source": "none",
"error": reason or f"Missing fallback scene: {_DEFAULT_SCENE_PATH}",
"created_at": time.time(),
}
payload = {
"ok": True,
"source": "default",
"image": _file_data_url(_DEFAULT_SCENE_PATH),
"prompt": "Default dojo scene",
"seed": None,
"steps": None,
"created_at": time.time(),
}
if reason:
payload["warning"] = reason
return payload
def _coerce_dojo_inputs(payload_json: str | dict | None) -> tuple[str, int, int, str, float, float, bool]:
if isinstance(payload_json, dict):
raw = payload_json
else:
try:
raw = json.loads(payload_json or "{}")
except Exception:
raw = {}
prompt = str(raw.get("prompt") or "").strip()
seed = int(raw.get("seed", -1) if raw.get("seed", -1) not in ("", None) else -1)
steps = int(raw.get("steps", 28) if raw.get("steps", 28) not in ("", None) else 28)
mode = str(raw.get("mode") or "splat").strip().lower()
if mode not in ("splat", "panorama"):
mode = "splat"
room_size = float(raw.get("room_size", 6.5) if raw.get("room_size", 6.5) not in ("", None) else 6.5)
room_height = float(raw.get("room_height", 3.2) if raw.get("room_height", 3.2) not in ("", None) else 3.2)
use_panorama_skybox = bool(raw.get("use_panorama_skybox", False))
if seed < 0:
seed = random.randint(0, MAX_SEED)
steps = max(1, min(100, steps))
room_size = max(1.0, min(30.0, room_size))
room_height = max(1.0, min(12.0, room_height))
return prompt, seed, steps, mode, room_size, room_height, use_panorama_skybox
def _dojo_progress_item(item) -> dict:
out = {}
for key in ("index", "length", "unit", "progress", "desc"):
if isinstance(item, dict):
value = item.get(key)
else:
value = getattr(item, key, None)
if value is not None:
out[key] = value
return out
def _dojo_status_payload(job, started_at: float) -> dict:
status = job.status()
code = getattr(status, "code", "")
code_value = getattr(code, "value", str(code))
progress_items = getattr(status, "progress_data", None) or []
progress = [_dojo_progress_item(item) for item in progress_items]
payload = {
"job_status": code_value,
"elapsed": max(0.0, time.time() - started_at),
}
for key in ("eta", "rank", "queue_size", "success"):
value = getattr(status, key, None)
if value is not None:
payload[key] = value
if progress:
payload["progress"] = progress
desc = next((item.get("desc") for item in reversed(progress) if item.get("desc")), "")
if desc:
payload["progress_desc"] = desc
return payload
def _dojo_wait_for_job(job, base_payload: dict, poll_seconds: float = 1.0):
started_at = time.time()
last_signature = None
while not job.done():
update = _dojo_status_payload(job, started_at)
signature = json.dumps(update, sort_keys=True, default=str)
if signature != last_signature:
last_signature = signature
yield {
**base_payload,
**update,
"done": False,
}
time.sleep(poll_seconds)
yield {
**base_payload,
**_dojo_status_payload(job, started_at),
"done": False,
}
def generate_dojo_scene(payload_json: str | dict | None) -> str:
def emit(payload: dict) -> str:
payload.setdefault("created_at", time.time())
return json.dumps(payload, separators=(",", ":"))
prompt, seed, steps, mode, room_size, room_height, use_panorama_skybox = _coerce_dojo_inputs(payload_json)
base = {
"prompt": prompt,
"seed": seed,
"steps": steps,
"mode": mode,
"room_size": room_size,
"room_height": room_height,
"use_panorama_skybox": use_panorama_skybox,
}
if not prompt:
yield emit({**_default_scene_payload("Add a prompt to generate a scene."), "done": True, "stage": "fallback"})
return
if not KLEIN_SPACE or not TRIPOSPLAT_SPACE:
yield emit({**_default_scene_payload("Klein or TripoSplat Space is not configured."), "done": True, "stage": "fallback"})
return
try:
from gradio_client import Client, handle_file
token = _hf_token()
klein_client = Client(KLEIN_SPACE, token=token)
iso_prompt = (
f"{prompt}. Isometric diorama of the full room environment, floor and walls visible, "
"clean centered miniature scene, no people, no text, high detail."
)
pano_prompt = (
f"{prompt}. 360 degree equirectangular panorama, full room interior, seamless walls and floor, "
"camera at room center, no people, no text, high detail."
)
yield emit({
"ok": True,
"done": False,
"source": "klein-triposplat",
"stage": "flux-iso-start",
"status": "Generating isometric Flux image...",
"isometric_prompt": iso_prompt,
"panorama_prompt": pano_prompt,
**base,
})
iso_job = klein_client.submit(iso_prompt, seed, api_name="/generate")
for update in _dojo_wait_for_job(iso_job, {
"ok": True,
"source": "klein-triposplat",
"stage": "flux-iso-progress",
"status": "Generating isometric Flux image...",
"isometric_prompt": iso_prompt,
"panorama_prompt": pano_prompt,
**base,
}):
yield emit(update)
iso_result = iso_job.result()
iso_path = _result_path_or_url(iso_result)
if not iso_path:
raise RuntimeError("Klein returned an unsupported image payload.")
isometric_image = _result_data_url(iso_result, "image/png")
panorama_image = None # panorama flux disabled for now — iso → splat only
yield emit({
"ok": True,
"done": False,
"source": "klein-triposplat",
"stage": "flux-iso-done",
"status": "Isometric image ready. Converting to 3D...",
"source_image": isometric_image,
"isometric_image": isometric_image,
"isometric_prompt": iso_prompt,
**base,
})
yield emit({
"ok": True,
"done": False,
"source": "klein-triposplat",
"stage": "splat-start",
"status": "Converting to 3D (TripoSplat)...",
"source_image": isometric_image,
"isometric_image": isometric_image,
"isometric_prompt": iso_prompt,
**base,
})
tripo_client = Client(TRIPOSPLAT_SPACE, token=token)
splat_job = tripo_client.submit(
handle_file(iso_path),
seed,
max(8, min(40, steps)),
3.0,
65536,
"ply",
api_name="/generate",
)
for update in _dojo_wait_for_job(splat_job, {
"ok": True,
"source": "klein-triposplat",
"stage": "splat-progress",
"status": "Generating TripoSplat...",
"source_image": isometric_image,
"isometric_image": isometric_image,
"panorama_image": panorama_image,
"image": panorama_image,
"isometric_prompt": iso_prompt,
"panorama_prompt": pano_prompt,
**base,
}):
yield emit(update)
splat_result = splat_job.result()
if not isinstance(splat_result, (tuple, list)) or len(splat_result) < 2:
raise RuntimeError("TripoSplat returned an unsupported payload.")
prep_image, ply_file, _download_file, info = splat_result[:4]
isometric_image = _result_data_url(prep_image or iso_result, "image/png") or isometric_image
splat = _result_data_url(ply_file, "application/octet-stream")
if not splat:
raise RuntimeError("TripoSplat did not return a readable PLY.")
yield emit({
"ok": True,
"done": True,
"source": "klein-triposplat",
"stage": "splat-done",
"status": "TripoSplat ready. Loading viewer...",
"source_image": isometric_image,
"isometric_image": isometric_image,
"panorama_image": panorama_image,
"image": panorama_image,
"splat": splat,
"info": info,
"isometric_prompt": iso_prompt,
"panorama_prompt": pano_prompt,
**base,
})
return
except Exception as exc:
fallback_reason = "Klein/TripoSplat unavailable"
if DIT360_SPACE:
try:
from gradio_client import Client
result = Client(DIT360_SPACE, token=_hf_token()).predict(prompt, seed, steps, api_name="/infer")
image = _result_data_url(result, "image/png")
if image:
yield emit({
"ok": True,
"done": True,
"source": "dit360",
"stage": "fallback",
"image": image,
"warning": f"{fallback_reason}: {exc}",
**base,
})
return
except Exception:
pass
yield emit({**_default_scene_payload(f"{fallback_reason}: {exc}"), "done": True, "stage": "fallback"})
return
def _nim_text(system: str, user: str, max_tokens: int, temperature: float) -> str:
"""Non-streaming completion from NVIDIA NIM's OpenAI-compatible endpoint (hosted
Nemotron) — the fallback text model for the Tiny Aya Space. reasoning_budget=0 keeps the
reply clean (Nemotron defaults thinking ON); mirrors tiny-army's _nim_text_stream."""
import re
messages = []
if system and system.strip():
messages.append({"role": "system", "content": system.strip()})
messages.append({"role": "user", "content": (user or "").strip()})
payload = {
"model": _NIM_NEMOTRON_MODEL,
"messages": messages,
"max_tokens": int(max_tokens or 512),
"temperature": float(temperature if temperature is not None else 0.6),
"top_p": 0.95,
"stream": False,
"reasoning_budget": 0,
}
body = json.dumps(payload).encode()
req = urllib.request.Request(_NIM_TEXT_URL, data=body, method="POST", headers={
"Authorization": f"Bearer {NIM_KEY}", "Content-Type": "application/json",
})
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8"))
text = (data["choices"][0]["message"].get("content") or "").strip()
# Defensive: drop any <think>…</think> trace if reasoning leaked into the content.
return re.sub(r"^\s*<think>.*?</think>\s*", "", text, flags=re.I | re.S).strip()
def _aya_generate(system: str, user: str, max_tokens: int, temperature: float) -> str:
"""Generate text via the Tiny Aya Space, falling back to hosted Nemotron (NVIDIA NIM)
when the Space is unavailable (asleep / GPU quota / error / not configured). Returns the
raw model text. Raises if neither backend is available."""
if TINY_AYA_SPACE:
try:
from gradio_client import Client
out = Client(TINY_AYA_SPACE, token=_hf_token()).predict(
system, user, int(max_tokens), float(temperature), api_name="/generate")
text = (out or "").strip()
if text:
return text
except Exception:
if not NIM_KEY:
raise
# Tiny Aya returned nothing or errored — fall through to Nemotron if we have a key.
if NIM_KEY:
return _nim_text(system, user, max_tokens, temperature)
if not TINY_AYA_SPACE:
raise RuntimeError("no text model configured (set KIMODO_TINY_AYA_SPACE or NVIDIA_NIM_API_KEY)")
raise RuntimeError("Tiny Aya unavailable and no Nemotron fallback configured")
def improve_dojo_prompt(payload_json: str | dict | None) -> str:
"""Use tiny-aya to turn a few keywords (or nothing) into a full dojo scene prompt.
Returns JSON: {"ok": true, "prompt": "..."} or {"ok": false, "error": "..."}."""
raw = payload_json
if not isinstance(raw, dict):
try:
raw = json.loads(raw or "{}")
except Exception:
raw = {"words": str(payload_json or "")}
words = (raw.get("words") or raw.get("prompt") or "").strip()
system = (
"You write a single vivid text-to-image prompt for a 360-degree room used as a "
"martial-arts dojo backdrop. Reply with ONLY the prompt on one line: no preamble, "
"no quotes, under 55 words. Describe a full room interior (floor, walls, lighting) "
"and end with: full room visible, high detail, no people, no text."
)
user = (f"Turn these ideas into a dojo scene prompt: {words}" if words
else "Invent a creative, unexpected dojo scene prompt.")
# A fresh nonce + high temperature make every suggestion / reroll different even
# for identical input. The model is told not to echo it.
import random
nonce = random.randint(1, 1_000_000)
user += f"\n\nGive a fresh, different idea than before (random key {nonce}; never mention this key)."
if not TINY_AYA_SPACE and not NIM_KEY:
return json.dumps({"ok": False, "error": "tiny-aya space not configured"})
try:
out = _aya_generate(system, user, 220, 1.05)
text = (out or "").strip()
# Prefer the first substantial line (drops any stray preamble the model adds).
best = ""
for line in text.splitlines():
cand = line.strip().strip('"\'*`').lstrip("-•0123456789. ").strip('"\'*`').strip()
if len(cand) > len(best):
best = cand
text = " ".join((best or text).split()).strip('"\'*` ')[:500]
if not text:
return json.dumps({"ok": False, "error": "empty suggestion"})
return json.dumps({"ok": True, "prompt": text})
except Exception as exc:
return json.dumps({"ok": False, "error": str(exc)[:180]})
def _decode_data_url(durl: str | None) -> bytes | None:
import re
if not durl or not isinstance(durl, str):
return None
m = re.match(r"data:[^;]+;base64,(.*)$", durl, re.S)
if not m:
return None
try:
return base64.b64decode(m.group(1))
except Exception:
return None
def save_dojo(payload_json: str | dict | None, profile: "gr.OAuthProfile | None" = None) -> str:
"""Publish a generated dojo (iso image + splat) into the dataset, tagged with the
contributor, and return the hosted entry so the UI can add it to the user's dojos.
payload: {name, prompt, settings, iso(dataURL), splat(dataURL), anon_id?}."""
raw = payload_json
if not isinstance(raw, dict):
try:
raw = json.loads(raw or "{}")
except Exception:
return json.dumps({"ok": False, "error": "bad payload"})
iso = _decode_data_url(raw.get("iso"))
splat = _decode_data_url(raw.get("splat"))
if not iso or not splat:
return json.dumps({"ok": False, "error": "missing iso/splat data"})
name = (str(raw.get("name") or "Dojo").strip() or "Dojo")[:40]
prompt = str(raw.get("prompt") or "")[:600]
settings = raw.get("settings") if isinstance(raw.get("settings"), dict) else {}
settings = {"room_size": float(settings.get("room_size") or 6.5),
"room_height": float(settings.get("room_height") or 3.2)}
creator = _creator_from_profile(profile)
anon = str(raw.get("anon_id") or "")[:16]
contributor = creator.get("created_by_name") or creator.get("created_by") or (("guest-" + anon[:6]) if anon else "anonymous")
contributor_id = creator.get("created_by") or (("anon:" + anon) if anon else "anon")
import time as _time, hashlib
uid = "u" + hashlib.sha1((contributor_id + name + str(_time.time())).encode("utf-8")).hexdigest()[:10]
base = f"viewer/dojos/user/{uid}"
try:
from huggingface_hub import HfApi, hf_hub_download
api = HfApi(token=_hf_token())
repo = _DATASET_REPO
api.upload_file(path_or_fileobj=iso, path_in_repo=f"{base}/iso.png", repo_id=repo, repo_type="dataset")
api.upload_file(path_or_fileobj=splat, path_in_repo=f"{base}/scene.ply", repo_id=repo, repo_type="dataset")
entry = {"id": uid, "name": name, "prompt": prompt, "mode": "splat", "settings": settings,
"iso": f"{base}/iso.png", "splat": f"{base}/scene.ply",
"contributor": contributor, "contributor_id": contributor_id, "ts": int(_time.time())}
try:
path = hf_hub_download(repo_id=repo, repo_type="dataset", filename="viewer/dojos/contributed.json")
existing = json.loads(open(path, encoding="utf-8").read())
if not isinstance(existing, list):
existing = []
except Exception:
existing = []
existing = [e for e in existing if isinstance(e, dict) and e.get("id") != uid]
existing.insert(0, entry)
api.upload_file(path_or_fileobj=json.dumps(existing[:200], indent=2).encode("utf-8"),
path_in_repo="viewer/dojos/contributed.json", repo_id=repo, repo_type="dataset")
return json.dumps({"ok": True, "entry": entry})
except Exception as exc:
return json.dumps({"ok": False, "error": str(exc)[:180]})
def _remote_get_record(clip_id: str):
if not KIMODO_REMOTE_URL or not clip_id:
return None
try:
req = urllib.request.Request(KIMODO_REMOTE_URL + "/animations/" + clip_id, method="GET")
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
except Exception:
return None
def publish_kata(payload_json: str | dict | None, profile: "gr.OAuthProfile | None" = None) -> str:
"""LOCAL-ONLY kata publisher: push a completed local kata into the shared dataset so
it shows up in the Community tab. Locally, a kata's move clips live on the remote
workstation and the recipe/spine live in localStorage — nothing reaches the dataset.
This fetches each move clip from the remote, assigns FRESH dataset ids (so it never
overwrites an existing record — forked katas reuse the original clip ids), stamps
continues_from chaining (frame -1 → end-to-end stitch, exactly like the Space's
chained katas) and compose_recipe/owner/fork attribution on the root, then saves them
so katasFromManifest reconstructs the spine. The button that drives this only renders
when KIMODO_REMOTE_URL is set (local dev), so it never appears on the Space. Pure addition."""
raw = payload_json
if not isinstance(raw, dict):
try:
raw = json.loads(raw or "{}")
except Exception:
return json.dumps({"ok": False, "error": "bad payload"})
name = str(raw.get("name") or "").strip()[:60]
ids = [str(x).strip() for x in (raw.get("ids") or []) if str(x).strip()]
if not ids:
return json.dumps({"ok": False, "error": "this kata has no generated moves to publish"})
recipe = raw.get("recipe") if isinstance(raw.get("recipe"), list) else None
# Fetch every move clip — prefer the remote (that's where local builds store them);
# fall back to the dataset store (a forked/community clip already lives there).
sources = []
for cid in ids:
rec = _remote_get_record(cid)
if not rec or "posed_joints" not in rec:
try:
rec = store.get(cid)
except Exception:
rec = None
if not rec or "posed_joints" not in rec or "global_quats_xyzw" not in rec:
return json.dumps({"ok": False, "error": "move clip not found: " + cid})
sources.append(rec)
creator = _creator_from_profile(profile)
if not creator.get("created_by"):
anon = str(raw.get("anon_id") or "")[:12]
creator = {"created_by": ("guest-" + anon[:6]) if anon else "anonymous"}
owner = str(raw.get("owner") or "").strip()[:64] or creator.get("created_by") or "anon"
root_extra = {"compose_owner": owner}
if recipe:
root_extra["compose_recipe"] = recipe
ff = raw.get("forked_from")
if isinstance(ff, dict) and (ff.get("root") or ff.get("contributor")):
root_extra["compose_forked_from"] = {
"root": str(ff.get("root") or "")[:64],
"contributor": str(ff.get("contributor") or "")[:64],
"contributor_name": str(ff.get("contributor_name") or "")[:80],
}
# Recipe entries reference STANCE ids, not clip ids, so renaming the clip ids is safe;
# only continues_from chaining uses them, and we rebuild that against the new ids here.
new_ids = ["kmd_%016x" % random.getrandbits(64) for _ in ids]
try:
for i, rec in enumerate(sources):
r = dict(rec)
r["id"] = new_ids[i]
r.pop("created_at", None)
if i == 0:
r.pop("continues_from", None)
r.update(root_extra)
if name:
r["prompt"] = name
else:
r["continues_from"] = {"source_id": new_ids[i - 1], "frame": -1}
for k, v in creator.items():
r.setdefault(k, v)
prev = _preview_payload_from_record(r)
prev["id"] = new_ids[i]
prev.update(creator)
store.save(r, prev, None)
except Exception as exc:
return json.dumps({"ok": False, "error": "save failed: " + str(exc)[:140]})
return json.dumps({"ok": True, "root": new_ids[0], "ids": new_ids, "name": name, "moves": len(new_ids)})
def _featured() -> dict:
"""Read the featured-kata pointer from the dataset: {root, name, contributor, set_by, ts}.
Returns {} when none is set (or the file is missing). Cheap-ish; cached per process so
start-up renders don't re-download it every request."""
cached = getattr(_featured, "_cache", None)
if cached is not None:
return cached
out: dict = {}
try:
from huggingface_hub import hf_hub_download
path = hf_hub_download(repo_id=_DATASET_REPO, repo_type="dataset", filename=_FEATURED_PATH)
data = json.loads(open(path, encoding="utf-8").read())
if isinstance(data, dict) and data.get("root"):
out = {
"root": str(data.get("root") or ""),
"name": str(data.get("name") or ""),
"contributor": str(data.get("contributor") or ""),
"set_by": str(data.get("set_by") or ""),
"ts": int(data.get("ts") or 0),
}
except Exception:
out = {}
_featured._cache = out # type: ignore[attr-defined]
return out
def set_featured(payload_json: str | dict | None, profile: "gr.OAuthProfile | None" = None) -> str:
"""OWNER-ONLY: pin a kata as the start-up (featured) one. Writes viewer/featured.json
{root, name, contributor, set_by, ts} into the dataset. Only HF usernames in
OWNER_USERNAMES may do this; everyone else is rejected. payload: {root, name?, contributor?}."""
raw = payload_json
if not isinstance(raw, dict):
try:
raw = json.loads(raw or "{}")
except Exception:
return json.dumps({"ok": False, "error": "bad payload"})
creator = _creator_from_profile(profile)
username = str(creator.get("created_by") or "").strip().lower()
if not username or username not in OWNER_USERNAMES:
return json.dumps({"ok": False, "error": "only the space owner can set the featured kata"})
root = str(raw.get("root") or "").strip()
if not root:
return json.dumps({"ok": False, "error": "missing kata root"})
# Confirm the root really is a kata in the dataset (don't pin a dangling id).
name = str(raw.get("name") or "").strip()[:60]
contributor = str(raw.get("contributor") or "").strip()[:80]
try:
for k in _kata_list():
if k.get("root") == root:
name = name or str(k.get("label") or "")
contributor = contributor or str(k.get("contributor") or "")
break
else:
return json.dumps({"ok": False, "error": "that kata is not in the shared library"})
except Exception:
pass
import time as _time
entry = {"root": root, "name": name, "contributor": contributor,
"set_by": creator.get("created_by") or "", "ts": int(_time.time())}
try:
from huggingface_hub import HfApi
api = HfApi(token=_hf_token())
api.upload_file(path_or_fileobj=json.dumps(entry, indent=2).encode("utf-8"),
path_in_repo=_FEATURED_PATH, repo_id=_DATASET_REPO, repo_type="dataset")
except Exception as exc:
return json.dumps({"ok": False, "error": str(exc)[:180]})
_featured._cache = entry # type: ignore[attr-defined]
return json.dumps({"ok": True, "featured": entry})
def delete_kata(payload_json: str | dict | None, profile: "gr.OAuthProfile | None" = None) -> str:
"""OWNER-ONLY: permanently delete a community move/kata (its root + spine animations) from
the dataset store. Only HF usernames in OWNER_USERNAMES may do this. payload: {root, ids?}."""
raw = payload_json
if not isinstance(raw, dict):
try:
raw = json.loads(raw or "{}")
except Exception:
return json.dumps({"ok": False, "error": "bad payload"})
creator = _creator_from_profile(profile)
username = str(creator.get("created_by") or "").strip().lower()
if not username or username not in OWNER_USERNAMES:
return json.dumps({"ok": False, "error": "only the space owner can delete moves"})
root = str(raw.get("root") or "").strip()
targets: list[str] = []
if root:
targets.append(root)
for i in (raw.get("ids") or []):
i = str(i or "").strip()
if i and i not in targets:
targets.append(i)
if not targets:
return json.dumps({"ok": False, "error": "missing move id"})
deleted = []
for t in targets:
try:
if store.delete(t):
deleted.append(t)
except Exception: # noqa: BLE001 — best-effort per id
pass
# If the featured kata was just deleted, drop the cached pointer so start-up doesn't pin a dead id.
if root and getattr(_featured, "_cache", None) and (_featured._cache or {}).get("root") == root: # type: ignore[attr-defined]
_featured._cache = None # type: ignore[attr-defined]
return json.dumps({"ok": bool(deleted), "deleted": deleted})
def _votes() -> dict:
"""Read community upvotes from the dataset: { root_id: [voter_key, ...] }. Returns {} on
miss. Process-cached so start-up renders don't re-download it (mirrors _featured)."""
cached = getattr(_votes, "_cache", None)
if cached is not None:
return cached
out: dict = {}
try:
from huggingface_hub import hf_hub_download
path = hf_hub_download(repo_id=_DATASET_REPO, repo_type="dataset", filename=_VOTES_PATH)
data = json.loads(open(path, encoding="utf-8").read())
if isinstance(data, dict):
out = {str(k): [str(v) for v in (vs or [])] for k, vs in data.items() if isinstance(vs, list)}
except Exception:
out = {}
_votes._cache = out # type: ignore[attr-defined]
return out
def _vote_counts() -> dict:
"""{ root_id: count } for the start-up map the Community sort/badges read."""
return {root: len(voters) for root, voters in _votes().items() if voters}
def vote_kata(payload_json: str | dict | None, profile: "gr.OAuthProfile | None" = None) -> str:
"""Toggle the caller's upvote for a community kata and return the new count. Anyone may
vote: signed-in users are deduped by username, anonymous by their local anon id
("anon:<id>"). Writes viewer/votes.json { root: [voter_key, ...] }. payload: {root, anon_id?}.
NOTE: votes.json is a single dataset file with no locking — simultaneous votes can race on
the read-modify-write. Acceptable for this Space's traffic."""
raw = payload_json
if not isinstance(raw, dict):
try:
raw = json.loads(raw or "{}")
except Exception:
return json.dumps({"ok": False, "error": "bad payload"})
root = str(raw.get("root") or "").strip()
if not root:
return json.dumps({"ok": False, "error": "missing kata root"})
creator = _creator_from_profile(profile)
username = str(creator.get("created_by") or "").strip().lower()
anon = str(raw.get("anon_id") or "").strip()[:32]
voter_key = username or (("anon:" + anon) if anon else "")
if not voter_key:
return json.dumps({"ok": False, "error": "no identity to vote with"})
try:
from huggingface_hub import HfApi, hf_hub_download
try:
path = hf_hub_download(repo_id=_DATASET_REPO, repo_type="dataset", filename=_VOTES_PATH)
votes = json.loads(open(path, encoding="utf-8").read())
if not isinstance(votes, dict):
votes = {}
except Exception:
votes = {}
voters = [str(v) for v in (votes.get(root) or []) if isinstance(v, str)]
if voter_key in voters:
voters = [v for v in voters if v != voter_key]
voted = False
else:
voters.append(voter_key)
voted = True
if voters:
votes[root] = voters
else:
votes.pop(root, None)
api = HfApi(token=_hf_token())
api.upload_file(path_or_fileobj=json.dumps(votes, indent=2).encode("utf-8"),
path_in_repo=_VOTES_PATH, repo_id=_DATASET_REPO, repo_type="dataset")
except Exception as exc:
return json.dumps({"ok": False, "error": str(exc)[:180]})
# Keep the process cache coherent with what we just wrote.
votes_clean = {str(k): [str(x) for x in v] for k, v in votes.items() if v}
_votes._cache = votes_clean # type: ignore[attr-defined]
return json.dumps({"ok": True, "root": root, "count": len(voters), "voted": voted})
# --- Chat with Karate Wiener (WHO AM I tab) --------------------------------
# An LLM voices Wiener's persona (tiny-aya Space, with hosted Nemotron as fallback) and
# VoxCPM clones his voice from a
# reference wav. Both are remote ZeroGPU "sidecar" Spaces reached via gradio_client, so the
# same code runs locally and on the Space (mirrors tiny-army's _voxcpm_predict + kimodo's
# improve_dojo_prompt). HF_TOKEN must be set; the Spaces may cold-start (slow first reply).
WIENER_SYSTEM = (
"You ARE Karate Wiener: a bald, big-hearted sausage who is a karate master. You wear a "
"white gi and a blue belt, and you firmly (and wrongly) insist the blue belt is the "
"highest belt of all. You speak to the user as 'my student'. You are wise, calm, and "
"deadpan, with a streak of cheerful absurdity. Besides karate wisdom you offer "
"unsolicited 'spiritual and legal advice' — always confident, occasionally ridiculous, "
"never actual legal advice. Reply IN CHARACTER in 1-3 short sentences. Never mention you "
"are an AI or break character. End with 'Osu!' only when it fits."
)
def _wiener_voice_b64() -> str:
"""Base64 of Wiener's reference voice (from the dataset), cached for the process."""
cached = getattr(_wiener_voice_b64, "_cache", None)
if cached is not None:
return cached
out = ""
try:
from huggingface_hub import hf_hub_download
path = hf_hub_download(repo_id=_DATASET_REPO, repo_type="dataset", filename=_WIENER_VOICE_PATH)
out = base64.b64encode(open(path, "rb").read()).decode("ascii")
except Exception:
out = ""
_wiener_voice_b64._cache = out # type: ignore[attr-defined]
return out
def _wiener_reply(history: list, message: str) -> str:
"""Generate Wiener's in-character reply via the tiny-aya Space (same client call as
improve_dojo_prompt), falling back to hosted Nemotron when tiny-aya is unavailable.
Folds recent history into a compact transcript."""
if not TINY_AYA_SPACE and not NIM_KEY:
return ""
lines = []
for turn in (history or [])[-8:]:
if not isinstance(turn, dict):
continue
role = "Student" if turn.get("role") == "user" else "Karate Wiener"
txt = str(turn.get("content") or "").strip()
if txt:
lines.append(f"{role}: {txt}")
lines.append("Student: " + str(message or "").strip())
lines.append("Karate Wiener:")
user = "\n".join(lines)
import re
out = _aya_generate(WIENER_SYSTEM, user, 200, 0.9)
text = (out or "").strip()
# Drop any echoed "Karate Wiener:" prefix and collapse whitespace.
text = re.sub(r"^\s*(karate\s+)?wiener\s*:\s*", "", text, flags=re.I)
return " ".join(text.split())[:600]
def _wiener_tts(text: str) -> str:
"""Clone Wiener's voice over `text` via the VoxCPM Space → a data:audio/wav URL.
Best-effort: returns '' on any failure (the chat still shows the text). Retries a few
times on transient ZeroGPU errors (mirrors tiny-army's _voxcpm_predict)."""
text = (text or "").strip()
ref = _wiener_voice_b64()
if not text or not ref or not VOXCPM_SPACE:
return ""
import time as _time
try:
from gradio_client import Client
client = Client(VOXCPM_SPACE, token=_hf_token())
except Exception:
return ""
last = None
for attempt in range(3):
try:
out = client.predict(text, ref, "", "", api_name="/clone")
path = out[0] if isinstance(out, (tuple, list)) else out
data = open(os.fspath(path), "rb").read()
return "data:audio/wav;base64," + base64.b64encode(data).decode("ascii")
except Exception as exc:
last = exc
msg = str(exc).lower()
if attempt == 2 or not any(s in msg for s in ("accelerator", "queue", "gpu", "timeout", "temporarily")):
break
_time.sleep(1.5 * (attempt + 1))
return ""
def wiener_chat(payload_json: str | dict | None) -> str:
"""One chat turn with Karate Wiener: generate his (text) reply via the tiny-aya Space.
payload: {history:[{role,content}], message}. Returns {ok, reply}. Voice is generated
separately + on demand via wiener_speak (a 🔊 button per message), so the text is fast."""
raw = payload_json
if not isinstance(raw, dict):
try:
raw = json.loads(raw or "{}")
except Exception:
return json.dumps({"ok": False, "error": "bad payload"})
message = str(raw.get("message") or "").strip()
history = raw.get("history") if isinstance(raw.get("history"), list) else []
if not message:
return json.dumps({"ok": False, "error": "say something to the master first"})
try:
reply = _wiener_reply(history, message)
except Exception as exc:
return json.dumps({"ok": False, "error": "the master is meditating — try again (" + str(exc)[:120] + ")"})
if not reply:
reply = "Patience, my student. The path is quiet right now — ask me again. Osu!"
return json.dumps({"ok": True, "reply": reply})
def wiener_speak(payload_json: str | dict | None) -> str:
"""On-demand TTS for one message (the 🔊 speak button): clone Wiener's voice over the
given text via VoxCPM. payload: {text}. Returns {ok, audio} (data:audio/wav URL)."""
raw = payload_json
if not isinstance(raw, dict):
try:
raw = json.loads(raw or "{}")
except Exception:
return json.dumps({"ok": False, "error": "bad payload"})
text = str(raw.get("text") or "").strip()
if not text:
return json.dumps({"ok": False, "error": "nothing to speak"})
try:
audio = _wiener_tts(text)
except Exception as exc:
return json.dumps({"ok": False, "error": str(exc)[:160]})
if not audio:
return json.dumps({"ok": False, "error": "the master's voice is resting — try again"})
return json.dumps({"ok": True, "audio": audio})
def _copy_creator_fields(dst: dict, src: dict | None) -> dict:
if not src:
return dst
for key in ("created_by", "created_by_name", "created_by_avatar"):
if src.get(key) and not dst.get(key):
dst[key] = src[key]
return dst
def _drawers_html() -> str:
"""Left-tab rail + the three slide-in drawers (Actions, Kata, Clothing).
Actions and Kata are empty scaffolds here; later phases fill their bodies."""
by_slot: dict[str, list[dict]] = {}
for item in CLOTHING_CATALOG:
by_slot.setdefault(item["slot"], []).append(item)
parts = [
'<div class="kimodo-drawer-root" id="kimodo-drawer-root">',
'<div class="kimodo-left-tabs" id="kimodo-left-tabs">',
'<button id="kimodo-actions-toggle" class="kimodo-left-tab" type="button" '
'data-drawer="kimodo-actions-drawer" title="Reusable action clips for building katas">ACTIONS</button>',
'<button id="kimodo-kata-toggle" class="kimodo-left-tab" type="button" '
'data-drawer="kimodo-kata-drawer" title="Build and play katas (move trees)">KATA</button>',
'<button id="kimodo-stances-toggle" class="kimodo-left-tab" type="button" '
'data-drawer="kimodo-stances-drawer" title="Library of captured pose stances">STANCES</button>',
'<button id="kimodo-compose-toggle" class="kimodo-left-tab" type="button" '
'data-drawer="kimodo-compose-drawer" title="Build your kata from a sequence of stances">MY KARATE</button>',
'<button id="kimodo-clothing-toggle" class="kimodo-left-tab" type="button" '
'data-drawer="kimodo-clothing-drawer" title="Add clothing to the citizen">CLOTHING</button>',
# Pluggable tabs drop a rail button here (tabs/<name>.tab.html).
_read_tabs("*.tab.html"),
'</div>',
# Actions library (clip cards populated in a later phase).
'<aside id="kimodo-actions-drawer" class="kimodo-drawer" aria-label="Actions library">',
'<h2>Actions library</h2>',
'<div class="kimodo-drawer-sub">Reusable moves for building katas. <b>Preview</b> plays one in '
'the viewer; <b>regenerate</b> makes a fresh take.</div>',
'<div id="kimodo-actions-list"></div>',
'</aside>',
# Kata move tree (d3 tree + transport populated in a later phase). Wider drawer.
'<aside id="kimodo-kata-drawer" class="kimodo-drawer kimodo-drawer--wide" aria-label="Kata move tree">',
'<h2>Kata move tree</h2>',
'<div class="kimodo-drawer-sub">Each node is a move clip. <b>Click a move</b> to play the path up '
'to it (<span style="color:#ffe14a">yellow</span>); <b>click again</b> to view just that move '
'(<span style="color:#4ea8ff">blue</span>). Use <b>+ kata</b> in the Actions drawer to start one.</div>',
'<div class="kimodo-kata-bar"><select id="kimodo-kata-select"></select></div>',
'<div id="kimodo-kata-status" class="kimodo-drawer-sub" style="margin:6px 0">loading…</div>',
'<svg id="kimodo-tree-svg"></svg>',
'<div class="kimodo-drawer-sub" style="margin-top:6px;font-size:10px">scroll = zoom · drag = pan · '
'click once = play path · click again = view move</div>',
'</aside>',
# Stance library (captured pose stances; populated from the dataset manifest).
'<aside id="kimodo-stances-drawer" class="kimodo-drawer" aria-label="Stance library">',
'<h2>Stance library</h2>',
'<div class="kimodo-drawer-sub">Captured pose <b>stances</b> — the building blocks of katas. '
'<b>Click one</b> to preview the pose in the viewer.</div>',
'<input id="kimodo-stance-filter" type="text" placeholder="filter by name or tag…" autocomplete="off" '
'style="width:100%;box-sizing:border-box;margin-bottom:8px;padding:5px 7px;font-size:12px;color:#e6eaef;'
'background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.18);border-radius:4px" />',
'<div id="kimodo-stances-status" class="kimodo-drawer-sub" style="margin:4px 0">loading…</div>',
'<div id="kimodo-stances-list"></div>',
'</aside>',
# Storyboard composer — a left side drawer (the controls live in the drawer,
# not a slide-up bar). Opening it shifts the viewer camera so the character
# stays centered in the visible area beside it.
'<aside id="kimodo-compose-drawer" class="kimodo-drawer" aria-label="My Karate">',
# The three views live in a sliding stack: selecting a kata slides the library
# left and the player in from the right (reversed on back).
'<div id="kimodo-cz-views" class="kimodo-cz-views">',
# Library view (home): Mine/Community filter + a "+ Create a kata" card on
# top of the list, then the saved-kata cards.
'<div id="kimodo-cz-library" class="kimodo-cz-view cz-active">',
# Anchored (sticky) browse header: pill + Create button (Mine) + search/sort
# (Community). renderComposeLibrary toggles which controls show per scope.
'<div class="kimodo-libhead">',
'<div class="kimodo-cz-seg kimodo-cz-seg--sub" id="kimodo-cz-scope">',
'<button type="button" class="kimodo-cz-segbtn active" data-scope="mine">Mine</button>',
'<button type="button" class="kimodo-cz-segbtn" data-scope="community">Community</button>',
'</div>',
# + Create a kata — Mine only.
'<button type="button" id="kimodo-cz-create" class="kimodo-cz-createbtn"><span style="font-size:16px">+</span> Create a kata</button>',
# Search + sort (Top by votes / Newest / Featured) — Community only.
'<div class="kimodo-cz-libctrls" id="kimodo-cz-libctrls">',
'<input id="kimodo-cz-cfilter" class="kimodo-libsearch kimodo-cz-cfilter" type="text" placeholder="🔍 search" autocomplete="off" />',
'<select id="kimodo-cz-csort" class="kimodo-cz-csort" title="Sort / filter">',
'<option value="top">▲ Top</option>',
'<option value="newest">Newest</option>',
'<option value="featured">★ Featured</option>',
'</select>',
'</div>',
'</div>', # /libhead
'<div id="kimodo-cz-katalist" class="kimodo-cz-katalist"></div>',
'</div>',
# Kata view (unified): tap a kata → view/edit it here with a scrubbable
# preview + per-move controls. Built entirely by renderComposePlayer().
'<div id="kimodo-cz-player" class="kimodo-cz-view cz-right"></div>',
'</div>', # /views
# Stance picker overlay (opened by "+ Add a stance").
'<div id="kimodo-cz-picker" class="kimodo-cz-picker" style="display:none">',
# Sticky header: title/close + the two-tap hint + the filter stay anchored at the
# top so they're visible while the stance grid scrolls underneath.
'<div class="kimodo-cz-pickhead">',
'<div class="kimodo-cz-phead">',
'<div class="kimodo-cz-ptitle" id="kimodo-cz-ptitle">Describe a move</div>',
'<button type="button" class="kimodo-cz-pback" id="kimodo-cz-pickerclose">✕</button>',
'</div>',
# Normal (text-prompt) vs Advanced (stance) move mode. Chosen while the kata is
# empty; locked (disabled) once the first move is added.
'<div class="kimodo-cz-seg kimodo-cz-seg--sub kimodo-cz-modeseg" id="kimodo-cz-mode">',
'<button type="button" class="kimodo-cz-segbtn active" data-mode="normal">Normal</button>',
'<button type="button" class="kimodo-cz-segbtn" data-mode="advanced">Advanced</button>',
'</div>',
'<div id="kimodo-cz-pickhint" class="kimodo-cz-pickhint"></div>',
'<input id="kimodo-cz-pfilter" class="kimodo-cz-pfilter" type="text" placeholder="Search stances by name…" autocomplete="off" />',
'</div>',
# Advanced body: the stance grid. Normal body: a prompt box + Add move.
'<div id="kimodo-compose-palette" class="kimodo-cz-palette"></div>',
'<div id="kimodo-cz-promptbody" class="kimodo-cz-promptbody" style="display:none">',
'<textarea id="kimodo-cz-pprompt" class="kimodo-cz-pprompt" rows="3" placeholder="Describe this move… e.g. steps forward and throws a punch"></textarea>',
'<button type="button" id="kimodo-cz-paddmove" class="kimodo-cz-paddmove">Add move</button>',
'</div>',
'</div>',
'<div id="kimodo-compose-status" class="kimodo-cz-statusline"></div>',
'</aside>',
# Selected-kata name overlay — slides into the upper-left of the 3D view.
'<div id="kimodo-cz-vname" class="kimodo-cz-vname"><span id="kimodo-cz-vname-text"></span>'
'<button type="button" id="kimodo-cz-vname-edit" class="kimodo-cz-vname-edit" title="rename">✎</button>'
'<span id="kimodo-cz-vname-by" class="kimodo-cz-vname-by"></span></div>',
# Clothing.
'<aside id="kimodo-clothing-drawer" class="kimodo-drawer" aria-label="Clothing drawer">',
'<h2>Clothing</h2>',
'<div class="kimodo-drawer-sub">Toggle one garment per slot. The viewer updates immediately, and Generate uses the same selection.</div>',
]
for slot, label in CLOTHING_SLOTS.items():
items = by_slot.get(slot, [])
if not items:
continue
parts.append(f'<div class="kimodo-slot-title">{html.escape(label)}</div>')
parts.append(
f'<button class="kimodo-drawer-option" type="button" data-slot="{html.escape(slot)}" data-id="">None</button>'
)
for item in items:
parts.append(
'<button class="kimodo-drawer-option" type="button" '
f'data-slot="{html.escape(slot)}" data-id="{html.escape(item["id"])}">'
f'{html.escape(item["label"])}</button>'
)
parts.append("</aside>")
# Pluggable tabs drop their slide-in <aside> panel here (tabs/<name>.drawer.html).
parts.append(_read_tabs("*.drawer.html"))
# Fixed bottom-of-page Build & Play — shown only while a sequence is storyboarded.
parts.append('<button id="kimodo-compose-launch" type="button">▶ Build &amp; Play</button>')
# Bottom-right HF account widget: 🤗 (sign in) when logged out; ☰ → right account
# drawer (avatar + sign out) when logged in. Driven by #current-user-info + #kimodo-login.
parts.append(
'<button id="kimodo-acct-btn" class="kimodo-acct-btn" type="button" title="Sign in with Hugging Face">🤗</button>'
'<aside id="kimodo-acct-drawer" class="kimodo-acct-drawer" aria-label="Account">'
'<div class="kimodo-acct-head">'
'<img id="kimodo-acct-avatar" class="kimodo-acct-avatar" alt="" />'
'<div class="kimodo-acct-id"><div id="kimodo-acct-name" class="kimodo-acct-name"></div>'
'<div id="kimodo-acct-user" class="kimodo-acct-user"></div></div>'
'<button id="kimodo-acct-close" class="kimodo-acct-close" type="button" title="Close">✕</button>'
'</div>'
'<button id="kimodo-acct-tutorial" class="kimodo-acct-tutorial" type="button">↻ Restart tutorial</button>'
'<button id="kimodo-acct-logout" class="kimodo-acct-logout" type="button">Sign out</button>'
'</aside>'
)
parts.append("</div>")
return "".join(parts)
DRAWER_HTML = _drawers_html()
# Actions-library logic, kept as a plain (non-f) string so its JS object/braces
# need no escaping. Interpolated into DRAWER_JS inside boot()'s scope, so it can
# use DATASET_BASE / previewFrame defined there. Functions are declarations
# (hoisted), so the earlier drawer manager can call renderActions/refreshClips.
_ACTIONS_JS = r"""
// Actions = the signed-in user's generated clips (anonymous: this browser's
// clips, tracked in localStorage). One card per clip, mirroring the kata web
// UI's .action-card. Templates/seeds are gone — these are real animations.
const actionsList = document.getElementById('kimodo-actions-list');
const KATA_ROOTS_KEY = 'kimodo-kata-roots-v1';
const MY_CLIPS_KEY = 'kimodo-my-clips-v1';
function lsGet(k, d) { try { const v = JSON.parse(localStorage.getItem(k)); return v == null ? d : v; } catch (e) { return d; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
let kataRoots = new Set(lsGet(KATA_ROOTS_KEY, []));
function saveKataRoots() { lsSet(KATA_ROOTS_KEY, [...kataRoots]); }
let myClips = new Set(lsGet(MY_CLIPS_KEY, []));
function saveMyClips() { lsSet(MY_CLIPS_KEY, [...myClips]); }
function currentUser() { const el = inputInside('current-user'); return el ? (el.value || '').trim() : ''; }
function deriveName(p) {
const s = (p || '').replace(/^(a martial artist|a person|the practitioner|the martial artist)\s+/i, '').trim();
const w = s.split(/\s+/).slice(0, 5).join(' ');
return (w.charAt(0).toUpperCase() + w.slice(1)).slice(0, 32) || 'Move';
}
let actions = []; // [{ id, prompt, seconds, created_by, name }]
let editingId = null; // action id whose prompt is being edited
let busyId = null; // action id currently regenerating
let previewingId = null; // clip id shown in the viewer
let pendingGen = false; // a generation was triggered from this browser
let adding = false; // the add form is generating
let addSeconds = 2.2; // add form's chosen duration
let durOpen = new Set(); // action ids whose options section is expanded
let scrubbing = false; // a card scrubber is being dragged
let aPlayBtn = null, aScrub = null, aLabel = null; // previewing card's live transport refs
async function fetchManifest() {
if (!DATASET_BASE) return [];
try {
const r = await fetch(DATASET_BASE + '/manifest.json', { cache: 'no-store' });
if (!r.ok) return [];
const recs = ((await r.json()) || {}).records || {};
const items = Object.entries(recs).map(([id, m]) => Object.assign({ id }, m));
items.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
return items;
} catch (err) { console.error('manifest fetch failed', err); return []; }
}
async function refreshClips() {
const items = await fetchManifest();
const user = currentUser();
actions = items
.filter((m) => {
if (m.continues_from && m.continues_from.source_id) return false; // standalone moves only
if (user) return m.created_by === user; // signed in: my attributed clips
return !m.created_by && myClips.has(m.id); // anon: this browser's clips
})
.map((m) => ({ id: m.id, prompt: m.prompt || '', seconds: Number(m.seconds) || 2.2,
created_by: m.created_by || '', name: deriveName(m.prompt) }));
}
function scrubFill(el) {
const max = Number(el.max) || 1, pct = (Number(el.value) / max) * 100;
el.style.background = 'linear-gradient(90deg, #5ad1ff ' + pct + '%, #34343c ' + pct + '%)';
}
function sendTransport(action, frameNum) {
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'transport', action, frame: frameNum }, '*');
}
function previewClip(id) {
if (!id) return;
previewingId = id;
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({ kind: 'load-animation', id }, '*');
renderActions();
}
// Drive the hidden logged-in generate path (quota + attribution intact) with a
// fresh random seed. Completion arrives via onPreviewReady().
function fireGenerate(prompt, seconds) {
const pin = document.querySelector('#regen-prompt textarea, #regen-prompt input');
const sin = document.querySelector('#regen-seconds textarea, #regen-seconds input');
const btn = document.querySelector('#regen-btn');
if (!pin || !sin || !btn) return false;
pin.value = prompt; pin.dispatchEvent(new Event('input', { bubbles: true }));
sin.value = String(seconds); sin.dispatchEvent(new Event('input', { bubbles: true }));
pendingGen = true;
setTimeout(() => btn.click(), 40);
return true;
}
function regenAction(act, promptOverride) {
if (busyId || adding) return;
busyId = act.id; renderActions();
if (!fireGenerate(promptOverride || act.prompt, Number(act.seconds) || 2.2)) { busyId = null; renderActions(); return; }
setTimeout(() => { if (busyId === act.id) { busyId = null; renderActions(); } }, 240000); // safety
}
function addAction(promptText, seconds) {
const p = (promptText || '').trim(); if (!p || adding || busyId) return;
adding = true; renderActions();
if (!fireGenerate(p, seconds || addSeconds)) { adding = false; renderActions(); return; }
setTimeout(() => { if (adding) { adding = false; renderActions(); } }, 240000); // safety
}
// The clip currently in the viewer + the frame it's paused/at, for branching.
function previewSource() {
if (!viewerState || !viewerState.id) return null;
return { id: viewerState.id, frame: Math.round(viewerState.frame || 0) };
}
// Kata authoring: branch a NEW move that starts from the frame currently shown
// in the viewer (any previewed clip), via the constrained generate_continue path.
function fireContinue(prompt, seconds, sourceId, frame) {
const pin = document.querySelector('#cont-prompt textarea, #cont-prompt input');
const sin = document.querySelector('#cont-seconds textarea, #cont-seconds input');
const src = document.querySelector('#cont-source-id textarea, #cont-source-id input');
const frm = document.querySelector('#cont-source-frame textarea, #cont-source-frame input');
const btn = document.querySelector('#cont-btn');
if (!pin || !sin || !src || !frm || !btn) return false;
pin.value = prompt; pin.dispatchEvent(new Event('input', { bubbles: true }));
sin.value = String(seconds); sin.dispatchEvent(new Event('input', { bubbles: true }));
src.value = sourceId; src.dispatchEvent(new Event('input', { bubbles: true }));
frm.value = String(frame); frm.dispatchEvent(new Event('input', { bubbles: true }));
pendingGen = true;
setTimeout(() => btn.click(), 40);
return true;
}
function continueAction(act) {
const s = previewSource(); if (!s || busyId || adding) return;
busyId = act.id; renderActions();
if (!fireContinue(act.prompt, Number(act.seconds) || 2.2, s.id, s.frame)) { busyId = null; renderActions(); return; }
setTimeout(() => { if (busyId === act.id) { busyId = null; renderActions(); } }, 240000);
}
function addContinue(promptText, seconds) {
const p = (promptText || '').trim(); const s = previewSource();
if (!p || !s || adding || busyId) return;
adding = true; renderActions();
if (!fireContinue(p, seconds || addSeconds, s.id, s.frame)) { adding = false; renderActions(); return; }
setTimeout(() => { if (adding) { adding = false; renderActions(); } }, 240000);
}
function deleteAction(act) {
const did = document.querySelector('#del-id textarea, #del-id input');
const dbtn = document.querySelector('#del-btn');
if (did && dbtn) {
did.value = act.id; did.dispatchEvent(new Event('input', { bubbles: true }));
setTimeout(() => dbtn.click(), 30);
}
actions = actions.filter((a) => a.id !== act.id); // optimistic
if (myClips.has(act.id)) { myClips.delete(act.id); saveMyClips(); }
if (previewingId === act.id) previewingId = null;
renderActions();
setTimeout(() => refreshClips().then(renderActions), 2500); // reconcile with server
}
function makeKataFromAction(act) {
if (kataRoots.has(act.id)) kataRoots.delete(act.id); else kataRoots.add(act.id);
saveKataRoots(); renderActions();
// Reflect the new/removed kata root in the tree if that drawer is open.
const kd = document.getElementById('kimodo-kata-drawer');
if (kd && kd.classList.contains('open') && typeof refreshKataTree === 'function') refreshKataTree();
}
const startEdit = (act) => { editingId = act.id; renderActions(); };
const cancelEdit = () => { editingId = null; renderActions(); };
function commitEdit(act, value) {
const v = (value || '').trim(); editingId = null;
if (v && v !== act.prompt) { act.prompt = v; act.name = deriveName(v); } // used by the next regenerate
renderActions();
}
// Build one card, faithfully mirroring kata.js renderDrawer's .action-card.
function renderCard(act) {
const has = !!act.id;
const busy = busyId === act.id;
const editing = editingId === act.id;
const playing = previewingId === act.id && !busy && !editing;
const secs = Number(act.seconds) || 2.2;
const card = document.createElement('div'); card.className = 'action-card' + (playing ? ' playing' : '');
const nm = document.createElement('div'); nm.className = 'nm';
nm.innerHTML = act.name + ' <span class="dur-badge">' + secs.toFixed(1) + 's</span>'
+ (playing ? ' <span class="badge">● previewing</span>'
: busy ? ' <span class="badge gen"><span class="spin">⟳</span> generating…</span>' : '');
card.appendChild(nm);
const durBadge = nm.querySelector('.dur-badge');
if (editing) {
const ta = document.createElement('textarea'); ta.className = 'pr-edit'; ta.rows = 3; ta.value = act.prompt;
ta.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEdit(act, ta.value); } else if (e.key === 'Escape') cancelEdit(); };
const bar = document.createElement('div'); bar.className = 'edit-bar';
const save = document.createElement('button'); save.textContent = '✓ save'; save.onclick = () => commitEdit(act, ta.value);
const cancel = document.createElement('button'); cancel.textContent = 'cancel'; cancel.onclick = cancelEdit;
bar.appendChild(save); bar.appendChild(cancel);
card.appendChild(ta); card.appendChild(bar);
setTimeout(() => { ta.focus(); ta.select(); }, 0);
return card;
}
// delete X — top-right corner
const x = document.createElement('button'); x.className = 'del-x'; x.textContent = '✕'; x.title = 'delete animation'; x.disabled = busy;
x.onclick = () => deleteAction(act); card.appendChild(x);
// prompt + inline edit
const pr = document.createElement('div'); pr.className = 'pr';
const txt = document.createElement('span'); txt.className = 'pr-txt'; txt.textContent = act.prompt;
const ed = document.createElement('button'); ed.className = 'pr-edit-btn'; ed.textContent = '✎'; ed.title = 'edit prompt'; ed.disabled = busy;
ed.onclick = () => startEdit(act);
pr.appendChild(txt); pr.appendChild(ed); card.appendChild(pr);
// preview, or — while previewing this card — a live media transport
if (playing) {
const max = Math.max(0, (viewerState.num_frames || 1) - 1);
const pp = document.createElement('button'); pp.className = 'pp'; pp.textContent = viewerState.playing ? '❚❚' : '▶';
pp.onclick = () => sendTransport('toggle');
const lbl = document.createElement('span'); lbl.className = 'frame-lbl'; lbl.textContent = Math.round(viewerState.frame || 0) + '/' + max;
const sc = document.createElement('input'); sc.type = 'range'; sc.className = 'play-scrub'; sc.min = 0; sc.max = max; sc.step = 1; sc.value = Math.round(viewerState.frame || 0);
sc.oninput = () => { scrubbing = true; const f = Number(sc.value); sendTransport('seek', f); pp.textContent = '▶'; lbl.textContent = f + '/' + max; scrubFill(sc); };
sc.onchange = () => { scrubbing = false; };
scrubFill(sc);
const prow = document.createElement('div'); prow.className = 'play-row';
prow.appendChild(pp); prow.appendChild(sc); prow.appendChild(lbl); card.appendChild(prow);
aPlayBtn = pp; aScrub = sc; aLabel = lbl;
} else {
const prev = document.createElement('button'); prev.className = 'prev-btn'; prev.disabled = !has || busy;
prev.textContent = busy ? 'generating…' : !has ? 'not generated' : '▶ preview';
prev.onclick = () => previewClip(act.id); card.appendChild(prev);
}
// collapsible options: regenerate(s) + duration + rotation
const open = durOpen.has(act.id);
const optRow = document.createElement('div'); optRow.className = 'row'; optRow.style.alignItems = 'center';
const tog = document.createElement('button'); tog.className = 'dur-tog'; tog.style.flex = '1';
tog.textContent = (open ? '▾' : '▸') + ' options · ' + secs.toFixed(1) + 's';
tog.onclick = () => { durOpen.has(act.id) ? durOpen.delete(act.id) : durOpen.add(act.id); renderActions(); };
const mk = document.createElement('button'); mk.className = 'kata-plus'; mk.disabled = !has || busy;
mk.textContent = (has && kataRoots.has(act.id)) ? '✓ kata' : '+ kata';
mk.title = has ? 'start a new kata with this move as the first step' : 'generate this action first';
mk.onclick = () => makeKataFromAction(act);
optRow.appendChild(tog); optRow.appendChild(mk); card.appendChild(optRow);
if (open) {
const rrow = document.createElement('div'); rrow.className = 'regen-row';
const reg = document.createElement('button'); reg.className = 'rg'; reg.disabled = busy;
reg.innerHTML = busy ? '<span class="spin">⟳</span>' : '↻ regenerate'; reg.title = 'regenerate from scratch (rest pose)';
reg.onclick = () => regenAction(act);
const src = previewSource();
const regf = document.createElement('button'); regf.className = 'rg'; regf.disabled = busy || !src;
regf.textContent = '↳ from preview frame';
regf.title = src ? ('start this move from frame ' + src.frame + ' of the previewed clip') : 'preview a clip and pause on a frame first';
regf.onclick = () => continueAction(act);
rrow.appendChild(reg); rrow.appendChild(regf); card.appendChild(rrow);
const dur = document.createElement('div'); dur.className = 'dur';
const dtag = document.createElement('span'); dtag.className = 'dur-tag'; dtag.textContent = '⏱';
const drange = document.createElement('input'); drange.type = 'range'; drange.min = '0.5'; drange.max = '10'; drange.step = '0.5'; drange.value = String(secs); drange.disabled = busy;
const dlbl = document.createElement('span'); dlbl.className = 'dur-lbl'; dlbl.textContent = secs.toFixed(1) + 's';
drange.oninput = () => { act.seconds = Number(drange.value); const t = act.seconds.toFixed(1) + 's'; dlbl.textContent = t; if (durBadge) durBadge.textContent = t; tog.textContent = '▾ options · ' + t; };
dur.appendChild(dtag); dur.appendChild(drange); dur.appendChild(dlbl); card.appendChild(dur);
// rotation row — disabled until Phase 4 (rotate_clip)
const rotRow = document.createElement('div'); rotRow.className = 'rot-row';
const rtag = document.createElement('span'); rtag.className = 'dur-tag'; rtag.textContent = '⟲';
const rrange = document.createElement('input'); rrange.type = 'range'; rrange.min = '-180'; rrange.max = '180'; rrange.step = '1'; rrange.value = '0'; rrange.disabled = true;
rrange.title = 'rotation comes with kata authoring (Phase 4)';
const rlbl = document.createElement('span'); rlbl.className = 'dur-lbl'; rlbl.textContent = '0°';
const rsave = document.createElement('button'); rsave.className = 'rot-save'; rsave.textContent = 'save'; rsave.disabled = true;
rotRow.appendChild(rtag); rotRow.appendChild(rrange); rotRow.appendChild(rlbl); rotRow.appendChild(rsave); card.appendChild(rotRow);
}
return card;
}
function renderActions() {
if (!actionsList) return;
actionsList.textContent = '';
// Add form: prompt + (Add & generate / from preview frame) + duration.
const form = document.createElement('div'); form.className = 'add-form';
const addTa = document.createElement('textarea'); addTa.className = 'add-input'; addTa.rows = 2;
addTa.placeholder = 'describe a new move, e.g. "a martial artist throws an elbow strike"';
addTa.disabled = adding;
const submitAdd = () => { const v = addTa.value.trim(); if (v && !adding) addAction(v, addSeconds); };
const submitAddFrame = () => { const v = addTa.value.trim(); if (v && !adding && previewSource()) addContinue(v, addSeconds); };
addTa.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitAdd(); } };
const arow = document.createElement('div'); arow.className = 'add-row';
const add = document.createElement('button'); add.className = 'add-action'; add.disabled = adding;
add.innerHTML = adding ? '<span class="spin">⟳</span> generating…' : '+ Add &amp; 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_JS = r"""
// --- 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.');
}
"""
# Stance library drawer: list the [STANCE]-tagged records from the dataset manifest;
# click one to preview the pose in the viewer. Reuses fetchManifest / fetchRecord /
# loadIntoViewer (defined in the actions/kata JS above; same shared scope).
_STANCES_JS = r"""
let STANCES = [];
let _stanceFilterWired = false;
const SMPLX22_BONES = ['pelvis','left_hip','right_hip','spine1','left_knee','right_knee','spine2',
'left_ankle','right_ankle','spine3','left_foot','right_foot','neck','left_collar','right_collar',
'head','left_shoulder','right_shoulder','left_elbow','right_elbow','left_wrist','right_wrist'];
function stanceName(m) { return m.stance_name || (m.prompt || '').replace(/^\[STANCE\]\s*/, '') || m.id; }
async function refreshStances() {
const status = document.getElementById('kimodo-stances-status');
const list = document.getElementById('kimodo-stances-list');
if (!list) return;
const fEl = document.getElementById('kimodo-stance-filter');
if (fEl && !_stanceFilterWired) { fEl.addEventListener('input', renderStances); _stanceFilterWired = true; }
if (status) status.textContent = 'loading…';
const items = await fetchManifest();
// The manifest only carries a meta subset (prompt is in it; is_stance/tags are
// not), so identify stances by their "[STANCE] …" prompt prefix.
STANCES = items.filter((m) => (m.prompt || '').startsWith('[STANCE]'));
if (status) status.textContent = STANCES.length ? (STANCES.length + ' stances') : 'no stances yet';
renderStances();
}
function renderStances() {
const list = document.getElementById('kimodo-stances-list');
const fEl = document.getElementById('kimodo-stance-filter');
if (!list) return;
const q = (fEl && fEl.value || '').trim().toLowerCase();
const match = (m) => !q || stanceName(m).toLowerCase().includes(q)
|| (m.stance_tags || []).join(' ').toLowerCase().includes(q);
list.innerHTML = '';
const shown = STANCES.filter(match);
if (!shown.length) { list.innerHTML = '<div class="kimodo-drawer-sub" style="color:#888">no matches</div>'; return; }
for (const m of shown) {
const card = document.createElement('button');
card.type = 'button'; card.className = 'kimodo-drawer-option'; card.style.cssText =
'display:block;width:100%;text-align:left;margin-bottom:6px;padding:7px 9px;line-height:1.3';
const tags = (m.stance_tags || []).map((t) => '<span style="color:#8fd3ff">#' + t + '</span>').join(' ');
card.innerHTML = '<div style="font-size:12px;font-weight:600;color:#e6eaef">' + stanceName(m) + '</div>'
+ '<div style="font-size:10px;margin-top:2px">' + tags + '</div>';
card.onclick = async () => {
try {
const rec = await fetchRecord(m.id);
// Stance records lack num_joints and have empty bone_names; the viewer
// skips clips whose num_joints != 22, so derive them (like stitchPath does).
if (!rec.num_joints) rec.num_joints = (rec.posed_joints && rec.posed_joints[0]) ? rec.posed_joints[0].length : 22;
if (!rec.bone_names || !rec.bone_names.length) rec.bone_names = SMPLX22_BONES;
loadIntoViewer(rec, m.id, stanceName(m));
} catch (e) { console.error('stance preview failed', e); }
};
list.appendChild(card);
}
}
"""
# Storyboard composer drawer: sequence stances into a kata. Each consecutive pair
# becomes one move via the backend's /generate_between (both ends pinned → 0-drift
# seams); the chain is stitched with /stitch_path and played in the viewer. Reuses
# fetchManifest / fetchRecord / loadIntoViewer / KIMODO_BACKEND (shared scope).
_COMPOSE_JS = r"""
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}]
let composeFilter = ''; // library search query (name + contributor)
let composeSort = 'top'; // 'top' (by upvotes) | 'newest' | 'featured' (show only featured)
let _czWatchingRoot = ''; // root of the kata currently loaded in the viewer (highlight its card)
// Upvotes: KIMODO_VOTES (root->count) is a start-up snapshot; voteKata keeps it + the
// card in sync. kimodoMyVotes is the set of roots THIS browser has voted (for the ▲ fill).
function loadMyVotes() { try { return new Set(JSON.parse(window.localStorage.getItem('kimodoMyVotes') || '[]')); } catch (e) { return new Set(); } }
function saveMyVotes(s) { try { window.localStorage.setItem('kimodoMyVotes', JSON.stringify([...s])); } catch (e) {} }
let _czMyVotes = loadMyVotes();
function voteCount(root) { return (root && KIMODO_VOTES[root]) || 0; }
function iVoted(root) { return !!root && _czMyVotes.has(root); }
// This browser's voter key — mirrors the backend (signed-in username, else anon:<id>) so
// "have I voted" can be derived from the authoritative votes file.
function myVoterKey() {
let u = '';
try { const el = inputInside('current-user-info'); if (el && el.value) u = (JSON.parse(el.value).username || ''); } catch (e) {}
u = String(u).toLowerCase();
return u || ('anon:' + myAnonId());
}
// Pull the CURRENT vote tallies from the dataset's votes.json so counts/sort are fresh
// every time the library opens (KIMODO_VOTES is only a start-up snapshot). Rebuilds the
// count map + this browser's ▲ state from the file (the server is authoritative).
async function fetchVotes() {
if (!DATASET_BASE) return;
try {
const r = await fetch(DATASET_BASE + '/viewer/votes.json?t=' + Date.now(), { cache: 'no-store' });
if (!r.ok) { return; } // 404 = nobody has voted yet
const data = await r.json();
if (!data || typeof data !== 'object') return;
Object.keys(KIMODO_VOTES).forEach((k) => delete KIMODO_VOTES[k]);
const mine = myVoterKey(); const mineSet = new Set();
for (const root in data) {
const voters = Array.isArray(data[root]) ? data[root] : [];
if (voters.length) KIMODO_VOTES[root] = voters.length;
if (voters.indexOf(mine) >= 0) mineSet.add(root);
}
_czMyVotes = mineSet; saveMyVotes(_czMyVotes);
} catch (e) {}
}
// Refresh the featured-kata pointer from the dataset so the badge/orange card/filter are
// correct even when it was pinned after the Space booted (KIMODO_FEATURED_ROOT is only a
// start-up snapshot).
async function fetchFeatured() {
if (!DATASET_BASE) return;
try {
const r = await fetch(DATASET_BASE + '/viewer/featured.json?t=' + Date.now(), { cache: 'no-store' });
if (!r.ok) { KIMODO_FEATURED_ROOT = ''; return; } // 404 = nothing pinned
const data = await r.json();
KIMODO_FEATURED_ROOT = (data && data.root) ? String(data.root) : '';
} catch (e) {}
}
const _czName = (m) => m.stance_name || (m.prompt || '').replace(/^\[STANCE\]\s*/, '') || m.id;
const _czDefaultPrompt = (name) => 'A person moves into ' + String(name || 'a pose').replace(/\s*@\d+$/, '').toLowerCase() + '.';
const _czGlyph = (n) => /kick/i.test(n) ? '🦵' : /punch|fist|strike|block/i.test(n) ? '👊' : /jump|leap|fly|somersault|flip|aerial|cartwheel|roll/i.test(n) ? '🤸' : /bow/i.test(n) ? '🙇' : /fall/i.test(n) ? '🤕' : '🧍';
// Pose thumbnails: a lightweight front-view stick figure drawn from a stance's
// frame-0 posed_joints (x = right, y = up). Cached by id; fetched lazily per card.
const _poseThumb = {};
function poseThumbSVG(P) {
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
for (const j of P) { if (j[0] < minX) minX = j[0]; if (j[0] > maxX) maxX = j[0]; if (j[1] < minY) minY = j[1]; if (j[1] > maxY) maxY = j[1]; }
const sp = Math.max(maxX - minX, maxY - minY, 0.5), pad = sp * 0.16;
const vbX = minX - pad, vbY = -(maxY + pad), vbW = (maxX - minX) + 2 * pad, vbH = (maxY - minY) + 2 * pad;
const sw = sp * 0.03, r = sp * 0.024;
let bones = '';
for (let i = 0; i < P.length; i++) { const p = SMPLX22_PARENTS[i]; if (p < 0) continue;
bones += '<line x1="' + P[p][0] + '" y1="' + (-P[p][1]) + '" x2="' + P[i][0] + '" y2="' + (-P[i][1]) + '"/>'; }
let dots = '';
for (const j of P) dots += '<circle cx="' + j[0] + '" cy="' + (-j[1]) + '" r="' + r + '"/>';
return '<svg viewBox="' + vbX + ' ' + vbY + ' ' + vbW + ' ' + vbH + '" preserveAspectRatio="xMidYMid meet">'
+ '<g stroke="#c79bff" stroke-width="' + sw + '" stroke-linecap="round" fill="none">' + bones + '</g>'
+ '<g fill="#e6c7ff">' + dots + '</g></svg>';
}
async function ensurePoseThumb(id) {
if (_poseThumb[id] != null) return _poseThumb[id];
const rec = await fetchRecord(id);
const P = rec && rec.posed_joints && rec.posed_joints[0];
_poseThumb[id] = P ? poseThumbSVG(P) : '';
return _poseThumb[id];
}
function fillPoseThumb(el, id) {
if (_poseThumb[id]) { el.innerHTML = _poseThumb[id]; return; }
el.textContent = '…';
ensurePoseThumb(id).then((svg) => { if (svg) el.innerHTML = svg; else el.textContent = '🧍'; }).catch(() => { el.textContent = '🧍'; });
}
// Canonicalise a stance's frame-0 pose: re-root pelvis to XZ origin + rotate about
// Y so the hip-line heading is 0 (Y kept, so airborne poses preserve height). Same
// convention as the backend _place_pose, so per-move turn/advance behave correctly.
function canonKeyframe(P, Q) {
const px = P[0][0], pz = P[0][2];
const P0 = P.map((j) => [j[0]-px, j[1], j[2]-pz]);
const phi = -Math.atan2(P0[2][2]-P0[1][2], -(P0[2][0]-P0[1][0]));
const c = Math.cos(phi), s = Math.sin(phi);
const Pc = P0.map((j) => [c*j[0]+s*j[2], j[1], -s*j[0]+c*j[2]]);
const qy = Math.sin(phi/2), qw = Math.cos(phi/2);
const Qc = Q.map((q) => { const gx=q[0], gy=q[1], gz=q[2], gw=q[3];
return [qw*gx+qy*gz, qw*gy+qy*gw, qw*gz-qy*gx, qw*gw-qy*gy]; });
return { posed: Pc, quats: Qc };
}
// Place a canon pose at a ground (x,z) with a Y-rotation (deg) — mirrors the backend
// _place_pose, done client-side so the move's 2D placement needs no backend change.
function placePose(posed, quats, rotDeg, tx, tz) {
const th = (rotDeg || 0) * Math.PI / 180, c = Math.cos(th), s = Math.sin(th);
const P = posed.map((j) => [c*j[0]+s*j[2] + (tx||0), j[1], -s*j[0]+c*j[2] + (tz||0)]);
const qy = Math.sin(th/2), qw = Math.cos(th/2);
const Q = quats.map((q) => { const gx=q[0], gy=q[1], gz=q[2], gw=q[3];
return [qw*gx+qy*gz, qw*gy+qy*gw, qw*gz-qy*gx, qw*gw-qy*gy]; });
return { posed: P, quats: Q };
}
async function previewComposeStance(id, name) {
try {
const rec = await fetchRecord(id);
if (!rec.num_joints) rec.num_joints = (rec.posed_joints && rec.posed_joints[0]) ? rec.posed_joints[0].length : 22;
if (!rec.bone_names || !rec.bone_names.length) rec.bone_names = SMPLX22_BONES;
_czViewerHasPreview = false; // a single browsed stance is loaded, not the kata preview
loadIntoViewer(rec, id, name, { keepCamera: true }); // browsing stances must not jog the camera
} catch (e) { console.error('compose preview failed', e); }
}
function setComposeStatus(t) { const s = document.getElementById('kimodo-compose-status'); if (s) s.textContent = t || ''; }
// No-op retained so existing call sites stay valid: the composer is a normal side
// drawer again (positioned by .kimodo-drawer CSS), not a JS-anchored bottom bar.
function positionComposeBar() {}
// Stable per-stance number (#1, #2…) for easy identification across the picker
// and the sequence cards.
let _stanceNum = {};
function stanceBadge(id) {
const b = document.createElement('span'); b.className = 'kimodo-cz-thumbnum';
b.textContent = '#' + (_stanceNum[id] || '?'); return b;
}
async function composeOpen() {
const items = await fetchManifest();
await fetchVotes(); // fresh upvote tallies so the Top sort + counts aren't a boot-time snapshot
await fetchFeatured(); // fresh featured pointer so the badge/orange card show even if pinned post-boot
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")
// The kata playing in the MAIN viewer (e.g. the featured/start-up one). Drives the same
// top-left name overlay, read-only, whenever the compose drawer isn't showing its own.
let _mainViewerKata = null;
function setMainViewerKata(k) { _mainViewerKata = k || null; _czWatchingRoot = (k && k.root) || ''; updateComposeViewerName(); }
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 drawer = document.getElementById('kimodo-compose-drawer');
const drawerOpen = !!(drawer && drawer.classList.contains('open'));
// A rename input is live — don't rebuild the DOM out from under it.
if (box.querySelector('input')) return;
const showing = (composeView === 'player' || (composeView === 'create' && composeEditing)) && composeCurrentKata;
if (showing) {
const w = drawer ? Math.round(drawer.getBoundingClientRect().width) : 230;
box.style.left = (w + 16) + 'px';
restoreViewerNameDom(); // editable: text + ✎ rename
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');
return;
}
// No compose name. Fall back to the MAIN-viewer (featured/start-up) kata — read-only,
// anchored top-left — but only when the drawer isn't open (so it never floats over it).
if (_mainViewerKata && !drawerOpen) {
box.style.left = '14px';
restoreViewerNameDom();
const ed = document.getElementById('kimodo-cz-vname-edit'); if (ed) ed.style.display = 'none';
const txt = document.getElementById('kimodo-cz-vname-text'); if (txt) { txt.style.cursor = 'default'; txt.onclick = null; }
renderViewerName(_mainViewerKata.label || 'Kata');
const byEl = document.getElementById('kimodo-cz-vname-by');
if (byEl) byEl.textContent = _mainViewerKata.contributor ? ('by ' + _mainViewerKata.contributor) : '';
box.classList.add('show');
} else {
box.classList.remove('show');
}
}
function renderViewerName(name) {
const txt = document.getElementById('kimodo-cz-vname-text'); if (txt) txt.textContent = name;
}
function startRenameKata() {
const box = document.getElementById('kimodo-cz-vname'); const k = composeCurrentKata; if (!box || !k) return;
const cur = k.label || k.name || 'Kata';
box.innerHTML = '';
const inp = document.createElement('input'); inp.type = 'text'; inp.value = cur;
box.appendChild(inp); inp.focus(); inp.select();
const finish = (save) => {
const v = (inp.value || '').trim();
if (save && v) {
k.label = v; k.name = v;
if (composePlayer && composePlayer.k === k) { composePlayer.k.label = v; }
if (k.local && k.lid) { const a = loadMyKatas(); const e = a.find((x) => x.lid === k.lid); if (e) { e.name = v; saveMyKatas(a); } }
}
restoreViewerNameDom(); renderViewerName(k.label || 'Kata');
};
inp.onkeydown = (ev) => { if (ev.key === 'Enter') finish(true); else if (ev.key === 'Escape') finish(false); };
inp.onblur = () => finish(true);
}
// Rebuild the name box's text + edit-icon DOM after an inline rename. Both the
// text and the ✎ start a rename.
function restoreViewerNameDom() {
const box = document.getElementById('kimodo-cz-vname'); if (!box) return;
box.innerHTML = '<span id="kimodo-cz-vname-text"></span><button type="button" id="kimodo-cz-vname-edit" class="kimodo-cz-vname-edit" title="rename">✎</button><span id="kimodo-cz-vname-by" class="kimodo-cz-vname-by"></span>';
const txt = document.getElementById('kimodo-cz-vname-text'); if (txt) { txt.style.cursor = 'text'; txt.onclick = startRenameKata; }
const ed = document.getElementById('kimodo-cz-vname-edit'); if (ed) ed.onclick = startRenameKata;
}
// Start a fresh kata (+ Create a kata).
function newComposeKata() {
composeStrip = []; composeSelId = null; composePlayer = null; composeKataEditable = true; _prevScrubRegion = null;
reframeViewer(); // fresh kata → re-center the camera once on the first stance load
// a fresh local kata, persisted to localStorage as soon as a move is generated.
// Default mode = 'normal' (text-prompt); the picker pill flips it while the kata is empty.
composeCurrentKata = { local: true, lid: 'k' + Date.now().toString(36), name: 'Untitled kata', label: 'Untitled kata', recipe: [], ids: [], mode: 'normal' };
setComposeView('player');
setComposeStatus('');
openStancePicker(); // go straight to the add-move screen (the Normal|Advanced pill)
}
// 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
renderPickerMode(); // pill active-state + lock; also swaps the body for the mode
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 = ''; }
if (composeMode() === 'normal') { const tp = document.getElementById('kimodo-cz-pprompt'); if (tp) { tp.value = ''; setTimeout(() => tp.focus(), 60); } }
}
// Reflect the kata's mode on the picker pill + swap the picker body (stance grid vs
// prompt box). The pill only changes the mode while the kata is EMPTY; once it has a
// move the mode is locked, so the buttons are disabled (but still show the current mode).
function renderPickerMode() {
const mode = composeMode();
const locked = composeStrip.length > 0;
document.querySelectorAll('#kimodo-cz-mode .kimodo-cz-segbtn').forEach((b) => {
b.classList.toggle('active', b.dataset.mode === mode);
b.disabled = locked;
});
renderPickerBody(mode);
}
function renderPickerBody(mode) {
const grid = document.getElementById('kimodo-compose-palette');
const filter = document.getElementById('kimodo-cz-pfilter');
const prompt = document.getElementById('kimodo-cz-promptbody');
const title = document.getElementById('kimodo-cz-ptitle');
const hint = document.getElementById('kimodo-cz-pickhint');
const normal = mode === 'normal';
if (grid) grid.style.display = normal ? 'none' : '';
if (filter) filter.style.display = normal ? 'none' : '';
if (prompt) prompt.style.display = normal ? '' : 'none';
if (title) title.textContent = normal ? 'Describe a move' : 'Pick a stance';
if (normal && hint) { hint.innerHTML = 'Type what the character does, then <b>Add move</b>.'; }
if (!normal) renderComposePalette(); // (re)build the stance grid + its hint
}
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);
}
// The picker's ✕ (back). Backing out of the add-move (pill) screen WITHOUT adding a move
// returns to the kata list (not a stranded empty player); with moves it just closes the
// picker (back to the player). Only the explicit ✕ does this — closeStancePicker itself
// is also called by setComposeView, so the redirect must live here, not there.
function pickerBack() {
if (!composeStrip.length) { composeCurrentKata = null; composeKataEditable = false; setComposeView('library'); }
else closeStancePicker();
}
// --- 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;
// A kata = a root (no parent) that EITHER starts a chain (has continuations) OR was
// explicitly built/published as a kata (carries a compose_recipe/owner stamp). The
// latter lets a single-move kata (e.g. a one-prompt normal kata) appear too.
if (!rid || (m.continues_from || {}).source_id) continue;
const stamped = !!(m.compose_recipe || m.compose_owner);
if (!children[rid] && !stamped) continue;
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));
// Anchored header: Create button is Mine-only; search + sort/featured are Community-only.
const ctrls = document.getElementById('kimodo-cz-libctrls'); if (ctrls) ctrls.style.display = (composeScope === 'community') ? '' : 'none';
const createBtn = document.getElementById('kimodo-cz-create'); if (createBtn) createBtn.style.display = (composeScope === 'mine') ? '' : 'none';
// 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, mode: e.mode || null, 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, created: e.ts || 0, local: true }));
} else {
shown = composeKatas;
}
// Search filter (name + contributor). Then the sort/filter control: 'featured' shows
// ONLY the featured kata; 'top' sorts by upvotes; 'newest' by recency. Don't mutate the
// source array — slice before sort.
const q = (composeFilter || '').trim().toLowerCase();
if (q) shown = shown.filter((k) => ((k.label || '') + ' ' + (k.contributor || '')).toLowerCase().includes(q));
if (composeSort === 'featured') shown = shown.filter((k) => k.root && k.root === KIMODO_FEATURED_ROOT);
shown = shown.slice().sort((a, b) => {
if (composeSort === 'top') { const d = voteCount(b.root) - voteCount(a.root); if (d) return d; }
return (b.created || 0) - (a.created || 0);
});
list.innerHTML = '';
// (The "+ Create a kata" button now lives in the anchored header, Mine only.)
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 isFeatured = !!(!k.local && k.root && KIMODO_FEATURED_ROOT && k.root === KIMODO_FEATURED_ROOT);
const isWatching = !!(k.root && _czWatchingRoot && k.root === _czWatchingRoot);
const card = document.createElement('div');
card.className = 'kimodo-cz-katacard' + (isFeatured ? ' cz-featured' : '') + (isWatching ? ' cz-watching' : '');
// 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);
// Footer row: upvote (▲ count) on the LEFT, "★ Featured" badge on the lower-RIGHT
// (beside the votes) so the top-right fork button never covers it.
const foot = document.createElement('div'); foot.className = 'kimodo-cz-cardfoot';
if (!k.local && k.root) {
const vb = document.createElement('button'); vb.type = 'button';
vb.className = 'kimodo-cz-katavote' + (iVoted(k.root) ? ' voted' : '');
vb.innerHTML = '<span class="cz-arrow">▲</span><span class="cz-vc">' + voteCount(k.root) + '</span>';
vb.title = iVoted(k.root) ? 'Remove your upvote' : 'Upvote this kata';
vb.onclick = (e) => { e.stopPropagation(); voteKata(k, vb); };
foot.appendChild(vb);
}
if (isFeatured) {
const fb = document.createElement('span'); fb.className = 'kimodo-cz-featbadge'; fb.textContent = '★ Featured'; foot.appendChild(fb);
}
if (foot.childNodes.length) card.appendChild(foot);
// 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);
}
// Owner-only (the space owner): pin this Community kata as the featured one — it's
// the kata that auto-plays on start-up. Sits below ⑂ Fork.
if (!k.local && k.root && isOwner()) {
const isFeat = !!(KIMODO_FEATURED_ROOT && k.root === KIMODO_FEATURED_ROOT);
const fb = document.createElement('button'); fb.type = 'button';
fb.className = 'kimodo-cz-katafeat' + (isFeat ? ' featured' : '');
fb.textContent = isFeat ? '★ Featured' : '★ Set as featured';
fb.disabled = isFeat;
fb.onclick = (e) => { e.stopPropagation(); if (!isFeat) setFeaturedKata(k, fb); };
card.appendChild(fb);
// Owner-only: permanently delete this community move (with a confirm). Sits below featured.
const db = document.createElement('button'); db.type = 'button';
db.className = 'kimodo-cz-katadel';
db.textContent = '🗑 Delete move';
db.onclick = (e) => { e.stopPropagation(); deleteKata(k, db); };
card.appendChild(db);
}
// 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 = composeFirstMoveIdx(); 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;
_czWatchingRoot = (k && k.root) || ''; // highlight this kata's card back in the library
// Mode: explicit on the kata, else inferred from the recipe's first entry (prompt → normal).
// Set BEFORE composeFirstMoveIdx() is read below.
const mode = k.mode || ((k.recipe && k.recipe[0] && k.recipe[0].kind === 'prompt') ? 'normal' : 'advanced');
composeCurrentKata.mode = mode;
const first = (mode === 'normal') ? 0 : 1;
if (!keepCam) reframeViewer(); // fresh session → re-center the camera once on the first motion load
// Build the working strip: per-move data, with clipIds from the saved spine. For a
// normal kata every recipe entry is a real move (clip = k.ids[idx]); for advanced,
// index 0 is the start stance (no clip) and move idx maps to k.ids[idx-1].
if (k.recipe && k.recipe.length) {
composeStrip = k.recipe.map((r, idx) => ({ kind: r.kind || 'stance', id: r.id || null, name: r.name || r.id || (r.prompt || '').slice(0, 40), 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: (k.ids && idx >= first) ? (k.ids[idx - first] || null) : null, dirty: false, expanded: false, _uid: ++_czUid }));
} else {
composeStrip = (k.ids || []).map((id, i) => ({ kind: 'stance', 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 >= first && 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 < composeFirstMoveIdx()) return;
composeBusy = true; composeGeneratingIdx = i; renderComposePlayer(); // button → loading state
setComposeStatus('generating move ' + (i - composeFirstMoveIdx() + 1) + '…');
try {
let move, localUrl, localBody;
if (c.kind === 'prompt') {
// NORMAL: a text-prompt move. The first move is a from-scratch generation; later
// moves CONTINUE from the previous move's end frame (source_frame -1).
const prev = composeStrip[i - 1];
const sourceId = (prev && prev.clipId) || null;
move = { kind: 'prompt', prompt: (c.prompt || '').trim(), seconds: c.seconds || 2.2, source_id: sourceId, source_frame: -1 };
if (sourceId) { localUrl = '/generate_continue'; localBody = { prompt: move.prompt, seconds: move.seconds, num_steps: 60, source_id: sourceId, source_frame: -1, stitch: false }; }
else { localUrl = '/generate'; localBody = { prompt: move.prompt, seconds: move.seconds, num_steps: 60 }; }
} else {
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
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 } ] };
localUrl = '/generate_between'; localBody = Object.assign({ num_steps: 60, post_processing: true }, move);
}
// 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 + localUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(localBody) });
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 (= boundary pf[startMove - firstIdx])
// instead of the top. (advanced: pf[startMove-1]; normal: pf[startMove].)
let sf = 0;
if (startMove != null && startMove >= composeFirstMoveIdx()) {
const pf = composePlayer.poseFrac, fr = pf[Math.max(0, Math.min(startMove - composeFirstMoveIdx(), 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, mode: k.mode || 'advanced', 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'));
}
}
}
// True only for the space owner(s) (HF username in KIMODO_OWNERS). Gates the
// "★ Set as featured" button; set_featured re-checks server-side, so this is just UI.
function isOwner() {
// acctInfo() lives in the account-widget IIFE (out of this scope), so read the
// signed-in identity straight from the hidden #current-user-info field.
try {
const el = inputInside('current-user-info');
let u = '';
if (el && el.value) { try { u = (JSON.parse(el.value).username || ''); } catch (e) {} }
u = String(u).toLowerCase();
return !!u && KIMODO_OWNERS.indexOf(u) >= 0;
} catch (e) { return false; }
}
// Owner-only: pin a Community kata as the featured (start-up) one via the hidden gradio
// #kata-featured-btn → set_featured (writes viewer/featured.json). Mirrors publishKata.
function setFeaturedKata(k, btnEl) {
if (!k.root) { setComposeStatus('This kata has no shared root yet.'); return; }
const trigger = document.querySelector('#kata-featured-btn button, #kata-featured-btn');
const payloadInput = inputInside('kata-featured-payload');
const resultInput = inputInside('kata-featured-result');
if (!trigger || !payloadInput || !resultInput) { setComposeStatus('Featured backend not found.'); return; }
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '⟳ setting…'; }
setComposeStatus('Setting “' + (k.label || 'kata') + '” as the featured kata…');
resultInput.value = '';
payloadInput.value = JSON.stringify({ root: k.root, name: k.label || '', contributor: k.contributor || '' });
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 > 60000) { window.clearInterval(timer); finish('{"ok":false,"error":"timed out"}'); }
}, 400);
window.setTimeout(() => trigger.click(), 40);
function finish(val) {
let r = null; try { r = JSON.parse(val); } catch (e) {}
if (r && r.ok) {
KIMODO_FEATURED_ROOT = k.root;
setComposeStatus('“' + (k.label || 'kata') + '” is now the featured kata ★ — it plays on start.');
renderComposeLibrary();
} else {
if (btnEl) { btnEl.disabled = false; btnEl.textContent = '★ Set as featured'; }
setComposeStatus('Couldn’t set featured: ' + ((r && r.error) || 'unknown'));
}
}
}
// OWNER-ONLY: delete a community move via the hidden #kata-delete-* bridge (mirrors
// setFeaturedKata). Confirms first; delete_kata re-checks the caller is an owner server-side.
function deleteKata(k, btnEl) {
if (!k.root) { setComposeStatus('This move has no shared id yet.'); return; }
if (!window.confirm('Delete "' + (k.label || 'this move') + '" from the community for everyone? This cannot be undone.')) return;
const trigger = document.querySelector('#kata-delete-btn button, #kata-delete-btn');
const payloadInput = inputInside('kata-delete-payload');
const resultInput = inputInside('kata-delete-result');
if (!trigger || !payloadInput || !resultInput) { setComposeStatus('Delete backend not found.'); return; }
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '⟳ deleting…'; }
setComposeStatus('Deleting "' + (k.label || 'move') + '"…');
resultInput.value = '';
payloadInput.value = JSON.stringify({ root: k.root, ids: k.ids || [] });
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 > 60000) { window.clearInterval(timer); finish('{"ok":false,"error":"timed out"}'); }
}, 400);
window.setTimeout(() => trigger.click(), 40);
function finish(val) {
let r = null; try { r = JSON.parse(val); } catch (e) {}
if (r && r.ok) {
composeKatas = composeKatas.filter((x) => x.root !== k.root);
setComposeStatus('"' + (k.label || 'move') + '" deleted from the community.');
renderComposeLibrary();
} else {
if (btnEl) { btnEl.disabled = false; btnEl.textContent = '🗑 Delete move'; }
setComposeStatus('Couldn’t delete: ' + ((r && r.error) || 'unknown'));
}
}
}
// Toggle an upvote on a community kata via the hidden #kata-vote-* bridge (mirrors
// setFeaturedKata). Optimistically reflects the new count + ▲ state from the server's reply.
function voteKata(k, btnEl) {
if (!k.root) return;
const trigger = document.querySelector('#kata-vote-btn button, #kata-vote-btn');
const payloadInput = inputInside('kata-vote-payload');
const resultInput = inputInside('kata-vote-result');
if (!trigger || !payloadInput || !resultInput) { setComposeStatus('Vote backend not found.'); return; }
if (btnEl) btnEl.disabled = true;
resultInput.value = '';
payloadInput.value = JSON.stringify({ root: k.root, anon_id: myAnonId() });
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 > 30000) { window.clearInterval(timer); finish('{"ok":false,"error":"timed out"}'); }
}, 350);
window.setTimeout(() => trigger.click(), 40);
function finish(val) {
if (btnEl) btnEl.disabled = false;
let r = null; try { r = JSON.parse(val); } catch (e) {}
if (r && r.ok) {
KIMODO_VOTES[k.root] = r.count;
if (r.voted) _czMyVotes.add(k.root); else _czMyVotes.delete(k.root);
saveMyVotes(_czMyVotes);
renderComposeLibrary(); // updates the count + ▲ fill, and re-sorts when Top
} else {
setComposeStatus('Couldn’t vote: ' + ((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 firstIdx..i-1, already built) chains this move and lets
// the server stitch the preview up to here.
const prefix = [];
for (let k = composeFirstMoveIdx(); 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)); }
// --- Kata mode: 'advanced' (the original stance flow — composeStrip[0] is a non-clip
// START stance, moves run from index 1) vs 'normal' (a pure text-prompt chain — move 0
// IS a real generatable clip at composeStrip[0]). The first move locks the mode; legacy
// katas with no `mode` field are treated as advanced. ---
function composeMode() { return (composeCurrentKata && composeCurrentKata.mode) || 'advanced'; }
// Index of the first REAL move in composeStrip: 0 for a normal (prompt) kata (no start
// stance), 1 for an advanced (stance) kata. Replaces the hard-coded `1`/`i-1` assumptions.
function composeFirstMoveIdx() { return composeMode() === 'normal' ? 0 : 1; }
// 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 f = composeFirstMoveIdx(); const n = (composePlayer && composePlayer.genCount) || generatedPrefix().ids.length; for (let i = f; i < f + 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 mode = composeMode();
const firstIdx = composeFirstMoveIdx(); // 0 for normal (prompt), 1 for advanced (stance)
const addLabel = (mode === 'normal') ? '+ Add a move' : '+ Add a stance';
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 >= firstIdx && !c.clipId);
const hasDirty = poses.some((c, k) => k >= firstIdx && 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 >= firstIdx; i--) { if (!poses[i].clipId) { ungen = i; break; } }
if (ungen >= firstIdx) activeIdx = ungen;
else if (composeSelMove >= firstIdx && composeSelMove <= last) activeIdx = composeSelMove;
}
if (editable) {
let hintHTML = '';
const moveNo = (a) => (a - firstIdx + 1); // human move number (1-based) in either mode
if (poses.length === 0) hintHTML = 'Tap <b style="color:#c79bff">' + addLabel + '</b> below to begin.';
else if (activeIdx >= firstIdx && poses[activeIdx] && poses[activeIdx]._uid === _czGenPulseUid) {
// Just generated/updated: celebrate + point at the next action (pulses since the text changed).
hintHTML = '<b style="color:#c79bff">Move #' + moveNo(activeIdx) + '</b> updated. Keep going with <b style="color:#c79bff">' + addLabel + '</b> or keep editing.';
}
else if (activeIdx >= firstIdx) {
const btn = poses[activeIdx].clipId ? 'Refresh' : 'Generate';
if (mode === 'normal') {
hintHTML = '<span style="color:#9a9aa3">Describe the move</span><br>'
+ 'edit the <b style="color:#c79bff">prompt</b> below<br><br>'
+ '<span style="color:#9a9aa3">changing duration</span><br>'
+ 'drag <b style="color:#c79bff">…</b> up/down<br><br>'
+ '<b style="color:#7db8ff">' + btn + '</b> when done';
} else {
hintHTML = '<span style="color:#9a9aa3">Editing pose on 3d viewer</span><br>'
+ 'drag <b style="color:#6fd0a0">ring</b> to move • <b style="color:#e8736a">knob</b> to rotate<br><br>'
+ '<span style="color:#9a9aa3">changing duration</span><br>'
+ 'drag <b style="color:#c79bff">…</b> up/down<br><br>'
+ '<b style="color:#7db8ff">' + btn + '</b> when done';
}
}
else hintHTML = 'Tap a move to edit it, or ' + addLabel + ' 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 = addLabel;
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 = firstIdx; i <= last; i++) track.appendChild(renderTimelineCard(i, last, editable, activeIdx));
body.appendChild(track);
// scrubber rail over the generated region = the unified playhead (reused component).
// Based on the loaded preview, so it stays put while you edit a (dirty) move.
if (hasPreview && previewMoves >= 1) {
const rail = document.createElement('div'); rail.className = 'kimodo-cz-prail'; rail.id = 'kimodo-cz-prail';
const railfill = document.createElement('div'); railfill.className = 'kimodo-cz-prailfill'; railfill.id = 'kimodo-cz-prailfill';
const handle = document.createElement('div'); handle.className = 'kimodo-cz-phandle'; handle.id = 'kimodo-cz-phandle'; handle.textContent = '⇕';
rail.appendChild(railfill); rail.appendChild(handle); body.appendChild(rail);
wireScrubRail(rail);
}
root.appendChild(body);
appendAdd(); // + Add a stance at the END of the timeline
if (hasPreview) {
const time = document.createElement('div'); time.className = 'kimodo-cz-ptime';
time.innerHTML = '<span id="kimodo-cz-pcur">0:00</span><span id="kimodo-cz-ptot">' + _czTime(composePlayer.numFrames - 1, composePlayer.fps) + '</span>';
root.appendChild(time);
requestAnimationFrame(positionScrubber); // size the rail to the generated region after layout
}
composeJustGenIdx = -1; _czPopUids = null; _czSlideUid = null; _czGenPulseUid = null; _czSelPulseUid = null; // one-shot animations consumed
}
// A move on the timeline. The ACTIVE (being-edited) move expands to the editor
// (start stance on top, end stance on the bottom = drag handle, then GENERATE +
// refine + numbers). Every other move is a compact card whose HEIGHT = its duration.
function renderTimelineCard(i, last, editable, activeIdx) {
const c = composeStrip[i];
const isEnd = (i === last);
const pop = _czPopUids && _czPopUids.has(c._uid);
const slide = (c._uid === _czSlideUid);
const row = document.createElement('div'); row.className = 'kimodo-cz-pcardrow' + (pop ? ' cz-pop' : '') + (i === activeIdx ? ' cz-activerow' : '') + (slide ? ' cz-slidein' : ''); row.dataset.i = i;
if (editable && i === activeIdx) { row.appendChild(renderEditCard(i, last)); return row; }
const generated = !!(c.clipId && !c.dirty);
row.style.height = Math.max(CZ_MINCARD, _czDur(c) * CZ_PXPS) + 'px';
const card = document.createElement('div');
card.className = 'kimodo-cz-tlcard' + (isEnd ? ' end' : '') + (generated ? ' gen' : ' cz-pending') + (composeJustGenIdx === i ? ' cz-justgen' : '');
const node = document.createElement('span'); node.className = 'kimodo-cz-tlnode ' + (isEnd ? 'endn' : 'midn'); card.appendChild(node);
const thumb = document.createElement('div'); thumb.className = 'kimodo-cz-tlsthumb';
if (c.kind === 'prompt') { thumb.classList.add('cz-prompt'); thumb.textContent = '❝'; } // prompt move: a text glyph, no stance pose
else { 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 isPrompt = c.kind === 'prompt';
const start = composeStrip[i - 1];
const isEnd = (i === last);
const firstIdx = composeFirstMoveIdx();
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' + (isPrompt ? ' cz-prompt' : '') + (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 - firstIdx + 1); card.appendChild(moveLab);
// duration region. Advanced: start pose (top) → gap → end pose (bottom), the two thumbs
// TOGGLE their viewer skeletons. Normal (prompt): no stances — just the duration.
const region = document.createElement('div'); region.className = 'kimodo-cz-emregion' + (isPrompt ? ' cz-promptregion' : ''); region.style.height = Math.max(CZ_MINREG, _czDur(c) * CZ_PXPS) + 'px';
const durBig = document.createElement('div'); durBig.className = 'kimodo-cz-emdur'; durBig.textContent = _czDur(c).toFixed(1) + 's';
if (isPrompt) {
region.appendChild(durBig);
} else {
const startShown = !!c._showStart, endShown = (c._showEnd !== false); // end shown by default, start hidden
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'; if (start) { fillPoseThumb(fThumb, start.id); fThumb.appendChild(stanceBadge(start.id)); }
fThumb.onclick = (e) => { e.stopPropagation(); c._showStart = !startShown; sendPlaceVis(i); renderComposePlayer(); };
from.appendChild(fThumb);
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); }
// For a prompt move, the PROMPT is the primary input — show it first (always open),
// above GENERATE. For a stance move it stays a collapsed "Refine prompt" below GENERATE.
const refine = document.createElement('details'); refine.className = 'kimodo-cz-refine'; if (isPrompt || c._refineOpen) refine.open = true;
const sm = document.createElement('summary'); sm.textContent = isPrompt ? 'Prompt' : 'Refine prompt'; refine.appendChild(sm);
// GENERATE / REFRESH. For a prompt continuation, it needs the previous move's clip.
const gen = document.createElement('button'); gen.type = 'button'; gen.className = 'kimodo-cz-genbtn show' + (c.clipId ? ' refresh' : ''); // Refresh = blue
const generating = composeBusy && composeGeneratingIdx === i;
const needsPrev = isPrompt && i > firstIdx && !(start && start.clipId);
if (generating) { gen.classList.add('loading'); gen.disabled = true; gen.innerHTML = '<span class="kimodo-cz-spin"></span>'; } // spinner only, no text
else { gen.disabled = composeBusy || needsPrev; gen.textContent = c.clipId ? '↻ Refresh' : 'Generate'; }
gen.onclick = (e) => { e.stopPropagation(); generateMove(i); };
const tp = document.createElement('textarea'); tp.className = 'kimodo-cz-tprompt'; tp.value = c.prompt; tp.placeholder = isPrompt ? 'Describe this move… e.g. steps forward and throws a punch' : 'Describe the move into this pose…';
tp.oninput = () => { c.prompt = tp.value; if (isPrompt) c.name = tp.value.slice(0, 40); markMoveDirty(i, gen); };
refine.appendChild(tp); refine.addEventListener('toggle', () => { c._refineOpen = refine.open; });
// Prompt move: prompt (open) THEN GENERATE. Stance move: GENERATE THEN collapsed refine.
if (isPrompt) { card.appendChild(refine); card.appendChild(gen); }
else { card.appendChild(gen); card.appendChild(refine); }
// Move/rotation numbers below — stance moves only (prompt moves have no ring placement).
if (!isPrompt) { const place = document.createElement('div'); place.className = 'kimodo-cz-placerow'; place.id = 'kimodo-cz-place-' + i; place.innerHTML = czPlaceText(c); card.appendChild(place); }
// Resize handle (dots) across the BOTTOM of the whole card — drag it (as if resizing
// the whole div) to set the move length; the card grows taller as the move lengthens.
const handle = document.createElement('div'); handle.className = 'kimodo-cz-emhandle kimodo-cz-cardhandle'; handle.title = 'drag to set the move length';
const dots = document.createElement('span'); dots.className = 'kimodo-cz-dots'; handle.appendChild(dots);
card.appendChild(handle);
wireDurationDrag(handle, null, region, CZ_MINREG, i, durBig); // resizes the duration region (whole card grows)
return card;
}
// Compact "x 0.0 · y 0.0 · ↻ 0°" readout for a move's 2D placement (no label, to
// save vertical/horizontal space on a thin sidebar).
function czPlaceText(c) {
return '<span class="v">x ' + (c.tx || 0).toFixed(1) + ' · y ' + (c.tz || 0).toFixed(1) + ' · ↻ ' + Math.round(c.rot || 0) + '°</span>';
}
// Select a move: it becomes the active move (controls panel shows below the timeline)
// and its END pose is overlaid in the viewer for 3D placement. Re-render so the
// controls follow the selection.
function selectMove(i) {
// Edit one move at a time: if another (generated) move has unsaved edits, confirm,
// then revert it to its saved state before switching.
const dj = composeStrip.findIndex((c, k) => k >= composeFirstMoveIdx() && 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();
// Stance moves get the 3D ring (overlay this move's END pose); prompt moves have no
// stance to place, so just clear any stale overlay.
if (sc && sc.kind === 'prompt') clearPlaceMode(); else sendPlacePose(i);
// 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): for advanced, load the start stance
// (index i-1) at origin. Normal move 0 has no start stance → leave the viewer.
const ps = composeStrip[i - 1];
if (ps && ps.id) 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;
// poseFrac is a boundary array; the move being played INTO is composeStrip index
// (curI + firstIdx - 1) — advanced: curI; normal: curI-1.
const curCard = curI + composeFirstMoveIdx() - 1;
document.querySelectorAll('#kimodo-cz-ptrack .kimodo-cz-pcardrow').forEach((row) => row.classList.toggle('cur', Number(row.dataset.i) === curCard));
const cur = document.getElementById('kimodo-cz-pcur'); if (cur) cur.textContent = _czTime(frame, p.fps);
const pp = document.getElementById('kimodo-cz-pplay'); if (pp) pp.textContent = p.playing ? '❚❚' : '▶';
}
// Driven by the viewer's per-frame messages while the player view is open.
function composeOnFrame(msg) {
if (!composePlayer || composeView !== 'player' || !msg) return;
// Only trust frame counts while the viewer holds the KATA preview — a browsed single
// stance must not overwrite the kata's length (it would mis-place the seek/scrubber).
if (!_czViewerHasPreview) return;
if (typeof msg.num_frames === 'number' && msg.num_frames > 1) composePlayer.numFrames = msg.num_frames;
updatePlayerProgress(Math.round(msg.frame || 0), !!msg.playing);
}
function deleteKata(k) {
if (!window.confirm('Delete this kata?')) return;
if (k.local) { // Mine entry — drop the localStorage record (+ server clips if known)
saveMyKatas(loadMyKatas().filter((e) => e.lid !== k.lid));
if (k.root) { const did = document.querySelector('#del-id textarea, #del-id input'); const dbtn = document.querySelector('#del-btn'); if (did && dbtn) { did.value = k.root; did.dispatchEvent(new Event('input', { bubbles: true })); dbtn.click(); } }
} else {
const did = document.querySelector('#del-id textarea, #del-id input');
const dbtn = document.querySelector('#del-btn');
for (const id of (k.ids || [k.root])) { if (did && dbtn) { did.value = id; did.dispatchEvent(new Event('input', { bubbles: true })); dbtn.click(); } }
composeKatas = composeKatas.filter((x) => x.root !== k.root);
}
renderComposeLibrary();
setComposeStatus('deleted “' + k.label + '”');
}
function renderComposePalette() {
const pal = document.getElementById('kimodo-compose-palette'); if (!pal) return;
pal.innerHTML = '';
const fEl = document.getElementById('kimodo-cz-pfilter');
const q = (fEl && fEl.value || '').trim().toLowerCase();
const list = q ? composeStances.filter((m) => _czName(m).toLowerCase().includes(q) || (m.stance_tags || []).join(' ').toLowerCase().includes(q)) : composeStances;
// Anchored hint above the (scrolling) grid: two-tap prompt for the first move,
// else a plain add-the-next-stance prompt.
const startPhase = (composeStrip.length === 0 && !_czFirstPick); // picking START (green) vs END (red)
const hintEl = document.getElementById('kimodo-cz-pickhint');
if (hintEl) {
const cl = startPhase ? 's' : 'e'; // color only the ✓ and the start/end word
hintEl.innerHTML = _czTentative
? ('Tap the <b class="' + cl + '">✓</b> stance to confirm')
: ('Tap a stance to set ' + (startPhase ? 'a <b class="s">start pose</b>' : 'the <b class="e">end pose</b>'));
}
if (!composeStances.length) { pal.innerHTML = '<div class="kimodo-cz-statusline" style="color:#888;padding:8px 4px">no stances in this dataset</div>'; return; }
if (!list.length) { pal.innerHTML = '<div class="kimodo-cz-statusline" style="color:#888;padding:8px 4px">no stances match “' + q.replace(/</g, '&lt;') + '”</div>'; return; }
for (const m of list) {
const card = document.createElement('div');
const isStartPick = _czFirstPick && m.id === _czFirstPick.id; // confirmed START (during END phase)
const isTentative = _czTentative && m.id === _czTentative.id; // tapped to preview, awaiting confirm
card.className = 'kimodo-cz-pcard' + (isStartPick ? ' cz-startpick' : (isTentative ? (' cz-tentative ' + (startPhase ? 'tent-start' : 'tent-end')) : (m.id === composeSelId ? ' sel' : '')));
card.title = _czName(m);
const thumb = document.createElement('div'); thumb.className = 'kimodo-cz-pthumb'; fillPoseThumb(thumb, m.id); thumb.appendChild(stanceBadge(m.id));
if (isStartPick) { const tg = document.createElement('span'); tg.className = 'kimodo-cz-picktag'; tg.textContent = 'START'; card.appendChild(tg); }
if (isTentative) { const ck = document.createElement('span'); ck.className = 'kimodo-cz-pcheck'; ck.textContent = '✓'; card.appendChild(ck); } // big ✓ over the whole card = confirm
const nm = document.createElement('div'); nm.className = 'kimodo-cz-pname'; nm.textContent = _czName(m);
const add = document.createElement('button'); add.type = 'button'; add.textContent = '+';
add.title = 'add to storyboard'; add.className = 'kimodo-cz-padd';
add.onclick = (e) => { e.stopPropagation(); composeAdd(m); };
// In the picker, tapping a stance selects it (two-tap for the first move).
card.onclick = () => composeAdd(m);
card.appendChild(thumb); card.appendChild(nm); card.appendChild(add);
pal.appendChild(card);
}
}
function _czStanceEntry(m, isStart) {
return { id: m.id, name: _czName(m), prompt: isStart ? '' : _czDefaultPrompt(_czName(m)),
tx: 0, tz: 0, rot: 0, seconds: 2.2, clipId: null, dirty: true, expanded: true, _uid: ++_czUid };
}
// Normal (text-prompt) move add: append a prompt move and open it for editing. The FIRST
// prompt move sits at composeStrip[0] (a normal kata has no start stance) and locks the
// kata into normal mode. Generation happens when the user taps the edit card's GENERATE.
function composeAddPrompt(text) {
const prompt = (text || '').trim();
if (!prompt) { const tp = document.getElementById('kimodo-cz-pprompt'); if (tp) { tp.focus(); } setComposeStatus('Type a description first.'); return; }
if (composeCurrentKata && composeStrip.length === 0) composeCurrentKata.mode = 'normal'; // first move locks the mode
const entry = { kind: 'prompt', id: null, name: prompt.slice(0, 40), prompt: prompt,
tx: 0, tz: 0, rot: 0, seconds: 2.2, clipId: null, dirty: true, expanded: true, _uid: ++_czUid };
composeStrip.push(entry);
closeStancePicker();
_czPopUids = new Set([entry._uid]);
_czSlideUid = entry._uid;
renderComposePlayer();
selectMove(composeStrip.length - 1); _czScrollToActive();
}
// 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(composeMode() === 'normal' ? 'Remove this move from the kata?' : 'Remove this stance from the kata?')) return;
const drop = () => {
composeStrip.splice(i, 1);
// Advanced: a lone START stance (no move) is meaningless → clear back to empty so the
// kata returns to the "tap to add" state. Normal katas have no start stance, so a
// single remaining entry is a real move — keep it.
if (composeMode() === 'advanced' && 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) => ({ kind: c.kind || 'stance', 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 firstIdx = composeFirstMoveIdx(); // spine clip k maps to composeStrip[firstIdx + k]
const ids = meta.kata_ids || [meta.kata_root];
for (let i = firstIdx; i < composeStrip.length && i - firstIdx < ids.length; i++) { composeStrip[i].clipId = ids[i - firstIdx]; 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'));
} else {
updateKataPreview(builtIdx, null).then(() => { if (builtIdx >= firstIdx && builtIdx < composeStrip.length) { if (composeStrip[builtIdx]) _czGenPulseUid = composeStrip[builtIdx]._uid; selectMove(builtIdx); } });
setComposeStatus('▶ generated');
}
} 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 cfilter = document.getElementById('kimodo-cz-cfilter');
if (cfilter) cfilter.addEventListener('input', () => { composeFilter = cfilter.value || ''; renderComposeLibrary(); });
const csort = document.getElementById('kimodo-cz-csort');
if (csort) { csort.value = composeSort; csort.addEventListener('change', () => { composeSort = csort.value || 'top'; renderComposeLibrary(); }); }
const ccreate = document.getElementById('kimodo-cz-create');
if (ccreate) ccreate.addEventListener('click', () => newComposeKata());
// Move-mode pill: only changes the mode while the kata is empty (then it's locked).
const modeSeg = document.getElementById('kimodo-cz-mode');
if (modeSeg) modeSeg.addEventListener('click', (e) => {
const b = e.target.closest('.kimodo-cz-segbtn'); if (!b || b.disabled) return;
if (composeStrip.length > 0) return; // locked once a move exists
if (composeCurrentKata) composeCurrentKata.mode = b.dataset.mode;
renderPickerMode();
const tp = document.getElementById('kimodo-cz-pprompt'); if (tp && composeMode() === 'normal') setTimeout(() => tp.focus(), 40);
});
const paddmove = document.getElementById('kimodo-cz-paddmove');
if (paddmove) paddmove.addEventListener('click', () => { const tp = document.getElementById('kimodo-cz-pprompt'); composeAddPrompt(tp ? tp.value : ''); });
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', pickerBack);
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 (plain string → single braces). Runs in the drawer-JS
# scope, so it can use inputInside(). Logged out = 🤗 (click → HF login); logged in = ☰
# (click → right account drawer with avatar + sign out).
_ACCT_JS = r"""
(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 side-tab behavior, concatenated into the drawer-JS scope (see _read_tabs).
_EXTRA_TABS_JS = _read_tabs("*.tab.js")
DRAWER_JS = f"""
(() => {{
// Lift the SSR boot splash (the body::before/::after overlay in _BLOCKS_CSS) by
// tagging <body>. Called on the viewer's first preview-ready / viewer-error, plus a
// safety timeout so a slow/blocked CDN never traps the user behind the spinner.
window.__kimodoHideSplash = function () {{ try {{ document.body.classList.add('kimodo-ready'); }} catch (e) {{}} }};
// The Space's own (non-iframe) host, e.g. "name.hf.space". Used to break OUT of the
// huggingface.co iframe for OAuth so the session cookie is first-party (iOS/Safari
// block third-party cookies in iframes). Empty off-Space (local dev).
window.__kimodoSpaceHost = {json.dumps((os.environ.get("SPACE_HOST", "") or "").strip())};
setTimeout(function () {{ if (window.__kimodoHideSplash) window.__kimodoHideSplash(); }}, 15000);
let attempts = 0;
function boot() {{
const defaultClothing = {json.dumps(DEFAULT_CLOTHING, separators=(",", ":"))};
const DATASET_BASE = {json.dumps(_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 = {json.dumps(KIMODO_REMOTE_URL)};
// Space owner usernames (lower-case) allowed to pin the featured kata, and the root id
// of the kata currently pinned to play first. Drive the owner-only "★ Set as featured"
// button + its "★ Featured" state on community cards.
const KIMODO_OWNERS = {json.dumps(sorted(OWNER_USERNAMES))};
let KIMODO_FEATURED_ROOT = {json.dumps(_featured().get("root", ""))};
// Community upvote counts (root -> count) read at start-up for the Top sort + card badges.
const KIMODO_VOTES = {json.dumps(_vote_counts())};
const root = document.getElementById('kimodo-drawer-root');
const tabs = document.getElementById('kimodo-left-tabs');
const clothingDrawer = document.getElementById('kimodo-clothing-drawer');
if (!root || !tabs || !clothingDrawer) {{
if (++attempts < 80) window.setTimeout(boot, 100);
return;
}}
if (root.dataset.ready === '1') return;
root.dataset.ready = '1';
if (root.parentElement !== document.body) document.body.appendChild(root);
// Embedded in the HF Space page? Pin the tab rail to the upper-left (the
// centered default lands mid-page when HF grows the iframe to content height).
let embedded = false;
try {{ embedded = window.self !== window.top; }} catch (e) {{ embedded = true; }}
if (embedded) tabs.classList.add('kimodo-embedded');
function inputInside(id) {{
const el = document.getElementById(id);
return el ? el.querySelector('textarea, input') : null;
}}
function previewFrame() {{
return document.querySelector('#main-preview iframe');
}}
// --- Generic drawer manager: each left-tab carries data-drawer; only one
// drawer is open at a time, and the tab rail slides to the open drawer's edge.
const pairs = Array.from(tabs.querySelectorAll('.kimodo-left-tab[data-drawer]'))
.map((tab) => ({{ tab, drawer: document.getElementById(tab.dataset.drawer) }}))
.filter((p) => p.drawer);
const drawers = pairs.map((p) => p.drawer);
// The composer is now a normal left side drawer again (reverted from the bottom bar).
const composeBar = document.getElementById('kimodo-compose-drawer');
const sideDrawers = drawers;
function openDrawer() {{ return drawers.find((d) => d.classList.contains('open')) || null; }}
function openSideDrawer() {{ return sideDrawers.find((d) => d.classList.contains('open')) || null; }}
// Tell the viewer how many px the open left drawer covers, so it can shift the
// camera to keep the character centered in the still-visible area (0 = none).
function sendViewportInset() {{
const open = openSideDrawer();
const left = open ? Math.round(open.getBoundingClientRect().width) : 0;
const f = previewFrame();
if (f && f.contentWindow) f.contentWindow.postMessage({{ kind: 'viewport-inset', left }}, '*');
}}
// --- Drawer resize handle: drag the open drawer's right edge to trade panel width
// for 3D-view width. The chosen width is persisted per drawer (localStorage) and the
// tab rail + camera inset follow the edge live while dragging. ---
const DRAWER_MIN_W = 158;
function winW() {{ return Math.max(window.innerWidth || 0, document.documentElement.clientWidth || 0); }}
function drawerMaxW() {{ return Math.max(DRAWER_MIN_W, Math.round(winW() * 0.85)); }}
function loadDrawerWidths() {{ try {{ return JSON.parse(localStorage.getItem('kimodo-drawer-widths') || '{{}}') || {{}}; }} catch (e) {{ return {{}}; }} }}
function saveDrawerWidth(id, px) {{ try {{ const m = loadDrawerWidths(); m[id] = Math.round(px); localStorage.setItem('kimodo-drawer-widths', JSON.stringify(m)); }} catch (e) {{}} }}
let resizerEl = null, resizing = null, resizeRaf = false;
function ensureResizer() {{
if (resizerEl) return resizerEl;
resizerEl = document.createElement('div');
resizerEl.id = 'kimodo-drawer-resizer';
resizerEl.title = 'Drag to resize the panel';
resizerEl.innerHTML = '<span class="grip"></span>';
document.body.appendChild(resizerEl);
resizerEl.addEventListener('pointerdown', startResize);
return resizerEl;
}}
function positionResizer() {{
const r = ensureResizer();
const open = openSideDrawer();
if (!open) {{ r.style.display = 'none'; return; }}
const w = Math.round(open.getBoundingClientRect().width);
r.style.left = (w - 6) + 'px'; // straddle the drawer's right edge
r.style.display = 'block';
}}
function startResize(e) {{
const open = openSideDrawer();
if (!open) return;
resizing = open;
e.preventDefault(); e.stopPropagation();
try {{ resizerEl.setPointerCapture(e.pointerId); }} catch (er) {{}}
document.body.style.userSelect = 'none';
resizerEl.classList.add('dragging');
}}
function onResizeMove(e) {{
if (!resizing) return;
const px = Math.max(DRAWER_MIN_W, Math.min(Math.round(e.clientX), drawerMaxW()));
// !important to beat the narrow-screen 50vw-important media-query rule.
resizing.style.setProperty('width', px + 'px', 'important');
positionResizer();
tabs.style.left = px + 'px'; // keep the tab rail glued to the edge while dragging
if (!resizeRaf) {{ resizeRaf = true; requestAnimationFrame(() => {{ resizeRaf = false; sendViewportInset(); }}); }}
}}
function endResize() {{
if (!resizing) return;
saveDrawerWidth(resizing.id, Math.round(resizing.getBoundingClientRect().width));
resizing = null;
document.body.style.userSelect = '';
if (resizerEl) resizerEl.classList.remove('dragging');
layoutTabs(); sendViewportInset();
}}
window.addEventListener('pointermove', onResizeMove);
window.addEventListener('pointerup', endResize);
window.addEventListener('pointercancel', endResize);
// Size each side drawer from the ACTUAL measured window width (reliable inside the
// HF embed iframe, where vw can resolve to the layout viewport, not the visible
// width). Half the width on narrow screens; full fixed width on desktop. Inline
// style overrides the stylesheet (and any stale cached CSS).
function layoutDrawerWidth() {{
const w = Math.max(window.innerWidth || 0, document.documentElement.clientWidth || 0);
const widths = loadDrawerWidths();
for (const d of sideDrawers) {{
let px;
if (d.id === 'kimodo-compose-drawer') {{
// "My Karate" drawer: a comfortable width that keeps the stance cards readable
// on mobile (~200px) while still leaving most of the screen for the 3D view; a
// fixed width on desktop. The user can still drag it thinner/wider (persisted,
// down to DRAWER_MIN_W).
px = (w && w < 760) ? Math.min(204, Math.round(w * 0.62)) : 230;
}} else {{
const full = d.classList.contains('kimodo-drawer--wide') ? 460 : 330;
px = (w && w < 760) ? Math.round(w * 0.5) : full;
}}
// A width the user dragged-to wins over the responsive default (clamped so it
// still fits if the window since shrank).
const saved = widths[d.id];
if (saved) px = Math.max(DRAWER_MIN_W, Math.min(Math.round(saved), drawerMaxW()));
// !important so this measured/dragged width beats the narrow-screen
// `@media (max-width:700px)` 50vw-important rule (else the panel can't shrink).
d.style.setProperty('width', px + 'px', 'important');
}}
}}
function layoutTabs() {{
layoutDrawerWidth();
const open = openSideDrawer(); // bottom bar doesn't push the tab rail
tabs.classList.toggle('drawer-open', !!open);
tabs.style.left = open ? Math.round(open.getBoundingClientRect().width) + 'px' : '0';
positionResizer(); // park the resize handle at the (new) drawer edge
}}
function setOpen(drawer, open) {{
if (open) for (const d of drawers) if (d !== drawer) d.classList.remove('open');
drawer.classList.toggle('open', !!open);
for (const p of pairs) p.tab.classList.toggle('active', p.drawer.classList.contains('open'));
layoutTabs();
// Refresh the action->clip map each time the library opens so new takes show.
if (open && drawer.id === 'kimodo-actions-drawer') refreshClips().then(renderActions);
// Build/refresh the kata tree when its drawer opens (svg needs a layout size).
if (open && drawer.id === 'kimodo-kata-drawer') refreshKataTree();
// Load the stance library when its drawer opens.
if (open && drawer.id === 'kimodo-stances-drawer') refreshStances();
// Build the storyboard composer (palette + strip) when its drawer opens.
if (open && drawer.id === 'kimodo-compose-drawer') composeOpen();
// Pluggable tabs (tabs/*.tab.js) hook their open behavior off this event,
// so a new tab never has to edit setOpen. detail = the opened drawer's id.
if (open) window.dispatchEvent(new CustomEvent('kimodo-drawer-open', {{ detail: drawer.id }}));
// Shift the viewer camera to keep the character centered beside the open drawer.
sendViewportInset();
}}
for (const p of pairs) {{
p.tab.addEventListener('click', () => setOpen(p.drawer, !p.drawer.classList.contains('open')));
}}
document.addEventListener('pointerdown', (event) => {{
const open = openDrawer();
if (!open) return;
if (open.contains(event.target) || tabs.contains(event.target)) return;
if (resizerEl && resizerEl.contains(event.target)) return; // dragging the resize handle, not an outside click
const viewer = document.getElementById('main-preview');
if (viewer && viewer.contains(event.target)) return;
setOpen(open, false);
}});
window.addEventListener('resize', () => {{ layoutTabs(); sendViewportInset(); }});
window.addEventListener('orientationchange', () => {{ layoutTabs(); sendViewportInset(); }});
layoutDrawerWidth(); // set the responsive drawer width once at startup
// --- Clothing drawer behavior (one garment per slot -> state textbox + iframe). ---
const state = {{ ...defaultClothing }};
function writeAnonymousClientId() {{
const input = inputInside('anonymous-client-id');
if (!input) return;
let id = '';
try {{
id = window.localStorage.getItem('kimodoAnonymousClientId') || '';
if (!id) {{
const bytes = new Uint8Array(16);
window.crypto.getRandomValues(bytes);
id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
window.localStorage.setItem('kimodoAnonymousClientId', id);
}}
}} catch (err) {{
id = 'session-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
}}
input.value = id;
input.dispatchEvent(new Event('input', {{ bubbles: true }}));
input.dispatchEvent(new Event('change', {{ bubbles: true }}));
}}
function writeState() {{
const input = inputInside('clothing-state');
if (!input) return;
input.value = JSON.stringify(state);
input.dispatchEvent(new Event('input', {{ bubbles: true }}));
input.dispatchEvent(new Event('change', {{ bubbles: true }}));
}}
function sendClothing(slot, value) {{
const frame = previewFrame();
if (frame && frame.contentWindow) {{
frame.contentWindow.postMessage({{ kind: 'clothing', slot, value: value || '' }}, '*');
}}
}}
function syncButtons() {{
clothingDrawer.querySelectorAll('.kimodo-drawer-option').forEach((button) => {{
const slot = button.dataset.slot;
const id = button.dataset.id || '';
const on = (state[slot] || '') === id;
button.classList.toggle('on', on);
button.textContent = (on && id ? '✓ ' : '') + button.textContent.replace(/^✓\\s+/, '');
}});
}}
clothingDrawer.addEventListener('click', (event) => {{
const button = event.target.closest('.kimodo-drawer-option');
if (!button) return;
const slot = button.dataset.slot;
const id = button.dataset.id || '';
if (!slot) return;
if (id) state[slot] = id;
else delete state[slot];
writeState();
syncButtons();
sendClothing(slot, id);
}});
// --- Actions library: curated move templates (localStorage) -> clips. ---
{_ACTIONS_JS}
{_KATA_JS}
{_STANCES_JS}
{_COMPOSE_JS}
// --- Bottom-right HF account widget (login / account drawer). ---
{_ACCT_JS}
// --- Pluggable tabs (tabs/*.tab.js) — run in this same scope, after the helpers. ---
{_EXTRA_TABS_JS}
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 (this fires on start-up for the featured kata too):
// stitch its path client-side (stitchPath lives in the kata module) and play it.
// Remember which kata is on the MAIN viewer so the top-left name overlay can show
// it (read-only) when the compose drawer isn't driving its own name.
if (typeof setMainViewerKata === 'function') {{
const lbl = (msg.label || 'Kata').replace(/\s*·\s*\d+\s*moves?$/i, '');
setMainViewerKata({{ label: lbl, contributor: msg.contributor || '', root: msg.root || '' }});
}}
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();
}})();
"""
TITLE_OVERLAY_CSS = """
#kw-title-overlay {
position: fixed; inset: 0; z-index: 100020; overflow: hidden;
display: grid; place-items: center; background: transparent;
font-family: Impact, Haettenschweiler, "Arial Black", system-ui, sans-serif;
pointer-events: none;
}
#kw-title-overlay.kw-ready { pointer-events: auto; }
#kw-title-overlay.kw-done { pointer-events: none; }
#kw-title-overlay::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(circle at 50% 50%, transparent 0 24%, rgba(0,0,0,.08) 62%, rgba(0,0,0,.22) 100%),
linear-gradient(90deg, rgba(255,42,163,.08), transparent 25% 75%, rgba(66,246,255,.08));
}
.kw-sun {
position: absolute; z-index: 3; left: 50%; top: 45%; width: min(42vw, 460px);
aspect-ratio: 1; border-radius: 50%; opacity: 0; pointer-events: none;
transform: translate(-50%, -58%) scale(.72);
background:
repeating-linear-gradient(0deg, transparent 0 18px, rgba(7,2,15,.78) 19px 26px),
linear-gradient(180deg, #ffe96c, #ff8a26 46%, #ef2a7a 100%);
}
#kw-title-overlay.kw-exploded .kw-sun { animation: kwSunReveal .55s ease-out forwards; }
#kw-title-overlay.kw-vanish .kw-sun { animation: kwSunVanish .48s ease-in forwards; }
.kw-card {
position: relative; z-index: 6; width: min(94vw, 1080px); text-align: center;
transform: translateY(-1vh); overflow: visible;
}
.kw-logo {
position: relative; transform: translateX(-145vw) rotate(-1080deg) scale(.18);
opacity: 0; transform-origin: 50% 52%; overflow: visible;
}
#kw-title-overlay.kw-running .kw-logo { animation: kwLogoSpinIn .95s linear forwards; }
#kw-title-overlay.kw-vanish .kw-logo { animation: kwLogoVanish .48s cubic-bezier(.62,0,.9,.42) forwards; }
.kw-title {
margin: 0; display: flex; flex-direction: column; align-items: center;
text-transform: uppercase; line-height: .78; letter-spacing: 0;
font-size: clamp(72px, 15vw, 184px); color: #ffd84a;
-webkit-text-stroke: clamp(2px, .45vw, 6px) #2a032a;
text-shadow:
5px 5px 0 #8f143f,
10px 10px 0 #2a032a,
0 0 18px rgba(255,216,74,.65),
0 0 44px rgba(255,42,163,.85);
transform: rotate(-7deg) scale(1);
}
.kw-title span:last-child {
color: #ff5d2c;
text-shadow:
5px 5px 0 #7021a8,
10px 10px 0 #22022d,
0 0 18px rgba(255,107,25,.75),
0 0 46px rgba(66,246,255,.46);
}
#kw-title-overlay.kw-exploded .kw-title { animation: kwTitleImpact .82s ease-out forwards; }
.kw-blast { position: absolute; inset: 0; z-index: 4; width: 100%; height: 100%; pointer-events: none; }
.kw-grain, .kw-scanlines { position: absolute; inset: 0; z-index: 7; pointer-events: none; }
.kw-play {
position: absolute; z-index: 6; left: 50%; top: 50%;
transform: translate(-50%, -50%); display: none; align-items: center; justify-content: center;
width: clamp(78px, 19vw, 110px); aspect-ratio: 1; padding: 0; border: 0;
border-radius: 999px;
background:
linear-gradient(rgba(7,2,15,.14), rgba(7,2,15,.14)) padding-box,
linear-gradient(180deg, rgba(255,233,108,.58) 0%, rgba(255,155,47,.52) 43%, rgba(239,42,122,.5) 100%) border-box;
border: 4px solid transparent; backdrop-filter: blur(1px);
box-shadow: 0 0 0 1px rgba(42,3,42,.38), 0 0 18px rgba(239,42,122,.18), inset 0 0 20px rgba(255,155,47,.06);
cursor: pointer;
}
.kw-play::before {
content: ""; width: 0; height: 0; margin-left: 7%;
border-top: clamp(13px, 3.2vw, 18px) solid transparent;
border-bottom: clamp(13px, 3.2vw, 18px) solid transparent;
border-left: clamp(20px, 5vw, 29px) solid rgba(255,138,38,.58);
filter: drop-shadow(0 0 6px rgba(255,138,38,.22)) drop-shadow(0 0 12px rgba(239,42,122,.16));
}
.kw-play:active { transform: translate(-50%, -50%) scale(.94); filter: brightness(1.18); }
#kw-title-overlay.kw-ready .kw-play { display: inline-flex; animation: kwPlayIdle 1.45s ease-in-out infinite; }
#kw-title-overlay.kw-pressed .kw-play { display: inline-flex; animation: kwPlayPressed .22s ease-in forwards; }
#kw-title-overlay.kw-running .kw-play,
#kw-title-overlay.kw-vanish .kw-play,
#kw-title-overlay.kw-done .kw-play { display: none; }
.kw-grain {
opacity: .12;
background-image:
radial-gradient(circle at 15% 35%, #fff 0 1px, transparent 1px),
radial-gradient(circle at 68% 22%, #fff 0 1px, transparent 1px),
radial-gradient(circle at 45% 71%, #fff 0 1px, transparent 1px);
background-size: 37px 31px, 53px 47px, 71px 61px;
animation: kwGrain .3s steps(2,end) infinite;
}
.kw-scanlines { background: repeating-linear-gradient(0deg, rgba(255,255,255,.035) 0 1px, transparent 1px 4px); mix-blend-mode: screen; }
#kw-title-overlay.kw-filters-out .kw-grain,
#kw-title-overlay.kw-filters-out .kw-scanlines { opacity: 0; transition: opacity 1.8s ease; }
#kw-title-overlay.kw-flash { animation: kwScreenFlash .82s ease-out; }
@keyframes kwLogoSpinIn {
0% { opacity: 0; transform: translateX(-145vw) rotate(-1260deg) scale(.16); }
66% { opacity: 1; transform: translateX(-26vw) rotate(-520deg) scale(.82); }
91% { opacity: 1; transform: translateX(0) rotate(-94deg) scale(1.18); }
97% { opacity: 1; transform: translateX(0) rotate(-24deg) scale(1.24); }
100% { opacity: 1; transform: translateX(0) rotate(0deg) scale(1.18); }
}
@keyframes kwLogoVanish {
0% { opacity: 1; transform: translateX(0) rotate(0deg) scale(1.18); filter: brightness(1.15); }
100% { opacity: 0; transform: translateX(0) rotate(0deg) scale(.03); filter: brightness(2.4) blur(8px); }
}
@keyframes kwSunReveal {
0% { opacity: 0; transform: translate(-50%, -58%) scale(.72); }
22% { opacity: .95; transform: translate(-50%, -58%) scale(1.08); }
100% { opacity: .88; transform: translate(-50%, -58%) scale(1); }
}
@keyframes kwSunVanish {
from { opacity: .88; transform: translate(-50%, -58%) scale(1); }
to { opacity: 0; transform: translate(-50%, -58%) scale(.25); }
}
@keyframes kwTitleImpact {
0% { transform: rotate(-7deg) scale(1); filter: brightness(1) saturate(1.4); }
18% { transform: rotate(-4deg) scale(1.1); filter: brightness(3.2) saturate(2.2); }
34% { transform: rotate(-10deg) scale(1.03); filter: brightness(1.55) saturate(1.7); }
100% { transform: rotate(-7deg) scale(1); filter: brightness(1.16) saturate(1.48); }
}
@keyframes kwScreenFlash {
0% { box-shadow: inset 0 0 0 100vmax rgba(255,247,196,.82); }
18% { box-shadow: inset 0 0 0 100vmax rgba(255,88,24,.42); }
100% { box-shadow: inset 0 0 0 100vmax rgba(255,88,24,0); }
}
@keyframes kwPlayIdle {
0%, 100% { opacity: .54; transform: translate(-50%, -50%) scale(1); filter: saturate(1.02) brightness(.96); }
50% { opacity: .7; transform: translate(-50%, -50%) scale(1.06); filter: saturate(1.12) brightness(1.06); }
}
@keyframes kwPlayPressed {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); filter: brightness(1); }
42% { opacity: .96; transform: translate(-50%, -50%) scale(1.18); filter: brightness(1.45); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(.08); filter: brightness(2) blur(3px); }
}
@keyframes kwGrain { from { transform: translate(0,0); } to { transform: translate(-18px,11px); } }
@media (max-width: 620px) {
.kw-title { font-size: clamp(58px, 21vw, 112px); }
.kw-sun { width: 76vw; }
}
"""
_TITLE_OVERLAY_AUDIO_B64 = _load_b64(_TITLE_OVERLAY_AUDIO_PATH)
_TITLE_OVERLAY_AUDIO_DATA = (
"data:audio/wav;base64," + _TITLE_OVERLAY_AUDIO_B64
if _TITLE_OVERLAY_AUDIO_B64 else ""
)
_THEME_AUDIO_B64 = _load_b64(_THEME_AUDIO_PATH)
_THEME_AUDIO_DATA = (
"data:audio/mpeg;base64," + _THEME_AUDIO_B64
if _THEME_AUDIO_B64 else ""
)
TITLE_OVERLAY_JS = f"""
(() => {{
const AUDIO_SRC = {json.dumps(_TITLE_OVERLAY_AUDIO_DATA)};
const THEME_SRC = {json.dumps(_THEME_AUDIO_DATA)};
if (!AUDIO_SRC || window.__kwTitleOverlayInstalled) return;
window.__kwTitleOverlayInstalled = true;
// Looping theme that kicks in when the intro sting ends. Lives on window (not inside the overlay
// root, which is removed at ~5.6s) so it keeps playing. Started from the same user gesture that
// ran the intro, so autoplay policies allow it.
function startTheme() {{
if (!THEME_SRC || window.__kwThemeStarted) return;
window.__kwThemeStarted = true;
try {{
const theme = window.__kwTheme || (window.__kwTheme = new Audio());
theme.src = THEME_SRC;
theme.loop = true;
theme.volume = 0.35; // music ducked so the voices clearly cut through
theme.muted = !!window.__kwMusicMuted; // honor the mute toggle
theme.currentTime = 0;
theme.play().catch((error) => {{ console.warn('Karate Wiener theme blocked:', error); }});
}} catch (error) {{ console.warn('Karate Wiener theme error:', error); }}
}}
function install() {{
if (document.getElementById('kw-title-overlay')) return;
const root = document.createElement('div');
root.id = 'kw-title-overlay';
root.innerHTML = `
<canvas class="kw-blast" aria-hidden="true"></canvas>
<div class="kw-sun" aria-hidden="true"></div>
<section class="kw-card" aria-label="Karate Wiener title card">
<div class="kw-logo">
<h1 class="kw-title"><span>Karate</span><span>Wiener</span></h1>
</div>
</section>
<button class="kw-play" type="button" aria-label="Start Karate Wiener intro"></button>
<div class="kw-grain" aria-hidden="true"></div>
<div class="kw-scanlines" aria-hidden="true"></div>
<audio class="kw-audio" preload="auto"></audio>
`;
document.body.appendChild(root);
const audio = root.querySelector('.kw-audio');
const canvas = root.querySelector('.kw-blast');
const ctx = canvas.getContext('2d');
const mobile = window.matchMedia('(max-width: 700px), (pointer: coarse)').matches;
let particles = [];
let sparks = [];
let raf = 0;
let active = false;
let starting = false;
audio.src = AUDIO_SRC;
function resize() {{
const dpr = Math.min(mobile ? 1.35 : 2, window.devicePixelRatio || 1);
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
canvas.style.width = `${{window.innerWidth}}px`;
canvas.style.height = `${{window.innerHeight}}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}}
function rand(min, max) {{ return min + Math.random() * (max - min); }}
function makeExplosion() {{
const cx = window.innerWidth / 2;
const cy = window.innerHeight * 0.45;
particles = [];
sparks = [];
const pc = mobile ? 58 : 96;
const sc = mobile ? 96 : 170;
for (let i = 0; i < pc; i += 1) {{
const a = rand(0, Math.PI * 2);
const speed = rand(5, 24);
particles.push({{
x: cx + Math.cos(a) * rand(0, 56),
y: cy + Math.sin(a) * rand(0, 38),
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed - rand(0, 5),
r: rand(10, 54),
life: rand(.85, 1.55),
max: 1,
hue: rand(22, 58),
}});
}}
for (let i = 0; i < sc; i += 1) {{
const a = rand(-Math.PI * .95, Math.PI * 1.95);
const speed = rand(7, 38);
sparks.push({{
x: cx, y: cy,
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed,
life: rand(.4, 1.1),
len: rand(8, 30),
hue: rand(35, 62),
}});
}}
root.classList.add('kw-flash', 'kw-exploded');
window.setTimeout(() => root.classList.remove('kw-flash'), 900);
animateExplosion();
}}
function drawExplosion() {{
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
ctx.globalCompositeOperation = 'lighter';
particles.forEach((p) => {{
const alpha = Math.max(0, p.life / p.max);
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r);
grad.addColorStop(0, `hsla(${{p.hue}},100%,72%,${{.8 * alpha}})`);
grad.addColorStop(.38, `hsla(${{p.hue - 18}},100%,50%,${{.45 * alpha}})`);
grad.addColorStop(1, 'hsla(3,94%,30%,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}});
ctx.lineCap = 'round';
sparks.forEach((s) => {{
const alpha = Math.max(0, s.life);
ctx.strokeStyle = `hsla(${{s.hue}},100%,70%,${{alpha}})`;
ctx.lineWidth = 2.4 * alpha;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(s.x - s.vx * .7, s.y - s.vy * .7 + s.len);
ctx.stroke();
}});
ctx.globalCompositeOperation = 'source-over';
}}
function animateExplosion() {{
window.cancelAnimationFrame(raf);
let last = performance.now();
function tick(now) {{
const dt = Math.min(.033, (now - last) / 1000);
last = now;
particles.forEach((p) => {{
p.x += p.vx; p.y += p.vy;
p.vx *= .964; p.vy = p.vy * .96 + .22;
p.r *= 1.012; p.life -= dt;
}});
sparks.forEach((s) => {{
s.x += s.vx; s.y += s.vy;
s.vx *= .982; s.vy = s.vy * .98 + .5;
s.life -= dt * 1.18;
}});
particles = particles.filter((p) => p.life > 0);
sparks = sparks.filter((s) => s.life > 0);
drawExplosion();
if (particles.length || sparks.length) raf = window.requestAnimationFrame(tick);
else ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
}}
raf = window.requestAnimationFrame(tick);
}}
async function run() {{
if (active) return;
active = true;
root.classList.remove('kw-ready', 'kw-pressed', 'kw-exploded', 'kw-flash', 'kw-vanish', 'kw-filters-out', 'kw-done');
void root.offsetWidth;
root.classList.add('kw-running');
startTheme(); // roll the theme NOW — it takes a moment to spin up, so it leads the sting by ~2s
window.setTimeout(async () => {{
makeExplosion();
try {{
audio.muted = false;
audio.volume = 1;
audio.currentTime = 0;
await audio.play();
}} catch (error) {{
console.warn('Karate Wiener audio blocked:', error);
}}
}}, 2000);
window.setTimeout(() => root.classList.add('kw-vanish'), 4500);
window.setTimeout(() => root.classList.add('kw-filters-out'), 5000);
window.setTimeout(() => {{
root.classList.add('kw-done');
root.remove();
}}, 6650);
}}
resize();
window.addEventListener('resize', resize, {{ passive: true }});
root.addEventListener('click', async (event) => {{
if (!root.classList.contains('kw-ready') || starting) return;
starting = true;
event.preventDefault();
root.classList.add('kw-pressed');
window.setTimeout(run, 180);
}});
root.classList.add('kw-ready');
}}
// Top-right mute toggle for the music (mirrors tiny-army's button). Persists across reloads and
// applies live to the running theme.
function installMuteButton() {{
if (!THEME_SRC || document.getElementById('kw-music-mute')) return;
try {{ window.__kwMusicMuted = (localStorage.getItem('kwMusicMuted') === '1'); }} catch (e) {{}}
const btn = document.createElement('button');
btn.id = 'kw-music-mute';
btn.type = 'button';
btn.style.cssText = 'position:fixed;top:14px;right:14px;z-index:100000;width:40px;height:40px;border-radius:10px;border:1px solid #4a3a60;background:rgba(20,16,28,.82);color:#e6c7ff;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);';
const render = () => {{ btn.textContent = window.__kwMusicMuted ? '\\uD83D\\uDD07' : '\\uD83D\\uDD0A'; btn.title = window.__kwMusicMuted ? 'Unmute music' : 'Mute music'; }};
render();
btn.addEventListener('click', () => {{
window.__kwMusicMuted = !window.__kwMusicMuted;
try {{ localStorage.setItem('kwMusicMuted', window.__kwMusicMuted ? '1' : '0'); }} catch (e) {{}}
if (window.__kwTheme) window.__kwTheme.muted = window.__kwMusicMuted;
render();
}});
document.body.appendChild(btn);
}}
function boot() {{ install(); installMuteButton(); }}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
// Demo shortcut: press ` (or ~) to replay the title intro (brings back the play button) and
// re-arm the first-run tutorial so the WHO AM I tab pulses again. Ignored while typing.
window.addEventListener('keydown', function (e) {{
if (e.key !== '`' && e.key !== '~' && e.code !== 'Backquote') return;
var t = e.target || {{}};
var tag = (t.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || t.isContentEditable) return;
e.preventDefault();
try {{
if (window.__kwTheme) {{ window.__kwTheme.pause(); window.__kwTheme.currentTime = 0; }}
window.__kwThemeStarted = false; // let the replayed intro restart the theme
}} catch (err) {{}}
var existing = document.getElementById('kw-title-overlay');
if (existing) existing.remove(); // force a fresh overlay even if one lingers
install(); // brings back the intro + play button
try {{ window.dispatchEvent(new Event('kimodo-tutorial-restart')); }} catch (err) {{}}
}});
}})();
"""
APP_HEAD = (
# Warm the CDNs the viewer iframe pulls three.js (+ lazy spark) from. (head= is
# injected at hydration, so this is a best-effort warm-up; the boot splash that
# hides the pre-hydration flash lives in the SSR'd css= block instead — see
# _BLOCKS_CSS — because head= lands too late to cover the first paint.)
'<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>'
'<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">'
'<link rel="preconnect" href="https://unpkg.com" crossorigin>'
'<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>'
# APP_CSS + any pluggable-tab styles (tabs/<name>.tab.css), then the drawer JS.
f"<style>{APP_CSS}{_read_tabs('*.tab.css')}{TITLE_OVERLAY_CSS}</style>"
f"<script>{DRAWER_JS}</script><script>{TITLE_OVERLAY_JS}</script>"
)
def generate(
prompt,
seconds,
steps,
seed,
generation_model: str = DEFAULT_GENERATION_MODEL,
character: str = DEFAULT_CHARACTER,
clothing: dict | None = None,
creator: dict | None = None,
anonymous_client_id: str | None = None,
request: gr.Request | None = None,
):
try:
prompt, seconds, steps, seed = _coerce_generation_inputs(prompt, seconds, steps, seed)
generation_model = generation_model or DEFAULT_GENERATION_MODEL
record_id = _animation_id(prompt, seconds, steps, seed, generation_model)
cached = store.get(record_id)
if cached is not None:
payload = store.get_preview(record_id)
if payload is None or "joint_data_b64" not in payload:
payload = _preview_payload_from_record(cached)
payload["id"] = record_id
payload.setdefault("prompt", cached.get("prompt", prompt))
_copy_creator_fields(payload, cached)
npz_path = store.get_npz(record_id)
meta = {
"id": record_id,
"model": cached.get("model", generation_model),
"prompt": cached.get("prompt", prompt),
"seconds": cached.get("seconds", seconds),
"fps": cached.get("fps", 30.0),
"num_frames": cached.get("num_frames"),
"denoising_steps": cached.get("denoising_steps", steps),
"seed": cached.get("seed", seed),
"device": "cache",
"elapsed_seconds": 0,
"joints": int(payload.get("num_joints", 0)),
"cached": True,
}
_copy_creator_fields(meta, cached)
else:
quota = quota_store.check_and_consume(bucket_for_request(creator, request, anonymous_client_id))
if not quota.allowed:
raise gr.Error(quota.message)
if KIMODO_REMOTE_URL:
meta, npz_path, payload = _generate_uncached_remote(prompt, seconds, steps, seed, record_id, generation_model, creator or {})
else:
meta, npz_path, payload = _generate_uncached(prompt, seconds, steps, seed, record_id, generation_model, creator or {})
if not isinstance(payload, dict):
return meta, npz_path, _empty_preview_html("Generation failed.")
current_id = meta.get("id") if isinstance(meta, dict) else record_id
if int(payload.get("num_joints", 0) or 0) != 22:
character = "skeleton"
html_ = _render_preview_html(payload, character, clothing, _animation_list(), current_id)
return meta, npz_path, html_
except Exception as exc:
if isinstance(exc, gr.Error):
raise
return {
"error_type": type(exc).__name__,
"error": str(exc),
"traceback": traceback.format_exc(),
}, None, _empty_preview_html(str(exc))
def generate_continue(
prompt,
seconds,
steps,
seed,
source_id: str,
source_frame: int,
generation_model: str = DEFAULT_GENERATION_MODEL,
character: str = DEFAULT_CHARACTER,
clothing: dict | None = None,
creator: dict | None = None,
anonymous_client_id: str | None = None,
request: gr.Request | None = None,
):
"""Branch a new move from a frame of an existing clip (kata authoring). Mirrors
generate() -- deterministic cache, quota, then the constrained @GPU path."""
try:
source_id = (source_id or "").strip()
if not source_id:
raise gr.Error("source clip is required")
src_record = store.get(source_id)
if src_record is None:
raise gr.Error(f"source clip not found: {source_id}")
prompt, seconds, steps, seed = _coerce_generation_inputs(prompt, seconds, steps, seed)
generation_model = generation_model or DEFAULT_GENERATION_MODEL
frame = _resolve_frame(src_record, source_frame)
record_id = _continue_animation_id(source_id, frame, prompt, seconds, steps, seed, generation_model)
cached = store.get(record_id)
if cached is not None:
payload = store.get_preview(record_id)
if payload is None or "joint_data_b64" not in payload:
payload = _preview_payload_from_record(cached)
payload["id"] = record_id
payload.setdefault("prompt", cached.get("prompt", prompt))
_copy_creator_fields(payload, cached)
npz_path = store.get_npz(record_id)
meta = {
"id": record_id, "model": cached.get("model", generation_model),
"prompt": cached.get("prompt", prompt), "seconds": cached.get("seconds", seconds),
"fps": cached.get("fps", 30.0), "num_frames": cached.get("num_frames"),
"denoising_steps": cached.get("denoising_steps", steps), "seed": cached.get("seed", seed),
"device": "cache", "elapsed_seconds": 0, "joints": int(payload.get("num_joints", 0)),
"cached": True, "continues_from": cached.get("continues_from"),
}
_copy_creator_fields(meta, cached)
else:
quota = quota_store.check_and_consume(bucket_for_request(creator, request, anonymous_client_id))
if not quota.allowed:
raise gr.Error(quota.message)
if KIMODO_REMOTE_URL:
meta, npz_path, payload = _generate_continue_uncached_remote(
prompt, seconds, steps, seed, record_id, source_id, frame, src_record,
generation_model, creator or {},
)
else:
meta, npz_path, payload = _generate_continue_uncached(
prompt, seconds, steps, seed, record_id, source_id, frame, src_record,
generation_model, creator or {},
)
if not isinstance(payload, dict):
return meta, npz_path, _empty_preview_html("Generation failed.")
current_id = meta.get("id") if isinstance(meta, dict) else record_id
if int(payload.get("num_joints", 0) or 0) != 22:
character = "skeleton"
html_ = _render_preview_html(payload, character, clothing, _animation_list(), current_id)
return meta, npz_path, html_
except Exception as exc:
if isinstance(exc, gr.Error):
raise
return {
"error_type": type(exc).__name__,
"error": str(exc),
"traceback": traceback.format_exc(),
}, None, _empty_preview_html(str(exc))
def _continue_api(prompt, seconds, source_id, source_frame, request: gr.Request | None = None):
"""API-callable continue: returns just the meta (so a client sees error dicts)."""
try:
secs = float(seconds)
except (TypeError, ValueError):
secs = 2.2
try:
frame = int(float(source_frame))
except (TypeError, ValueError):
frame = 0
seed = random.randint(0, 2**31 - 1)
meta, _npz, _html = generate_continue(
prompt, secs, 60, seed, (source_id or "").strip(), frame, request=request,
)
return meta
def render_view(animation_id: str | None, character: str, clothing: dict):
"""Render the viewer for a saved clip (defaults to the newest) with the page's
character/clothing and the full animation list for the in-viewer picker."""
animations = _animation_list()
# On first load (no explicit clip), default to the newest KATA: render its
# opening move as the initial frame, but point the picker at "kata:<root>" so
# the viewer auto-plays the whole stitched sequence.
auto_current = None
if not animation_id:
katas = _kata_list()
if katas:
# An owner can pin a "featured" kata to play first; otherwise newest wins.
start = katas[0]
feat = _featured()
if feat.get("root"):
start = next((k for k in katas if k["root"] == feat["root"]), start)
animation_id = start["root"]
auto_current = "kata:" + start["root"]
elif animations:
animation_id = animations[0]["id"]
if not animation_id:
return _empty_preview_html("Generate an animation to preview it."), {}, None
payload = store.get_preview(animation_id)
if payload is None or "joint_data_b64" not in payload:
rec = store.get(animation_id)
payload = _preview_payload_from_record(rec) if rec else None
if payload is not None:
payload["id"] = animation_id
if payload is None:
return _empty_preview_html("Animation not found."), {}, None
meta = store.get_meta(animation_id) or {}
payload.setdefault("prompt", meta.get("prompt"))
_copy_creator_fields(payload, meta)
html_ = _render_preview_html(payload, character, clothing, animations, auto_current or animation_id)
return html_, _meta_subset(meta), store.get_npz(animation_id)
def list_animations(limit: int = 200):
return {"animations": store.list(int(limit or 200))}
def get_animation(animation_id: str):
animation_id = (animation_id or "").strip()
if not animation_id:
raise gr.Error("Animation id is required.")
record = store.get(animation_id)
if record is None:
raise gr.Error(f"Animation not found: {animation_id}")
return record
def delete_animation(animation_id: str):
animation_id = (animation_id or "").strip()
if not animation_id:
raise gr.Error("Animation id is required.")
if not store.delete(animation_id):
raise gr.Error(f"Animation not found: {animation_id}")
return {"deleted": animation_id}
def _current_username(profile: gr.OAuthProfile | None = None) -> str:
"""The signed-in HF username (or '' when anonymous). Surfaced to the Actions
drawer so it can show only this user's clips."""
return _creator_from_profile(profile).get("created_by", "") or ""
def _current_user_info(profile: gr.OAuthProfile | None = None) -> str:
"""Signed-in user's {username, name, avatar} as JSON for the account widget."""
c = _creator_from_profile(profile)
return json.dumps({
"username": c.get("created_by", "") or "",
"name": c.get("created_by_name", "") or "",
"avatar": c.get("created_by_avatar", "") or "",
})
def delete_my_animation(animation_id: str, profile: gr.OAuthProfile | None = None):
"""Delete a clip from the Actions drawer's del-x. Authorized when the caller
owns it (created_by == signed-in user) or it is an owner-less anonymous clip,
so a signed-in user can't delete someone else's attributed animation."""
animation_id = (animation_id or "").strip()
if not animation_id:
raise gr.Error("Animation id is required.")
owner = (store.get_meta(animation_id) or {}).get("created_by")
username = _current_username(profile)
if owner and owner != username:
raise gr.Error("You can only delete animations you generated.")
if not store.delete(animation_id):
raise gr.Error(f"Animation not found: {animation_id}")
return {"deleted": animation_id}
def preview_animation(animation_id: str):
animation_id = (animation_id or "").strip()
if not animation_id:
return _empty_preview_html("Select an animation id.")
preview_payload = store.get_preview(animation_id)
if preview_payload is not None and "joint_data_b64" in preview_payload:
meta = store.get_meta(animation_id)
if meta:
preview_payload.setdefault("prompt", meta.get("prompt"))
_copy_creator_fields(preview_payload, meta)
return _render_preview_html(preview_payload)
record = get_animation(animation_id)
return _render_preview_html(_preview_payload_from_record(record))
def initial_load(model: str, clothing_state: str):
return render_view(None, model, _clothing_from_json(clothing_state))
def generate_ui(
prompt,
seconds,
steps,
seed,
generation_model,
model,
clothing_state,
anonymous_client_id,
request: gr.Request | None = None,
):
meta, npz_path, preview_html = generate(
prompt,
seconds,
steps,
seed,
generation_model,
model,
_clothing_from_json(clothing_state),
{},
anonymous_client_id,
request,
)
return preview_html, meta, npz_path
def generate_ui_logged_in(
prompt,
seconds,
steps,
seed,
generation_model,
model,
clothing_state,
anonymous_client_id,
profile: gr.OAuthProfile | None = None,
request: gr.Request | None = None,
):
meta, npz_path, preview_html = generate(
prompt,
seconds,
steps,
seed,
generation_model,
model,
_clothing_from_json(clothing_state),
_creator_from_profile(profile),
anonymous_client_id,
request,
)
return preview_html, meta, npz_path
def regenerate_ui_logged_in(
regen_prompt,
regen_seconds,
steps,
generation_model,
model,
clothing_state,
anonymous_client_id,
profile: gr.OAuthProfile | None = None,
request: gr.Request | None = None,
):
"""Regenerate a move from the Actions drawer: same logged-in generate path,
but with a fresh random seed each call so every take is new (never a
deterministic cache hit)."""
try:
seconds = float(regen_seconds)
except (TypeError, ValueError):
seconds = 2.2
seed = random.randint(0, 2**31 - 1)
meta, npz_path, preview_html = generate(
regen_prompt,
seconds,
steps,
seed,
generation_model,
model,
_clothing_from_json(clothing_state),
_creator_from_profile(profile),
anonymous_client_id,
request,
)
return preview_html, meta, npz_path
def continue_ui_logged_in(
cont_prompt,
cont_seconds,
cont_source_id,
cont_source_frame,
steps,
generation_model,
model,
clothing_state,
anonymous_client_id,
profile: gr.OAuthProfile | None = None,
request: gr.Request | None = None,
):
"""Branch a new move from a frame of an existing clip (kata authoring), through
the same logged-in path with a fresh random seed."""
try:
seconds = float(cont_seconds)
except (TypeError, ValueError):
seconds = 2.2
try:
frame = int(float(cont_source_frame))
except (TypeError, ValueError):
frame = 0
seed = random.randint(0, 2**31 - 1)
meta, npz_path, preview_html = generate_continue(
cont_prompt,
seconds,
steps,
seed,
(cont_source_id or "").strip(),
frame,
generation_model,
model,
_clothing_from_json(clothing_state),
_creator_from_profile(profile),
anonymous_client_id,
request,
)
return preview_html, meta, npz_path
def _compose_single_move_logged_in(
parsed, generation_model, model, clothing_state, anonymous_client_id, profile, request,
):
"""Per-move GENERATE on the Space: generate ONE /generate_between move and return
the kata-so-far (the already-built prefix loaded from the store + the new move,
stitched). The in-process twin of the local per-move fetch — one @GPU call per
GENERATE, not a whole-batch rebuild. Payload: {move, prefix_ids, recipe?, owner?}."""
mv = parsed.get("move") or {}
prefix_ids = [str(x) for x in (parsed.get("prefix_ids") or []) if x]
is_root = not prefix_ids
kind = str(mv.get("kind") or "stance")
kfs = mv.get("keyframes") or []
if kind != "prompt" and len(kfs) < 2:
raise gr.Error("move needs 2 keyframes (start + end stance)")
# The recipe-so-far is stamped on EVERY move (so the chain's LAST move always holds
# the complete kata — the root captured it before later moves existed). Owner + fork
# lineage stay on the ROOT only. MY KARATE / community read the longest recipe in the chain.
root_extra = {}
if parsed.get("recipe"):
root_extra["compose_recipe"] = parsed["recipe"]
if is_root:
if parsed.get("owner"):
root_extra["compose_owner"] = str(parsed["owner"])[:64]
ff = parsed.get("forked_from")
if isinstance(ff, dict) and (ff.get("root") or ff.get("contributor")):
root_extra["compose_forked_from"] = {
"root": str(ff.get("root") or "")[:64],
"contributor": str(ff.get("contributor") or "")[:64],
"contributor_name": str(ff.get("contributor_name") or "")[:80],
}
generation_model = generation_model or DEFAULT_GENERATION_MODEL
creator = _creator_from_profile(profile)
quota = quota_store.check_and_consume(bucket_for_request(creator, request, anonymous_client_id))
if not quota.allowed:
raise gr.Error(quota.message)
prompt, seconds, steps, seed = _coerce_generation_inputs(
mv.get("prompt"), mv.get("seconds", 2.2), mv.get("steps", 60), -1
)
record_id = "kmd_%016x" % random.getrandbits(64)
prev_id = prefix_ids[-1] if prefix_ids else None
rx = root_extra if root_extra else None
if kind == "prompt":
# NORMAL move: a from-scratch text->motion for the root, else continue from the
# previous move's end frame. These primitives return (meta, npz, payload) — re-load
# the saved record from the store for the stitch step below.
if is_root:
meta, npz_path, payload = _generate_uncached(
prompt, seconds, steps, seed, record_id, generation_model, creator or {}, root_extra=rx,
)
else:
src_record = store.get(prev_id)
if src_record is None:
return gr.skip(), {"error": "previous move not found for continuation"}, None
meta, npz_path, payload = _generate_continue_uncached(
prompt, seconds, steps, seed, record_id, prev_id, -1, src_record,
generation_model, creator or {}, root_extra=rx,
)
record = store.get(record_id) if isinstance(payload, dict) else None
else:
meta, npz_path, payload, record = _generate_between_uncached(
prompt, seconds, steps, seed, record_id, kfs, prev_id, generation_model, creator or {},
root_extra=rx,
)
if not isinstance(payload, dict) or record is None:
err = meta.get("error") if isinstance(meta, dict) else "generation failed"
# gr.skip() leaves the viewer iframe untouched (no reload) — surface the error
# through meta instead; the composer JS shows it via setComposeStatus.
return gr.skip(), {"error": str(err)[:160]}, None
# Stitch the generated prefix (loaded from the store — store.get reads the
# freshly-committed records reliably) + the new move, so the viewer shows the
# kata up to here without waiting on dataset/CDN propagation.
recs = []
for rid in prefix_ids:
r = store.get(rid)
if r is not None:
recs.append(r)
recs.append(record)
stitched = _stitch_records(recs)
# Ship the FULL frame count (no downsample) so the client's scrubber/timeline — which
# maps move seams in full-frame space — covers every move, including the one just added.
spayload = _preview_payload_from_record(stitched, max_frames=int(stitched["num_frames"]))
spine_ids = list(prefix_ids) + [record["id"]]
n = len(spine_ids)
spayload["prompt"] = f"kata · {n} move{'' if n == 1 else 's'}"
spayload["id"] = record["id"]
# REUSE the existing viewer iframe instead of returning fresh HTML: returning HTML to
# the `preview` output made Gradio swap #main-preview, fully reloading three.js + the
# character GLB + clothing on every move. Hand the server-stitched preview to the client
# (via meta.preview_payload) so it postMessages it straight into the live iframe, and
# gr.skip() the preview so the iframe is never touched.
meta_out = {
"kata_root": spine_ids[0], "kata_ids": spine_ids, "moves": n,
"num_frames": stitched["num_frames"], "move_index": int(mv.get("index") or n),
"preview_payload": spayload,
}
if creator:
meta_out.update(creator)
return gr.skip(), meta_out, npz_path
def compose_ui_logged_in(
payload_json,
generation_model,
model,
clothing_state,
anonymous_client_id,
profile: gr.OAuthProfile | None = None,
request: gr.Request | None = None,
):
"""Storyboard composer (in-process): each consecutive stance pair becomes one
/generate_between move (both ends pinned), chained via continues_from; the chain
is stitched and the whole sequence loaded into the viewer. Used on the deployed
Space, where there is no HTTP backend for the JS to call directly. A single-move
payload ({move, prefix_ids, ...}) routes to the per-move twin above."""
try:
parsed = json.loads(payload_json or "[]")
except (TypeError, ValueError):
raise gr.Error("bad storyboard payload")
# Single-move bridge (per-move GENERATE on the Space): one move at a time.
if isinstance(parsed, dict) and parsed.get("move") is not None:
return _compose_single_move_logged_in(
parsed, generation_model, model, clothing_state,
anonymous_client_id, profile, request,
)
# Batch payload is {moves, recipe, owner}; a bare list is the legacy moves-only form.
if isinstance(parsed, dict):
moves = parsed.get("moves") or []
recipe = parsed.get("recipe")
owner = parsed.get("owner")
else:
moves, recipe, owner = parsed, None, None
if not isinstance(moves, list) or not moves:
raise gr.Error("storyboard needs at least one move (two stances)")
# Recipe (+ owning browser) is stamped on the kata's ROOT move so MY KARATE can
# list it and reopen it for editing.
root_extra = {}
if recipe:
root_extra["compose_recipe"] = recipe
if owner:
root_extra["compose_owner"] = str(owner)[:64]
generation_model = generation_model or DEFAULT_GENERATION_MODEL
creator = _creator_from_profile(profile)
recs, prev_id, npz_last = [], None, None
for i, mv in enumerate(moves):
prompt, seconds, steps, seed = _coerce_generation_inputs(
mv.get("prompt"), mv.get("seconds", 2.2), mv.get("steps", 60), -1
)
kfs = mv.get("keyframes") or []
if len(kfs) < 2:
raise gr.Error(f"move {i + 1} needs 2 keyframes")
quota = quota_store.check_and_consume(bucket_for_request(creator, request, anonymous_client_id))
if not quota.allowed:
raise gr.Error(quota.message)
record_id = "kmd_%016x" % random.getrandbits(64)
meta, npz_path, payload, record = _generate_between_uncached(
prompt, seconds, steps, seed, record_id, kfs, prev_id, generation_model, creator or {},
root_extra=(root_extra if i == 0 else None),
)
if not isinstance(payload, dict) or record is None:
err = meta.get("error") if isinstance(meta, dict) else "generation failed"
return gr.skip(), {"error": f"Build failed at move {i + 1}: {str(err)[:140]}"}, None
prev_id = record["id"]
recs.append(record)
npz_last = npz_path
stitched = _stitch_records(recs)
spayload = _preview_payload_from_record(stitched, max_frames=int(stitched["num_frames"]))
spayload["prompt"] = f"storyboard · {len(recs)} moves"
spayload["id"] = recs[-1]["id"]
# Reuse the live viewer iframe (no reload): hand the server-stitched preview to the
# client to postMessage in, and gr.skip() the preview output (see the per-move twin).
meta_out = {"kata_root": recs[0]["id"], "kata_ids": [r["id"] for r in recs], "moves": len(recs),
"num_frames": stitched["num_frames"], "preview_payload": spayload}
if creator:
meta_out.update(creator)
return gr.skip(), meta_out, npz_last
# Collapse the empty drawer host (its content is fixed-positioned) so it leaves
# no bar/line at the top of the page.
_BLOCKS_CSS = """
#drawer-host { height: 0 !important; min-height: 0 !important; padding: 0 !important;
margin: 0 !important; border: 0 !important; overflow: visible !important; }
/* --- Critical pre-hydration rules. This `css=` block is SERVER-SIDE-RENDERED into
<head> (unlike `head=`, which Gradio injects only at client hydration). Putting the
chrome-hide + viewer sizing here means the Gradio controls are hidden and the page
doesn't scroll/jump from the FIRST paint, so the controls never flash before the JS
loads. (The same rules also live in APP_CSS for the hydrated state — harmless dup.) */
.kimodo-chrome { display: none !important; }
html, body, gradio-app, .gradio-container { height: 100%; margin: 0; overflow: hidden; }
.gradio-container { padding: 0 !important; max-width: 100% !important; }
#main-preview { position: fixed; inset: 0; z-index: 1; overflow: hidden; }
/* --- Boot splash: a full-screen overlay drawn purely from CSS (no JS or injected DOM,
both of which arrive too late), so it covers everything on the first SSR paint while
the viewer boots. Removed by adding `kimodo-ready` to <body> once the viewer signals
it's up (kimodo-preview-ready / kimodo-viewer-error) or a 15s safety timeout — see
__kimodoHideSplash in DRAWER_JS. The dark backdrop matches the viewer background so
the hand-off is seamless. --- */
body::before { content: ""; position: fixed; inset: 0; z-index: 100000; background: #151719;
opacity: 1; visibility: visible; transition: opacity .5s ease, visibility 0s linear .5s; }
body::after { content: ""; position: fixed; top: 50%; left: 50%; width: 30px; height: 30px;
margin: -15px 0 0 -15px; z-index: 100001; border: 3px solid #2a3038; border-top-color: #7dd3fc;
border-radius: 50%; animation: kimodo-splash-spin .8s linear infinite;
opacity: 1; visibility: visible; transition: opacity .5s ease, visibility 0s linear .5s; }
body.kimodo-ready::before, body.kimodo-ready::after { opacity: 0; visibility: hidden; }
@keyframes kimodo-splash-spin { to { transform: rotate(360deg); } }
"""
# Shown in the collapsed "Prompting Tips" section under the duration slider.
# Condensed from docs/prompting-guide.md.
PROMPTING_TIPS_MD = """\
**Kimodo was trained on captions that all start with "A person…"** — write your
prompt the same way and the moves come out much better.
- **Format:** `A person <verb>s <left/right limb> <direction>, <quality>.`
— third person, present tense, ~10–12 words, ending with a period.
- **Name the side & direction** — "right fist", "left leg", "forward", "to the left".
- **One move per clip.** For a combo, generate each move and chain them with **+ kata**.
- **Add a quality word** for feel — "fast", "sharp", "forceful", "smooth".
- **Avoid** bare phrases (*roundhouse kick*), commands (*throw a punch*), and vague
combos — they come out wrong or barely move.
✅ *A person throws a fast roundhouse kick with the right leg.*
❌ *roundhouse kick* — the model may not even lift the foot.
A kick needs ~1.5–2.5 s to land; to make it look fast, let it play back quickly
(the viewer defaults to 1.5×) rather than generating a 1-second clip.
"""
with gr.Blocks(title="Kimodo ZeroGPU", css=_BLOCKS_CSS) as demo:
# Drawer content is position:fixed; its Gradio wrapper would otherwise leave
# an empty bar at the top, so collapse the host to zero height.
gr.HTML(DRAWER_HTML, elem_id="drawer-host")
# State-only inputs, hidden via CSS. container=False keeps them out of a
# bordered .form group (which would otherwise draw a gray line at the top).
clothing_state = gr.Textbox(
value=json.dumps(DEFAULT_CLOTHING, separators=(",", ":")),
label="Clothing state",
elem_id="clothing-state",
container=False,
)
anonymous_client_id = gr.Textbox(
value="",
label="Anonymous client id",
elem_id="anonymous-client-id",
container=False,
)
# Hidden trigger the Actions drawer drives to regenerate a move (fresh seed)
# through the same logged-in generate path (quota + attribution intact).
regen_prompt = gr.Textbox(value="", label="Regen prompt", elem_id="regen-prompt", container=False)
regen_seconds = gr.Textbox(value="2.2", label="Regen seconds", elem_id="regen-seconds", container=False)
regen_btn = gr.Button("Regenerate move", elem_id="regen-btn")
# Signed-in username surfaced to the Actions drawer (filter to my clips).
current_user = gr.Textbox(value="", label="Current user", elem_id="current-user", container=False)
# {username, name, avatar} JSON for the bottom-right account widget.
current_user_info = gr.Textbox(value="", label="Current user info", elem_id="current-user-info", container=False)
# Hidden trigger the del-x uses to delete a clip (ownership-checked server-side).
del_id = gr.Textbox(value="", label="Delete id", elem_id="del-id", container=False)
del_btn = gr.Button("Delete move", elem_id="del-btn")
del_out = gr.JSON(visible=False)
# Hidden trigger for kata authoring: branch a move from a frame of a clip.
cont_prompt = gr.Textbox(value="", label="Continue prompt", elem_id="cont-prompt", container=False)
cont_seconds = gr.Textbox(value="2.2", label="Continue seconds", elem_id="cont-seconds", container=False)
cont_source_id = gr.Textbox(value="", label="Continue source id", elem_id="cont-source-id", container=False)
cont_source_frame = gr.Textbox(value="0", label="Continue source frame", elem_id="cont-source-frame", container=False)
cont_btn = gr.Button("Continue move", elem_id="cont-btn")
# Hidden trigger the Storyboard composer drives: a JSON list of moves (each with
# canon'd keyframes) -> in-process generate_between chain -> stitch -> viewer.
# MUST be top-level (not inside an accordion) so the drawer JS can reliably find
# #compose-btn via querySelector and click it (same pattern as cont-btn/regen-btn).
compose_payload = gr.Textbox(value="", label="Compose payload", elem_id="compose-payload", container=False)
compose_btn = gr.Button("Build storyboard", elem_id="compose-btn")
dojo_payload = gr.Textbox(value="", label="Dojo payload", elem_id="dojo-payload", container=False)
dojo_result = gr.Textbox(value="", label="Dojo result", elem_id="dojo-result", container=False)
dojo_btn = gr.Button("Generate dojo scene", elem_id="dojo-btn")
# tiny-aya prompt helper: JSON {"words": "..."} -> JSON {"ok", "prompt"}.
dojo_suggest_payload = gr.Textbox(value="", label="Dojo suggest payload", elem_id="dojo-suggest-payload", container=False)
dojo_suggest_result = gr.Textbox(value="", label="Dojo suggest result", elem_id="dojo-suggest-result", container=False)
dojo_suggest_btn = gr.Button("Suggest dojo prompt", elem_id="dojo-suggest-btn")
# Save a generated dojo into the dataset (tagged with the contributor).
dojo_save_payload = gr.Textbox(value="", label="Dojo save payload", elem_id="dojo-save-payload", container=False)
dojo_save_result = gr.Textbox(value="", label="Dojo save result", elem_id="dojo-save-result", container=False)
dojo_save_btn = gr.Button("Save dojo", elem_id="dojo-save-btn")
# Local-only: publish a completed local kata (clips fetched from the remote) into
# the dataset so it appears in Community. Inert on the Space (katas auto-publish there).
kata_pub_payload = gr.Textbox(value="", label="Kata publish payload", elem_id="kata-publish-payload", container=False)
kata_pub_result = gr.Textbox(value="", label="Kata publish result", elem_id="kata-publish-result", container=False)
kata_pub_btn = gr.Button("Publish kata", elem_id="kata-publish-btn")
# Owner-only: pin a kata as the featured (start-up) one. set_featured re-checks the
# caller is an owner (OWNER_USERNAMES) before writing viewer/featured.json.
kata_feat_payload = gr.Textbox(value="", label="Kata featured payload", elem_id="kata-featured-payload", container=False)
kata_feat_result = gr.Textbox(value="", label="Kata featured result", elem_id="kata-featured-result", container=False)
kata_feat_btn = gr.Button("Set featured kata", elem_id="kata-featured-btn")
# Owner-only: delete a community move/kata. delete_kata re-checks the caller is an owner.
kata_del_payload = gr.Textbox(value="", label="Kata delete payload", elem_id="kata-delete-payload", container=False)
kata_del_result = gr.Textbox(value="", label="Kata delete result", elem_id="kata-delete-result", container=False)
kata_del_btn = gr.Button("Delete kata", elem_id="kata-delete-btn")
# Community upvote: anyone can toggle a vote on a kata. vote_kata dedupes by username
# (signed-in) or anon id and writes viewer/votes.json.
kata_vote_payload = gr.Textbox(value="", label="Kata vote payload", elem_id="kata-vote-payload", container=False)
kata_vote_result = gr.Textbox(value="", label="Kata vote result", elem_id="kata-vote-result", container=False)
kata_vote_btn = gr.Button("Vote kata", elem_id="kata-vote-btn")
# Chat with Karate Wiener (WHO AM I tab): one turn -> {reply, audio} via the tiny-aya +
# VoxCPM sidecar Spaces.
wiener_chat_payload = gr.Textbox(value="", label="Wiener chat payload", elem_id="wiener-chat-payload", container=False)
wiener_chat_result = gr.Textbox(value="", label="Wiener chat result", elem_id="wiener-chat-result", container=False)
wiener_chat_btn = gr.Button("Wiener chat", elem_id="wiener-chat-btn")
# On-demand voice for the 🔊 speak button (VoxCPM clone of one message).
wiener_speak_payload = gr.Textbox(value="", label="Wiener speak payload", elem_id="wiener-speak-payload", container=False)
wiener_speak_result = gr.Textbox(value="", label="Wiener speak result", elem_id="wiener-speak-result", container=False)
wiener_speak_btn = gr.Button("Wiener speak", elem_id="wiener-speak-btn")
# Viewer is the only visible component — it fills the screen. Everything else
# is wrapped in .kimodo-chrome and hidden (kept in the DOM so the drawers can
# still read its inputs / fire its hidden buttons).
preview = gr.HTML(label="3D Viewer", value=_empty_preview_html("Loading saved animations..."), elem_id="main-preview")
prompt = gr.Textbox(label="Prompt", lines=3, value=DEFAULT_PROMPT, elem_classes="kimodo-chrome")
seconds = gr.Slider(label="Seconds", minimum=0.5, maximum=10.0, step=0.5, value=4.0, elem_classes="kimodo-chrome")
with gr.Accordion("Prompting Tips", open=False, elem_classes="kimodo-chrome"):
gr.Markdown(PROMPTING_TIPS_MD)
# Quota lives in the button label (updated on load + after each generate).
btn = gr.Button("Generate", variant="primary", elem_id="generate-btn", elem_classes="kimodo-chrome")
gr.LoginButton(value="Sign in", logout_value="Sign out", elem_id="kimodo-login", elem_classes="kimodo-chrome")
with gr.Accordion("Advanced settings", open=False, elem_classes="kimodo-chrome"):
steps = gr.Slider(label="Denoising steps", minimum=2, maximum=100, step=1, value=60)
seed = gr.Number(label="Seed (-1 for random)", value=42, precision=0)
generation_model_dd = gr.Dropdown(
GENERATION_MODELS,
value=DEFAULT_GENERATION_MODEL,
label="Motion skeleton",
)
model_dd = gr.Dropdown(
[(c["label"], c["id"]) for c in _CHARACTER_CATALOG if c["id"] == "skeleton" or c.get("glb_b64")],
value=DEFAULT_CHARACTER, label="Display model",
)
with gr.Accordion("Kata API Debug", open=False, elem_classes="kimodo-chrome"):
gr.Markdown("Utility calls for the kata backend. These are primarily meant for API clients.")
meta = gr.JSON(label="Metadata")
file_out = gr.File(label="Motion NPZ")
with gr.Row():
list_limit = gr.Number(label="List limit", value=50, precision=0)
list_btn = gr.Button("List animations")
list_out = gr.JSON(label="Animations")
list_btn.click(list_animations, inputs=[list_limit], outputs=[list_out], api_name="list_animations")
with gr.Row():
api_id = gr.Textbox(label="Animation id")
get_btn = gr.Button("Get")
preview_btn = gr.Button("Preview")
delete_btn = gr.Button("Delete")
get_out = gr.JSON(label="Animation record")
preview_out = gr.HTML(label="Stored animation preview", value=_empty_preview_html("Select an animation id."))
delete_out = gr.JSON(label="Delete result")
get_btn.click(get_animation, inputs=[api_id], outputs=[get_out], api_name="get_animation")
preview_btn.click(preview_animation, inputs=[api_id], outputs=[preview_out], api_name="preview_animation")
delete_btn.click(delete_animation, inputs=[api_id], outputs=[delete_out], api_name="delete_animation")
alias_meta = gr.JSON(visible=False)
alias_file = gr.File(visible=False)
alias_preview = gr.HTML(visible=False)
generate_root_alias_btn = gr.Button("Generate root API alias", visible=False)
generate_root_alias_btn.click(
fn=generate,
inputs=[prompt, seconds, steps, seed],
outputs=[alias_meta, alias_file, alias_preview],
api_name="generate_root",
)
generate_alias_btn = gr.Button("Generate API alias", visible=False)
generate_alias_btn.click(
fn=generate,
inputs=[prompt, seconds, steps, seed],
outputs=[alias_meta, alias_file, alias_preview],
api_name="generate",
)
# API alias for kata authoring so the continue result (incl. error dicts)
# is callable/inspectable via gradio_client.
continue_alias_btn = gr.Button("Continue API alias", visible=False)
continue_alias_btn.click(
fn=_continue_api,
inputs=[cont_prompt, cont_seconds, cont_source_id, cont_source_frame],
outputs=[alias_meta],
api_name="generate_continue",
)
# Event wiring (after all components — meta/file_out live in the debug accordion).
settings_inputs = [model_dd, clothing_state]
demo.load(fn=initial_load, inputs=settings_inputs, outputs=[preview, meta, file_out], show_progress="hidden")
# Quota/login state lives in the Generate button label: set on load and
# refreshed after each generate (runs even when a generation was blocked).
demo.load(fn=_generate_button_label, inputs=[anonymous_client_id], outputs=[btn])
# Surface the signed-in username so the Actions drawer can filter to my clips.
demo.load(fn=_current_username, inputs=[], outputs=[current_user])
demo.load(fn=_current_user_info, inputs=[], outputs=[current_user_info])
# del-x trigger -> ownership-checked delete.
del_btn.click(fn=delete_my_animation, inputs=[del_id], outputs=[del_out], api_name=False)
btn.click(
fn=generate_ui_logged_in,
inputs=[prompt, seconds, steps, seed, generation_model_dd, *settings_inputs, anonymous_client_id],
outputs=[preview, meta, file_out],
show_progress="hidden", # the app shows its own loaders; suppress Gradio's overlay on the viewer
api_name=False,
).then(
fn=_generate_button_label, inputs=[anonymous_client_id], outputs=[btn], api_name=False,
)
# Actions-drawer regenerate: hidden trigger -> same logged-in generate path.
regen_btn.click(
fn=regenerate_ui_logged_in,
inputs=[regen_prompt, regen_seconds, steps, generation_model_dd, *settings_inputs, anonymous_client_id],
outputs=[preview, meta, file_out],
show_progress="hidden",
api_name=False,
).then(
fn=_generate_button_label, inputs=[anonymous_client_id], outputs=[btn], api_name=False,
)
# Kata authoring: branch a move from a frame of an existing clip.
cont_btn.click(
fn=continue_ui_logged_in,
inputs=[cont_prompt, cont_seconds, cont_source_id, cont_source_frame, steps,
generation_model_dd, *settings_inputs, anonymous_client_id],
outputs=[preview, meta, file_out],
show_progress="hidden",
api_name=False,
).then(
fn=_generate_button_label, inputs=[anonymous_client_id], outputs=[btn], api_name=False,
)
# Storyboard composer: build a kata from a stance sequence (in-process).
compose_btn.click(
fn=compose_ui_logged_in,
inputs=[compose_payload, generation_model_dd, *settings_inputs, anonymous_client_id],
outputs=[preview, meta, file_out],
show_progress="hidden",
api_name=False,
).then(
# Hand the build result (incl. kata spine ids) to the composer JS so it can
# record the kata (with replayable ids) under Mine.
None, inputs=[meta], outputs=[], api_name=False,
js="(m) => { if (window.__kimodoOnComposeBuilt) window.__kimodoOnComposeBuilt(m); }",
).then(
fn=_generate_button_label, inputs=[anonymous_client_id], outputs=[btn], api_name=False,
)
# ===================== NEW-TAB BACKEND EXTENSION POINT =====================
# Front-end-only tabs need NOTHING here (drop files in tabs/). A tab that needs
# a Python/@GPU backend adds its hidden gr components + .click wiring BELOW this
# line (mirror the compose_btn pattern: a hidden #<name>-btn the tab JS clicks,
# outputs routed to [preview, meta, file_out]). Append only — don't edit above.
# ==========================================================================
dojo_btn.click(
fn=generate_dojo_scene,
inputs=[dojo_payload],
outputs=[dojo_result],
api_name=False,
)
dojo_suggest_btn.click(
fn=improve_dojo_prompt,
inputs=[dojo_suggest_payload],
outputs=[dojo_suggest_result],
api_name=False,
)
dojo_save_btn.click(
fn=save_dojo,
inputs=[dojo_save_payload],
outputs=[dojo_save_result],
api_name=False,
)
kata_pub_btn.click(
fn=publish_kata,
inputs=[kata_pub_payload],
outputs=[kata_pub_result],
api_name=False,
)
kata_feat_btn.click(
fn=set_featured,
inputs=[kata_feat_payload],
outputs=[kata_feat_result],
api_name=False,
)
kata_del_btn.click(
fn=delete_kata,
inputs=[kata_del_payload],
outputs=[kata_del_result],
api_name=False,
)
kata_vote_btn.click(
fn=vote_kata,
inputs=[kata_vote_payload],
outputs=[kata_vote_result],
api_name=False,
)
wiener_chat_btn.click(
fn=wiener_chat,
inputs=[wiener_chat_payload],
outputs=[wiener_chat_result],
api_name=False,
)
wiener_speak_btn.click(
fn=wiener_speak,
inputs=[wiener_speak_payload],
outputs=[wiener_speak_result],
api_name=False,
)
# Display model drives the persistent viewer live via postMessage (no reload).
# Clothing is handled by the fixed browser drawer so it can behave like kata's drawers.
model_dd.change(
None, inputs=[model_dd], outputs=[],
js="(v) => { const f = document.querySelector('#main-preview iframe');"
" if (f && f.contentWindow) f.contentWindow.postMessage({kind:'model', value:v}, '*'); }",
)
if __name__ == "__main__":
launch_kwargs = {}
if os.environ.get("SERVER_PORT"):
launch_kwargs["server_port"] = int(os.environ["SERVER_PORT"])
if os.environ.get("SERVER_NAME"):
launch_kwargs["server_name"] = os.environ["SERVER_NAME"]
demo.queue().launch(head=APP_HEAD, **launch_kwargs)