Kimodo Bot commited on
Commit
298ec39
·
1 Parent(s): 2d7ce22

Qwen loop: colored timeline blue→red→yellow→green + stop button + spinner

Browse files
Files changed (1) hide show
  1. app.py +243 -182
app.py CHANGED
@@ -1,220 +1,281 @@
1
- """Movimento Space UI: hackathon planner shell + Kimodo native 3D viewer."""
2
-
3
  from __future__ import annotations
4
 
5
  import json
6
  import os
7
- from typing import Any
 
 
 
8
 
9
  import gradio as gr
10
 
11
  try:
12
  import spaces # type: ignore
13
- except Exception: # pragma: no cover
14
  class _SpacesFallback:
15
  @staticmethod
16
  def GPU(*args, **kwargs):
17
  def _decorator(fn):
18
  return fn
19
-
20
  return _decorator
21
-
22
  spaces = _SpacesFallback()
23
 
24
-
25
- def _parse_character_ids(raw: str, count: int) -> list[str]:
26
- items = [part.strip() for part in (raw or "").split(",") if part.strip()]
27
- if not items:
28
- items = [f"char_{i + 1}" for i in range(count)]
29
- if len(items) < count:
30
- items.extend(f"char_{i + 1}" for i in range(len(items), count))
31
- return items[:count]
32
-
33
-
34
- def plan_script(scene_id: str, prompt: str, characters: int, character_ids_raw: str, transition: str, duration_sec: int) -> str:
35
- cleaned = (prompt or "").strip() or "Two characters wave and then walk together"
36
- count = max(1, int(characters))
37
- ids = _parse_character_ids(character_ids_raw, count)
38
-
39
- scripts: dict[str, list[dict[str, Any]]] = {}
40
- segment_duration = max(1.0, float(duration_sec) / 2.0)
41
- for idx, cid in enumerate(ids):
42
- target = ids[(idx + 1) % len(ids)] if len(ids) > 1 else None
43
- scripts[cid] = [
44
- {
45
- "segment_id": 0,
46
- "action_text": f"{cid} starts: {cleaned}",
47
- "duration_sec": segment_duration,
48
- "transition_policy": "smooth",
49
- "interaction_target": target,
50
- },
51
- {
52
- "segment_id": 1,
53
- "action_text": f"{cid} continues with {transition} transition",
54
- "duration_sec": segment_duration,
55
- "transition_policy": transition,
56
- "interaction_target": target,
57
- },
58
- ]
59
-
60
- payload = {
61
- "scene_id": scene_id.strip() or "space_scene",
62
- "status": "success",
63
- "scripts": scripts,
64
- "total_duration_sec": float(duration_sec),
65
- "metadata": {
66
- "source": "movimento_space_hackathon_shell",
67
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
- return json.dumps(payload, indent=2)
70
-
71
 
72
- @spaces.GPU(duration=60)
73
- def _execute_scene_gpu(script_json: str, fps: int, seed: int) -> tuple[str, dict[str, Any], str]:
74
- if not script_json.strip():
75
- return "", {"timeline": []}, "Execution failed: no script"
76
 
77
- try:
78
- payload = json.loads(script_json)
79
- except json.JSONDecodeError as exc:
80
- return "", {"timeline": []}, f"Execution failed: invalid JSON ({exc})"
81
-
82
- scripts = payload.get("scripts") or {}
83
- characters = list(scripts.keys()) if isinstance(scripts, dict) else []
84
- frame_count = max(1, int(fps) * 4)
85
- timeline = [{"frame": i, "state_hash": f"{seed:04d}-{i:05d}"} for i in range(frame_count)]
86
-
87
- summary = {
88
- "scene_id": payload.get("scene_id", "space_scene"),
89
- "characters": characters,
90
- "planner_status": payload.get("status", "unknown"),
91
- "state_hash_count": len(timeline),
92
- "interaction_count": max(0, len(characters) - 1),
93
- "viewer": "Use the Kimodo Native 3D tab for full motion visualization",
94
- }
95
- status = (
96
- f"Execution OK | chars={len(characters)} "
97
- f"frames={summary['state_hash_count']} interactions={summary['interaction_count']}"
98
  )
99
- return json.dumps(summary, indent=2), {"timeline": timeline}, status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
 
 
 
 
 
 
 
 
101
 
102
- def execute_scene(script_json: str, fps: int, seed: int) -> tuple[str, dict[str, Any], str]:
103
- return _execute_scene_gpu(script_json, fps, seed)
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
- def render_frame(frame_idx: int, playback_state: dict[str, Any]) -> str:
107
- timeline = playback_state.get("timeline") or []
108
- if not timeline:
109
- return "No execution timeline yet. Click Execute Scene first."
110
- bounded = max(0, min(int(frame_idx), len(timeline) - 1))
111
- frame = timeline[bounded]
112
- return f"Frame {frame['frame']} | state_hash={frame['state_hash']}"
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
- def _update_slider(playback_state: dict[str, Any]) -> gr.Slider:
116
- timeline = playback_state.get("timeline") or []
117
- max_frame = max(0, len(timeline) - 1)
118
- return gr.Slider(label="Frame", minimum=0, maximum=max_frame, value=0, step=1)
119
 
 
 
120
 
121
- def _prev_frame(current: float) -> float:
122
- return max(0, int(current) - 1)
 
 
 
123
 
 
 
 
 
 
 
124
 
125
- def _next_frame(current: float, playback_state: dict[str, Any]) -> float:
126
- max_frame = max(0, len((playback_state or {}).get("timeline") or []) - 1)
127
- return min(max_frame, int(current) + 1)
128
 
 
 
129
 
130
- def _kimodo_iframe_html() -> str:
131
- src = os.environ.get("KIMODO_UI_URL", "https://nvidia-kimodo.hf.space").strip()
132
- return (
133
- "<div style='border:1px solid #d9e7ef;border-radius:12px;overflow:hidden'>"
134
- f"<iframe src='{src}' title='Kimodo Native UI' style='width:100%;border:0' height='820' loading='lazy'></iframe>"
135
- "</div>"
136
  )
137
 
138
 
139
- with gr.Blocks(title="Movimento") as demo:
140
- gr.Markdown("# Movimento")
141
- gr.Markdown("Hackathon module: prompt planning + execution trace + Kimodo native 3D visualization")
142
-
143
- with gr.Tabs():
144
- with gr.Tab("Hackathon Copilot"):
145
- playback_state = gr.State({"timeline": []})
146
-
147
- with gr.Row():
148
- scene_id = gr.Textbox(label="Scene ID", value="space_scene")
149
- seed = gr.Number(label="Seed", value=42, precision=0)
150
- fps = gr.Slider(label="FPS", minimum=10, maximum=60, value=30, step=1)
151
-
152
- with gr.Row():
153
- prompt = gr.Textbox(label="Scene Prompt", lines=4, placeholder="Two characters greet and sit down")
154
-
155
- with gr.Row():
156
- characters = gr.Slider(label="Characters", minimum=1, maximum=6, step=1, value=2)
157
- character_ids = gr.Textbox(label="Character IDs (comma-separated)", value="lead,support")
158
- transition = gr.Dropdown(
159
- label="Transition Policy",
160
- choices=["smooth", "overlap", "hold", "cut"],
161
- value="smooth",
162
- )
163
- duration_sec = gr.Slider(label="Duration (sec)", minimum=10, maximum=120, step=5, value=30)
164
-
165
- plan_btn = gr.Button("Plan Script", variant="primary")
166
- script_preview = gr.Code(label="Script Preview (JSON)", language="json")
167
- status = gr.Textbox(label="Status", interactive=False)
168
-
169
- execute_btn = gr.Button("Execute Scene")
170
- summary = gr.Code(label="Execution Summary", language="json")
171
-
172
- with gr.Row():
173
- frame_slider = gr.Slider(label="Frame", minimum=0, maximum=1, value=0, step=1)
174
- frame_info = gr.Textbox(label="Playback", interactive=False)
175
-
176
- prev_btn = gr.Button("Prev Frame")
177
- next_btn = gr.Button("Next Frame")
178
-
179
- plan_btn.click(
180
- plan_script,
181
- inputs=[scene_id, prompt, characters, character_ids, transition, duration_sec],
182
- outputs=[script_preview],
183
- ).then(
184
- lambda: "Planner: SUCCESS",
185
- outputs=[status],
186
- )
187
-
188
- execute_btn.click(
189
- execute_scene,
190
- inputs=[script_preview, fps, seed],
191
- outputs=[summary, playback_state, status],
192
- ).then(
193
- _update_slider,
194
- inputs=[playback_state],
195
- outputs=[frame_slider],
196
- ).then(
197
- render_frame,
198
- inputs=[frame_slider, playback_state],
199
- outputs=[frame_info],
200
- )
201
-
202
- frame_slider.change(render_frame, inputs=[frame_slider, playback_state], outputs=[frame_info])
203
- prev_btn.click(_prev_frame, inputs=[frame_slider], outputs=[frame_slider]).then(
204
- render_frame,
205
- inputs=[frame_slider, playback_state],
206
- outputs=[frame_info],
207
- )
208
- next_btn.click(_next_frame, inputs=[frame_slider, playback_state], outputs=[frame_slider]).then(
209
- render_frame,
210
- inputs=[frame_slider, playback_state],
211
- outputs=[frame_info],
212
- )
213
-
214
- with gr.Tab("Kimodo Native 3D Viewer"):
215
- gr.Markdown("Full 3D character visualization is provided by the Kimodo native UI below.")
216
- gr.HTML(_kimodo_iframe_html())
217
-
218
-
219
  if __name__ == "__main__":
220
  demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")))
 
1
+ """Movimento single-character multi-text-prompt loop powered by Qwen on Fireworks."""
 
2
  from __future__ import annotations
3
 
4
  import json
5
  import os
6
+ import re
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Generator
10
 
11
  import gradio as gr
12
 
13
  try:
14
  import spaces # type: ignore
15
+ except Exception:
16
  class _SpacesFallback:
17
  @staticmethod
18
  def GPU(*args, **kwargs):
19
  def _decorator(fn):
20
  return fn
 
21
  return _decorator
 
22
  spaces = _SpacesFallback()
23
 
24
+ # ---------------------------------------------------------------------------
25
+ # Config
26
+ # ---------------------------------------------------------------------------
27
+ _MODEL = "accounts/fireworks/models/qwen3p6-27b"
28
+ _BASE = "https://api.fireworks.ai/inference/v1"
29
+
30
+ # Colors cycle per batch: blue red → yellow → green
31
+ _BATCH_COLORS = ["#4a90d9", "#e05252", "#f0b429", "#4caf50"]
32
+
33
+ _SYSTEM = """\
34
+ You are a motion-description writer for a single humanoid character in a 3D animation system.
35
+ Given a scene context and the character's recent motion history, output ONLY a JSON object:
36
+
37
+ {"texts": ["<action phrase 1>", ...], "durations": [<seconds float>, ...]}
38
+
39
+ Rules:
40
+ - Return 3 to 5 short, vivid action phrases that flow naturally from each other.
41
+ - Each phrase describes one distinct physical motion (e.g. "walks forward briskly", "pivots left and crouches").
42
+ - Each duration is between 2.0 and 8.0 seconds.
43
+ - texts and durations must have the same length.
44
+ - Do NOT repeat phrases from history.
45
+ - Return raw JSON only — no markdown, no explanation.
46
+ """
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Qwen via Fireworks
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def _call_qwen(messages: list[dict]) -> str:
53
+ api_key = os.environ.get("FIREWORKS_API_KEY", "").strip()
54
+ if not api_key:
55
+ raise RuntimeError("FIREWORKS_API_KEY is not set")
56
+ body = json.dumps({
57
+ "model": _MODEL,
58
+ "messages": messages,
59
+ "max_tokens": 400,
60
+ "temperature": 0.85,
61
+ }).encode()
62
+ req = urllib.request.Request(
63
+ f"{_BASE}/chat/completions",
64
+ data=body,
65
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
66
+ method="POST",
67
+ )
68
+ try:
69
+ with urllib.request.urlopen(req, timeout=40) as r:
70
+ return json.loads(r.read())["choices"][0]["message"]["content"]
71
+ except urllib.error.HTTPError as e:
72
+ raise RuntimeError(f"Fireworks {e.code}: {e.read().decode(errors='ignore')}") from e
73
+
74
+
75
+ def _parse(raw: str) -> dict:
76
+ text = raw.strip()
77
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
78
+ text = m.group(1) if m else text
79
+ s, e = text.find("{"), text.rfind("}")
80
+ return json.loads(text[s:e + 1])
81
+
82
+
83
+ def _fallback_batch(offset: int) -> dict:
84
+ phrases = [
85
+ "walks forward at a steady pace",
86
+ "turns smoothly to the left",
87
+ "pauses and surveys the surroundings",
88
+ "steps forward and gestures expressively",
89
+ "crouches down then rises back up",
90
+ "sidesteps to the right with purpose",
91
+ ]
92
+ n = len(phrases)
93
+ return {
94
+ "texts": [phrases[(offset + i) % n] for i in range(3)],
95
+ "durations": [3.0, 3.5, 3.0],
96
  }
 
 
97
 
 
 
 
 
98
 
99
+ @spaces.GPU(duration=120)
100
+ def _generate_next_batch(scene: str, history_json: str) -> tuple[str, str]:
101
+ history: list[str] = json.loads(history_json) if history_json else []
102
+ user_msg = (
103
+ f"Scene: {scene or 'a character moving continuously in 3D space'}\n"
104
+ f"Motion history (do not repeat): {json.dumps(history[-12:])}\n\n"
105
+ "Generate the next batch of motion prompts."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  )
107
+ try:
108
+ raw = _call_qwen([{"role": "system", "content": _SYSTEM}, {"role": "user", "content": user_msg}])
109
+ batch = _parse(raw)
110
+ if not isinstance(batch.get("texts"), list) or not isinstance(batch.get("durations"), list):
111
+ raise ValueError("Missing texts or durations in response")
112
+ n = min(len(batch["texts"]), len(batch["durations"]))
113
+ batch["texts"] = batch["texts"][:n]
114
+ batch["durations"] = batch["durations"][:n]
115
+ except Exception as exc: # noqa: BLE001
116
+ batch = _fallback_batch(len(history))
117
+ batch["_error"] = str(exc)
118
+
119
+ new_history = history + list(batch["texts"])
120
+ return json.dumps(batch), json.dumps(new_history)
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Timeline renderer
125
+ # ---------------------------------------------------------------------------
126
+
127
+ def _render_timeline(segments: list[dict]) -> str:
128
+ """Render a Kimodo-style horizontal colored timeline from all segments so far."""
129
+ if not segments:
130
+ return (
131
+ "<div style='padding:20px;color:#6b7280;font-family:monospace;background:#111827;"
132
+ "border-radius:10px;text-align:center;font-size:13px'>"
133
+ "Click Generate — prompts will appear as a coloured timeline</div>"
134
+ )
135
+
136
+ total_dur = sum(s["duration"] for s in segments) or 1.0
137
+
138
+ blocks = []
139
+ for seg in segments:
140
+ pct = max(4.0, (seg["duration"] / total_dur) * 100)
141
+ color = seg["color"]
142
+ dur_label = f"{seg['duration']:.1f}s"
143
+ text = seg["text"]
144
+ blocks.append(
145
+ f"<div style='flex:{pct:.2f};min-width:90px;background:{color};border-radius:6px;"
146
+ f"margin-right:4px;padding:7px 10px;box-sizing:border-box;overflow:hidden;"
147
+ f"display:flex;flex-direction:column;justify-content:center'>"
148
+ f"<span style='color:rgba(255,255,255,0.85);font-size:11px;font-weight:700;"
149
+ f"font-family:monospace'>{dur_label}</span>"
150
+ f"<span style='color:#fff;font-size:12px;white-space:nowrap;overflow:hidden;"
151
+ f"text-overflow:ellipsis;margin-top:3px'>{text}</span>"
152
+ f"</div>"
153
+ )
154
 
155
+ return (
156
+ "<div style='background:#111827;border-radius:10px;padding:14px;overflow-x:auto'>"
157
+ "<div style='font-size:11px;color:#6b7280;font-family:monospace;margin-bottom:8px;"
158
+ "letter-spacing:0.08em'>PROMPTS</div>"
159
+ f"<div style='display:flex;flex-direction:row;align-items:stretch;min-height:68px'>"
160
+ + "".join(blocks) +
161
+ "</div></div>"
162
+ )
163
 
 
 
164
 
165
+ # ---------------------------------------------------------------------------
166
+ # Generator — loops until Gradio cancels on Stop click
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def generate_loop(
170
+ scene: str,
171
+ history_json: str,
172
+ segments_json: str,
173
+ batch_idx_json: str,
174
+ ) -> Generator:
175
+ history = history_json or "[]"
176
+ segments: list[dict] = json.loads(segments_json) if segments_json else []
177
+ batch_idx: int = json.loads(batch_idx_json) if batch_idx_json else 0
178
+
179
+ while True:
180
+ color = _BATCH_COLORS[batch_idx % len(_BATCH_COLORS)]
181
+
182
+ # Emit "thinking" status before the GPU call so the user sees feedback
183
+ yield (
184
+ _render_timeline(segments),
185
+ f"⏳ Generating batch {batch_idx + 1}…",
186
+ history,
187
+ json.dumps(segments),
188
+ json.dumps(batch_idx),
189
+ )
190
+
191
+ batch_json, history = _generate_next_batch(scene, history)
192
+ batch = json.loads(batch_json)
193
+ texts = batch.get("texts", [])
194
+ durations = batch.get("durations", [])
195
+ error = batch.get("_error")
196
+
197
+ for t, d in zip(texts, durations):
198
+ segments.append({"text": t, "duration": d, "color": color})
199
+
200
+ status = (
201
+ f"✓ Batch {batch_idx + 1} — {len(texts)} prompts via {_MODEL.split('/')[-1]}"
202
+ if not error
203
+ else f"⚠ Batch {batch_idx + 1} fallback — {error}"
204
+ )
205
+
206
+ batch_idx += 1
207
+ yield (
208
+ _render_timeline(segments),
209
+ status,
210
+ history,
211
+ json.dumps(segments),
212
+ json.dumps(batch_idx),
213
+ )
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # UI
218
+ # ---------------------------------------------------------------------------
219
+
220
+ _KIMODO_SRC = os.environ.get("KIMODO_UI_URL", "https://nvidia-kimodo.hf.space").strip()
221
+
222
+ _empty_timeline = _render_timeline([])
223
 
224
+ with gr.Blocks(title="Movimento") as demo:
 
 
 
 
 
 
225
 
226
+ gr.HTML("""
227
+ <div style="background:linear-gradient(135deg,#0d3b66,#1b6ca8);padding:16px 22px;
228
+ border-radius:10px;margin-bottom:10px">
229
+ <h2 style="color:#fff;margin:0;font-size:22px">Movimento</h2>
230
+ <p style="color:#c8e6ff;margin:4px 0 0;font-size:13px">
231
+ Single character &middot; <strong>Qwen3&#8209;p6&nbsp;27B</strong> on Fireworks AI &middot;
232
+ Generates motion prompts continuously — click&nbsp;<strong>Stop</strong> to pause
233
+ </p>
234
+ </div>
235
+ """)
236
+
237
+ history_state = gr.State("[]")
238
+ segments_state = gr.State("[]")
239
+ batch_idx_state = gr.State("0")
240
+
241
+ scene_box = gr.Textbox(
242
+ label="Scene / character context",
243
+ value="A lone figure moving through an empty plaza",
244
+ lines=2,
245
+ )
246
 
247
+ with gr.Row():
248
+ generate_btn = gr.Button("▶ Generate", variant="primary", scale=4, min_width=160)
249
+ stop_btn = gr.Button("⏹ Stop", variant="stop", scale=1, min_width=100)
250
+ clear_btn = gr.Button("✕ Clear", variant="secondary", scale=1, min_width=100)
251
 
252
+ timeline_html = gr.HTML(_empty_timeline)
253
+ status_line = gr.Textbox(label="Status", interactive=False, lines=1)
254
 
255
+ gr.HTML(
256
+ "<div style='border:1px solid #c0d8ea;border-radius:10px;overflow:hidden;margin-top:14px'>"
257
+ f"<iframe src='{_KIMODO_SRC}' style='width:100%;border:0' height='820' "
258
+ "loading='lazy' title='Kimodo 3D'></iframe></div>"
259
+ )
260
 
261
+ # Wiring
262
+ click_event = generate_btn.click(
263
+ fn=generate_loop,
264
+ inputs=[scene_box, history_state, segments_state, batch_idx_state],
265
+ outputs=[timeline_html, status_line, history_state, segments_state, batch_idx_state],
266
+ )
267
 
268
+ stop_btn.click(fn=None, cancels=[click_event])
 
 
269
 
270
+ def _clear():
271
+ return _render_timeline([]), "", "[]", "[]", "0"
272
 
273
+ clear_btn.click(
274
+ fn=_clear,
275
+ inputs=[],
276
+ outputs=[timeline_html, status_line, history_state, segments_state, batch_idx_state],
 
 
277
  )
278
 
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  if __name__ == "__main__":
281
  demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")))