Spaces:
Running on Zero
Running on Zero
| 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">☰</button> | |
| <div id="creator-label"></div> | |
| <button id="camera-lock" title="Camera locked to character" aria-label="Camera locked to character">🔒</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 ? '🔒' : '🔓'; | |
| 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 | |
| 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] | |
| ) | |
| 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] | |
| ) | |
| 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 & 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 & 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, '<') + '”</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) | |