multimodalart HF Staff commited on
Commit
cb5eccc
Β·
verified Β·
1 Parent(s): 71dab2f

Restore working harness app (SFT prompt, per-step GPU requests); add bird example

Browse files
Files changed (4) hide show
  1. README.md +13 -13
  2. app.py +504 -399
  3. packages.txt +1 -0
  4. requirements.txt +3 -4
README.md CHANGED
@@ -6,26 +6,26 @@ colorTo: pink
6
  sdk: gradio
7
  sdk_version: 6.31.0
8
  app_file: app.py
9
- short_description: Agent writes Blender code to build a draft video
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
12
  license: apache-2.0
13
  models:
14
  - Video-Coder/Video-Coder-9B
15
- - MiniMaxAI/MiniMax-H3
16
  ---
17
 
18
- # Video-Coder: Reinforcing Agentic Coding for Video Generation
19
 
20
- Demo of [Video-Coder-9B](https://huggingface.co/Video-Coder/Video-Coder-9B) ([paper](https://huggingface.co/papers/2611.09894),
21
- [code](https://github.com/CYWang735/Video-Coder)). The agent runs in a port of the authors' **Video-Coder Harness**:
22
- it writes a Blender 4.5 Python script for your scene description, checks and renders it with `check_script` /
23
- `run_blender` (bpy 4.5.14, Workbench, Mesa llvmpipe on CPU), inspects the rendered frames, revises, and submits. The
24
- submitted script is re-rendered from scratch and becomes the **draft video**.
25
 
26
- Optional stage 2 sends the draft to MiniMax-H3 Ref2VA with the authors' white-model prompt template through the public
27
- [multimodalart/minimax-h3-reference](https://huggingface.co/spaces/multimodalart/minimax-h3-reference) Space (H3 is
28
- ~196 GB in bf16, too large to live in this Space alongside the agent).
 
29
 
30
- `harness/` contains code copied from the Video-Coder repository (Apache-2.0). Example prompts are the authors' own
31
- qualitative examples from the project page.
 
 
 
 
6
  sdk: gradio
7
  sdk_version: 6.31.0
8
  app_file: app.py
9
+ short_description: Agent writes Blender code to build a 3D draft video
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
12
  license: apache-2.0
13
  models:
14
  - Video-Coder/Video-Coder-9B
 
15
  ---
16
 
17
+ # Video-Coder
18
 
19
+ Demo of [Video-Coder: Reinforcing Agentic Coding for Video Generation](https://huggingface.co/papers/2611.09894)
20
+ ([code](https://github.com/CYWang735/Video-Coder), [model](https://huggingface.co/Video-Coder/Video-Coder-9B)).
 
 
 
21
 
22
+ **Video-Coder-9B** runs inside a port of the authors' *Video-Coder Harness*: it writes a Blender 4.5 Python script
23
+ for the scene description, renders it (`run_blender`, bpy 4.5.14 / Workbench on CPU), looks at the rendered frames,
24
+ fixes problems and submits. The output is the **draft video** (white-model render with the scene's layout, motion and
25
+ camera).
26
 
27
+ The paper then turns the draft video into a final video with MiniMax-H3 (Ref2VA). That ~33B audio-video generator
28
+ (plus its large text encoder) does not fit alongside the agent in this Space, so this demo stops at the draft video.
29
+
30
+ The harness code in `harness/` is taken from the Video-Coder repository (`Video-Coder-RL/uni-agent`, Apache-2.0).
31
+ Example prompts are from the Video-Coder project page and repository.
app.py CHANGED
@@ -1,27 +1,19 @@
1
- """Video-Coder: an agent that writes Blender code to build a draft video (Video-Coder-9B + the Video-Coder Harness).
2
-
3
- Port of the authors' inference path (examples/video_coder in https://github.com/CYWang735/Video-Coder):
4
- * same system / instance prompts, tool (`bash`), observation and format-error templates (harness/teacher.yaml),
5
- * same `check_script` / `run_blender` tools (copied harness code: bpy 4.5.14, Workbench on Mesa llvmpipe, CPU),
6
- with visual feedback on (the rendered frames go back to the model as images), as in their inference config,
7
- * same sampling (temperature 1.0, top_p 0.95) and acceptance (submit + static check + fresh re-render).
8
- The policy runs with transformers on ZeroGPU, one GPU call per agent turn; Blender runs on the CPU in between.
9
- Stage 2 (optional) sends the draft to MiniMax-H3 Ref2VA with the authors' prompt template through a public Space.
10
- """
11
-
12
  import os
13
 
14
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
15
 
16
- import spaces # noqa: E402 (before torch)
17
 
 
18
  import difflib # noqa: E402
 
 
19
  import json # noqa: E402
20
  import re # noqa: E402
21
  import shutil # noqa: E402
 
22
  import subprocess # noqa: E402
23
  import sys # noqa: E402
24
- import sysconfig # noqa: E402
25
  import tempfile # noqa: E402
26
  import threading # noqa: E402
27
  import time # noqa: E402
@@ -30,64 +22,90 @@ from pathlib import Path # noqa: E402
30
 
31
  import gradio as gr # noqa: E402
32
  import torch # noqa: E402
33
- import yaml # noqa: E402
34
  from jinja2 import StrictUndefined, Template # noqa: E402
 
35
  from transformers import AutoModelForImageTextToText, AutoProcessor, TextIteratorStreamer # noqa: E402
36
 
37
- APP_DIR = Path(__file__).resolve().parent
38
- HARNESS_ROOT = APP_DIR / "harness"
39
  sys.path.insert(0, str(HARNESS_ROOT))
40
-
41
- from guard import _normalize_output, harness_violation # noqa: E402
42
- from video_coder_harness.blender_exec import execute # noqa: E402
 
 
 
 
 
 
 
43
  from video_coder_harness.static_check import check_file # noqa: E402
44
 
45
  MODEL_ID = "Video-Coder/Video-Coder-9B"
46
- H3_SPACE = os.environ.get("H3_SPACE", "multimodalart/minimax-h3-reference")
47
- RUNS_DIR = Path(tempfile.gettempdir()) / "video_coder_runs"
48
- RUNS_DIR.mkdir(parents=True, exist_ok=True)
49
 
50
  # --------------------------------------------------------------------------------------------------------------------
51
- # Blender: bpy 4.5.14 needs Python 3.11, the Space runs 3.12 -> a separate uv-managed 3.11 env, as in the harness
52
- # (`bpy_python` is its own interpreter there too).
53
  # --------------------------------------------------------------------------------------------------------------------
54
- BPY_ENV = Path(os.environ.get("BPY_ENV", str(Path.home() / "bpy45")))
55
- BPY_PYTHON = BPY_ENV / "bin" / "python"
56
 
57
 
58
  def setup_bpy() -> None:
59
- if BPY_PYTHON.exists() and subprocess.run([str(BPY_PYTHON), "-c", "import bpy"], capture_output=True).returncode == 0:
60
- return
61
- uv = shutil.which("uv") or os.path.join(sysconfig.get_path("scripts"), "uv")
 
 
 
62
  t0 = time.time()
63
- subprocess.run([uv, "venv", "-q", "-p", "3.11", str(BPY_ENV)], check=True)
64
- subprocess.run([uv, "pip", "install", "-q", "--python", str(BPY_PYTHON), "bpy==4.5.14"], check=True)
65
- print(f"[bpy] installed bpy 4.5.14 in {time.time() - t0:.0f}s", flush=True)
 
 
 
66
 
67
 
68
  setup_bpy()
69
 
70
- _cfg = yaml.safe_load((HARNESS_ROOT / "teacher.yaml").read_text())
71
- # Overlay of the authors' inference config (configs/uni_container_visual.yaml): system Mesa, visual feedback on.
72
- HARNESS = {**_cfg["harness"], "bpy_python": str(BPY_PYTHON), "bpy_libs": "/usr", "visual_feedback": True}
73
- AGENT_CFG = _cfg["agent"]
74
- MODEL_CFG = _cfg["model"]
75
- ENV_CFG = _cfg["environment"]
76
- SUBMIT = "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"
77
- TEMPLATE_VARS = {
78
- "blender_version": HARNESS["blender_version"],
79
- "blender_available": HARNESS["blender_available"],
80
- "fps": HARNESS["fps"],
81
- "frame_count": HARNESS["frame_count"],
82
- "max_frame_count": HARNESS.get("max_frame_count", HARNESS["frame_count"]),
83
- "resolution": HARNESS["resolution"],
84
- "script_name": HARNESS["script_name"],
85
- "output_video": HARNESS["output_video"],
86
  "visual_feedback": True,
87
- "preview_frames": HARNESS.get("preview_frames", 8),
88
- "judge_enabled": False,
 
 
 
 
 
 
 
89
  }
90
- BASH_TOOL = { # mini-swe-agent's tool definition (minisweagent.models.utils.actions_toolcall.BASH_TOOL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  "type": "function",
92
  "function": {
93
  "name": "bash",
@@ -98,422 +116,509 @@ BASH_TOOL = { # mini-swe-agent's tool definition (minisweagent.models.utils.act
98
  "required": ["command"],
99
  },
100
  },
 
 
 
 
 
 
101
  }
102
- _SECRET_ENV = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
103
- _TAG = re.compile(r"<MSWEA_MULTIMODAL_CONTENT><CONTENT_TYPE>(.+?)</CONTENT_TYPE>(.+?)</MSWEA_MULTIMODAL_CONTENT>", re.S)
104
- GUARD_MARKERS = [str(HARNESS_ROOT), str(BPY_ENV)]
105
-
106
-
107
- def render_template(name_or_text: str, **kw) -> str:
108
- return Template(name_or_text, undefined=StrictUndefined).render(**kw)
109
-
110
-
111
- def preflight() -> None:
112
- """The harness' own Blender preflight (10% resolution): fails loudly in the logs if bpy/Mesa cannot render."""
113
- from video_coder_harness import blender_exec # noqa: F401
114
-
115
- script = """import bpy, os
116
- s = bpy.context.scene
117
- s.frame_start, s.frame_end = 1, {frames}
118
- s.render.engine = 'BLENDER_WORKBENCH'
119
- s.render.resolution_x, s.render.resolution_y, s.render.resolution_percentage = {w}, {h}, 10
120
- s.render.fps = {fps}
121
- cube = bpy.data.objects['Cube']
122
- cube.location = (0, 0, 0); cube.keyframe_insert('location', frame=1)
123
- cube.location = (2, 0, 0); cube.keyframe_insert('location', frame={frames})
124
- s.render.image_settings.file_format = 'FFMPEG'
125
- s.render.ffmpeg.format = 'MPEG4'
126
- s.render.ffmpeg.codec = 'H264'
127
- s.render.filepath = os.path.join(os.getcwd(), '{out}')
128
- bpy.ops.render.render(animation=True)
129
- """
130
- w, h = HARNESS["resolution"]
131
- with tempfile.TemporaryDirectory() as tmp:
132
- p = Path(tmp) / "preflight.py"
133
- p.write_text(script.format(frames=HARNESS["frame_count"], w=w, h=h, fps=HARNESS["fps"], out=HARNESS["output_video"]))
134
- r = execute(p, tmp, dict(HARNESS, resolution=[w // 10, h // 10]), preview=False)
135
- print(f"[bpy] preflight ok={r['ok']} in {r['seconds']}s (cpus={os.cpu_count()}) problems={r['problems']} "
136
- f"error={r.get('error')} tb={r.get('traceback')} log={r.get('log_tail')}", flush=True)
137
-
138
-
139
- preflight()
140
 
141
  # --------------------------------------------------------------------------------------------------------------------
142
- # Policy: Video-Coder-9B (Qwen3.5-9B, SFT + agentic RL)
143
  # --------------------------------------------------------------------------------------------------------------------
144
  processor = AutoProcessor.from_pretrained(MODEL_ID)
 
145
  model = AutoModelForImageTextToText.from_pretrained(MODEL_ID, dtype=torch.bfloat16).to("cuda").eval()
146
- TOKENS_PER_SECOND = 22.0 # measured decode speed on ZeroGPU (large), used to size each turn's GPU reservation
147
-
148
 
149
- def _turn_duration(messages, max_new_tokens=6144, *args, **kwargs):
150
- return int(min(600, 25 + int(max_new_tokens) / TOKENS_PER_SECOND))
151
 
152
 
153
- @spaces.GPU(duration=_turn_duration)
154
- def llm_turn(messages: list, max_new_tokens: int = 6144):
155
- """One agent turn: stream the policy's reply. Yields (text_so_far, done, finish_reason)."""
156
- inputs = processor.apply_chat_template(
157
- messages, tools=[BASH_TOOL], add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt"
158
- ).to("cuda")
159
- streamer = TextIteratorStreamer(processor.tokenizer, skip_prompt=True, skip_special_tokens=False)
 
 
160
  result = {}
161
 
162
- def run():
163
  try:
164
- out = model.generate(
165
- **inputs, streamer=streamer, max_new_tokens=int(max_new_tokens), do_sample=True,
166
- temperature=1.0, top_p=0.95, top_k=0,
167
- )
168
- result["n"] = out.shape[1] - inputs["input_ids"].shape[1]
169
- except Exception as e: # surfaced after the stream ends
170
  result["error"] = f"{type(e).__name__}: {e}"
171
  streamer.end()
172
 
173
- thread = threading.Thread(target=run, daemon=True)
174
- thread.start()
175
  text = ""
176
- for chunk in streamer:
177
- text += chunk
178
- yield text, False, None
179
- thread.join()
 
 
 
180
  if "error" in result:
181
- raise gr.Error(f"Generation failed: {result['error']}")
182
- finish = "stop" if ("<|im_end|>" in text or "<|endoftext|>" in text) else "length"
183
- if finish == "length" and result.get("n", 0) < int(max_new_tokens):
184
- finish = "stop"
185
- text = text.replace("<|im_end|>", "").replace("<|endoftext|>", "")
186
- yield text, True, finish
187
 
188
 
189
  # --------------------------------------------------------------------------------------------------------------------
190
- # Harness glue (mini-swe-agent DefaultAgent + GuardedAgent semantics)
 
191
  # --------------------------------------------------------------------------------------------------------------------
192
- _FUNC = re.compile(r"<function=([^>\n]+)>(.*?)</function>", re.S)
193
- _PARAM = re.compile(r"<parameter=([^>\n]+)>(.*?)</parameter>", re.S)
194
-
195
-
196
- def parse_reply(text: str):
197
- """Qwen3-coder tool-call format -> (content before the first call, [(name, args)])."""
198
- idx = text.find("<tool_call>")
199
- content = text if idx < 0 else text[:idx]
200
- calls = []
201
- if idx >= 0:
202
- for m in _FUNC.finditer(text[idx:]):
203
- args = {}
204
- for p in _PARAM.finditer(m.group(2)):
205
- v = p.group(2)
206
- v = v[1:] if v.startswith("\n") else v
207
- v = v[:-1] if v.endswith("\n") else v
208
- args[p.group(1).strip()] = v
209
- calls.append((m.group(1).strip(), args))
210
- return content.rstrip(), calls
211
-
212
-
213
- def split_reasoning(content: str):
214
- if "</think>" in content:
215
- r, c = content.split("</think>", 1)
216
- return r.replace("<think>", "").strip(), c.strip()
217
- return "", content.strip()
218
-
219
-
220
  def agent_env() -> dict:
221
- env = {k: v for k, v in os.environ.items() if not _SECRET_ENV.search(k)}
222
- env |= {k: str(v) for k, v in (ENV_CFG.get("env") or {}).items()}
 
223
  env["PATH"] = f"{HARNESS_ROOT / 'bin'}:{os.environ.get('PATH', '')}"
224
  env["VCSFT_PYTHON"] = sys.executable
225
  env["VCSFT_HARNESS"] = json.dumps(HARNESS)
226
  return env
227
 
228
 
229
- def run_command(command: str, cwd: Path) -> dict:
230
- reason = harness_violation(command, GUARD_MARKERS, str(cwd.resolve()))
231
- if reason:
232
- msg = ("BLOCKED: this command was not executed because {reason}. Work only in the current directory and use "
233
- "only check_script and run_blender; do not inspect or modify the harness or run Blender any other way. "
234
- "For a quick experiment, write a small script in the current directory and run `run_blender "
235
- "experiment.py`; its print() output appears in the blender log.").format(reason=reason)
236
- return {"output": msg, "returncode": 1, "exception_info": ""}
237
  try:
238
- r = subprocess.run(command, shell=True, cwd=cwd, env=agent_env(), text=True, encoding="utf-8",
239
- errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
240
- timeout=ENV_CFG.get("timeout", 900))
241
- return {"output": r.stdout, "returncode": r.returncode, "exception_info": ""}
242
- except Exception as e:
243
- raw = getattr(e, "output", None)
244
- raw = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else (raw or "")
245
- return {"output": raw, "returncode": -1, "exception_info": f"An error occurred while executing the command: {e}"}
246
-
247
-
248
- def observation_content(output: dict):
249
- """video_coder_harness.models.VisualChatModel: cut image tags out, template the text, attach the images."""
250
- text = output.get("output") or ""
 
 
 
251
  images = []
252
 
253
  def repl(m):
254
  if m.group(1).strip() != "image_url":
255
  return m.group(0)
256
  before = text[: m.start()].rstrip().splitlines()
257
- images.append((before[-1].strip() if before else "", m.group(2).strip()))
 
258
  return f"[image {len(images)} attached below]"
259
 
260
- stripped = _TAG.sub(repl, text)
261
- body = render_template(MODEL_CFG["observation_template"], output={**output, "output": stripped})
262
- if not images:
263
- return body, body, []
264
- content = [{"type": "text", "text": body}]
265
- for k, (caption, url) in enumerate(images, 1):
266
- content += [{"type": "text", "text": f"image {k}: {caption}" if caption else f"image {k}:"},
267
- {"type": "image", "image": url}]
268
- return content, body, images
269
 
270
 
271
- def truncate(s: str, n: int = 2500) -> str:
272
- return s if len(s) <= n else s[: n // 2] + f"\n… [{len(s) - n} chars elided] …\n" + s[-n // 2:]
273
 
274
 
275
- def show_command(cmd: str) -> str:
276
- lines = cmd.splitlines()
277
- if len(lines) > 30:
278
- cmd = "\n".join(lines[:30]) + f"\n# … {len(lines) - 30} more lines (full script in the Blender script panel)"
279
- return f"```bash\n{cmd}\n```"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
 
 
 
281
 
282
- def read_script(ws: Path) -> str:
283
- p = ws / HARNESS["script_name"]
 
284
  return p.read_text(errors="replace") if p.is_file() else ""
285
 
286
 
287
- def generate(prompt: str, max_steps: int = 20, max_new_tokens: int = 6144):
288
- """Run the Video-Coder agent on a scene description and return its Blender-rendered draft video.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
  Args:
291
- prompt: scene description (objects, layout, motion, camera).
292
- max_steps: maximum number of agent turns (model calls).
293
- max_new_tokens: maximum tokens the model may generate per turn.
294
-
295
- Returns:
296
- (agent trace, Blender script, draft video path, status)
297
  """
298
  if not prompt or not prompt.strip():
299
- raise gr.Error("Describe a scene first.")
300
- task = prompt.strip()
301
- run_id = uuid.uuid4().hex[:10]
302
- root = RUNS_DIR / run_id
303
- ws = root / "workspace"
304
- ws.mkdir(parents=True)
305
- tv = {**TEMPLATE_VARS, "task": task}
306
- messages = [
307
- {"role": "system", "content": render_template(AGENT_CFG["system_template"], **tv)},
308
- {"role": "user", "content": render_template(AGENT_CFG["instance_template"], **tv)},
309
- ]
310
- chat = [{"role": "user", "content": task}]
311
- script, draft, status = "", None, "Starting the agent…"
312
- yield chat, script, draft, status
313
-
314
- last, repeats, format_errors, submitted, n_calls = None, 0, 0, False, 0
315
- exit_status = "StepLimit"
316
- t_start = time.time()
317
- while n_calls < int(max_steps):
318
- n_calls += 1
319
- chat.append({"role": "assistant", "content": "", "metadata": {"title": f"πŸ’­ Step {n_calls}: thinking…"}})
320
- text, finish = "", None
321
- for text, done, finish in llm_turn(messages, int(max_new_tokens)):
322
- reasoning, visible = split_reasoning(text.split("<tool_call>")[0])
323
- chat[-1]["content"] = truncate((reasoning or visible), 6000)
324
- yield chat, script, draft, f"Step {n_calls}: the model is writing…"
325
- content, calls = parse_reply(text)
326
- reasoning, visible = split_reasoning(content)
327
- chat[-1]["content"] = truncate(reasoning, 6000) or "(no reasoning)"
328
- chat[-1]["metadata"] = {"title": f"πŸ’­ Step {n_calls}: reasoning"}
329
- if visible:
330
- chat.append({"role": "assistant", "content": visible})
331
-
332
- actions = [a.get("command") for name, a in calls if name == "bash" and "command" in a]
333
- if not calls or len(actions) != len(calls):
334
- format_errors += 1
335
- err = ("No tool calls found in the response. Every response MUST include at least one tool call."
336
- if not calls else "Missing 'command' argument in bash tool call.")
337
- messages.append({"role": "user", "content": render_template(
338
- MODEL_CFG["format_error_template"], error=err, actions=[], has_tool_calls=bool(calls),
339
- finish_reason=finish or "stop")})
340
- chat.append({"role": "assistant", "content": f"⚠️ Format error ({'output limit reached' if finish == 'length' else err}); asking the model to retry."})
341
- yield chat, script, draft, status
342
- if format_errors >= AGENT_CFG.get("max_consecutive_format_errors", 3):
343
- exit_status = "FormatError"
344
- break
345
- continue
346
- format_errors = 0
347
- messages.append({
348
- "role": "assistant", "content": content,
349
- "tool_calls": [{"type": "function", "function": {"name": "bash", "arguments": {"command": c}}} for c in actions],
350
- })
351
-
352
- stop = False
353
- for cmd in actions:
354
- chat.append({"role": "assistant", "content": show_command(cmd)})
355
- yield chat, read_script(ws) or script, draft, f"Step {n_calls}: running the command…"
356
- out = run_command(cmd, ws)
357
- lines = (out.get("output") or "").lstrip().splitlines()
358
- if lines and lines[0].strip() == SUBMIT and out["returncode"] == 0:
359
- submitted, stop, exit_status = True, True, "Submitted"
360
- chat.append({"role": "assistant", "content": "βœ… The agent submitted its script."})
361
- break
362
- key = (cmd, _normalize_output(out.get("output", "")))
363
- same = (last is not None and key[1] == last[1]
364
- and difflib.SequenceMatcher(None, key[0], last[0]).ratio() >= 0.9)
365
- repeats = repeats + 1 if same else 1
366
- last = key
367
- if repeats >= AGENT_CFG.get("repeat_warn", 3):
368
- out["output"] = (out.get("output") or "") + (
369
- f"\n\nWARNING: this is the {repeats}th consecutive near-identical command with the same result. "
370
- "Repeating it will not help: read the output, then change the script or your approach. The task "
371
- f"ends after {AGENT_CFG.get('repeat_stop', 6)} repetitions.")
372
- if repeats >= AGENT_CFG.get("repeat_stop", 6):
373
- stop, exit_status = True, "RepeatedActions"
374
- obs, obs_text, images = observation_content(out)
375
  messages.append({"role": "tool", "content": obs})
376
- chat.append({"role": "assistant", "content": f"```\n{truncate(obs_text)}\n```",
377
- "metadata": {"title": f"πŸ› οΈ Output (exit {out['returncode']})"}})
378
- script = read_script(ws) or script
379
- preview = ws / "preview.png"
380
- if images and preview.is_file():
381
- keep = root / f"preview_{n_calls}.png"
382
- shutil.copy(preview, keep)
383
- chat.append({"role": "assistant", "content": gr.Image(str(keep), label=f"Render after step {n_calls}")})
384
- if (ws / HARNESS["output_video"]).is_file() and "RUN OK" in (out.get("output") or ""):
385
- keep = root / f"render_{n_calls}.mp4"
386
- shutil.copy(ws / HARNESS["output_video"], keep)
387
- draft = str(keep)
388
- yield chat, script, draft, f"Step {n_calls} done ({time.time() - t_start:.0f}s)"
389
- if stop:
390
- break
391
 
392
- script = read_script(ws) or script
393
- if submitted:
394
- yield chat, script, draft, "Submitted: re-rendering the final script from scratch (harness acceptance)…"
395
- check = check_file(ws / HARNESS["script_name"])
396
- accepted = False
397
- if check["ok"]:
398
- final_dir = root / "final_run"
399
- final_dir.mkdir()
400
- shutil.copy(ws / HARNESS["script_name"], final_dir / HARNESS["script_name"])
401
- rep = execute(final_dir / HARNESS["script_name"], final_dir, HARNESS)
402
- accepted = bool(rep["ok"])
403
- if accepted:
404
- draft = str(final_dir / HARNESS["output_video"])
405
- if accepted:
406
- status = f"βœ… Accepted: the final script renders a valid draft video ({n_calls} steps, {time.time() - t_start:.0f}s)."
407
- else:
408
- status = "⚠️ Submitted, but the final re-render did not pass the harness checks" + (
409
- " (showing the last successful render)." if draft else ".")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  else:
411
- status = f"⚠️ Stopped ({exit_status}) after {n_calls} steps without a submission" + (
412
- "; showing the last successful render." if draft else ".")
413
- yield chat, script, draft, status
414
-
415
-
416
- # --------------------------------------------------------------------------------------------------------------------
417
- # Stage 2: draft -> final video with MiniMax-H3 Ref2VA (Minimax-H3-server/batch_generate.py's prompt template)
418
- # --------------------------------------------------------------------------------------------------------------------
419
- H3_TEMPLATE = """Generate a high-quality, photorealistic video based on the following scene description and white-model reference video.
420
-
421
- Use the scene description to determine the objects, colors, materials, and visual appearance. Follow the white-model video to preserve the scene layout, object movements, camera motion, and timing.
422
-
423
- Maintain visual realism and temporal consistency.
424
-
425
- Scene Description:
426
- {scene}
427
-
428
- Reference Video:
429
- <Video 1>"""
430
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
- def render_final(prompt: str, draft_video: str, request: gr.Request = None):
433
- """Turn the white-model draft into a photorealistic video with MiniMax-H3 (Ref2VA), via a public H3 Space."""
434
- if not draft_video:
435
- raise gr.Error("Generate a draft video first.")
436
- from gradio_client import Client, handle_file
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
 
438
- from video_coder_harness.video import probe
439
 
440
- info = probe(draft_video)
441
- seconds = (info.get("n_frames") or 120) / (info.get("fps") or 24)
442
- headers = {}
443
- if request is not None and request.headers.get("x-ip-token"):
444
- headers["x-ip-token"] = request.headers["x-ip-token"]
445
- try:
446
- client = Client(H3_SPACE, headers=headers or None)
447
- video, _ = client.predict(
448
- prompt=H3_TEMPLATE.format(scene=(prompt or "").strip()),
449
- video_path={"video": handle_file(draft_video)},
450
- canvas="960x544 Β· 16:9 fast", match=False, duration=int(max(2, min(14, round(seconds)))),
451
- steps=28, seed=42, upsample=False, api_name="/generate",
452
- )
453
- except Exception as e:
454
- raise gr.Error(f"MiniMax-H3 ({H3_SPACE}) failed: {type(e).__name__}: {e}")
455
- return video["video"] if isinstance(video, dict) else video
456
 
457
 
458
  # --------------------------------------------------------------------------------------------------------------------
459
  # UI
460
  # --------------------------------------------------------------------------------------------------------------------
461
- EXAMPLES = [ # the authors' qualitative examples (project page) and their H3 demo scene
462
- "The video captures a small bird perched on a tree branch. The bird, with its brown and white feathers, yellow beak, and black and white head, is the main focus of the video. The bird is seen in three different positions on the branch, each time facing a different direction. The background of the video is filled with green foliage, providing a natural and serene setting for the bird. The style of the video is a close-up shot, allowing the viewer to appreciate the details of the bird and its surroundings. The video does not contain any text or additional objects. The bird's movements and the changing angles of the shot create a sense of dynamism and life in the video.",
463
- "The video shows a sequence of three identical white cars driving on a road. The first car is in the foreground, the second car is slightly behind and to the right, and the third car is in the background. The cars are moving from left to right across the frame. The road appears to be a city street with a dark grey surface. The background features a city skyline with buildings of various heights, and the sky is a gradient of orange and yellow, suggesting either sunrise or sunset. The style of the video is realistic with a focus on the cars and their movement.",
464
- "A single polished walnut-wood chess pawn stands on a small square pedestal of white Carrara marble. No other objects are in the scene. Dark charcoal studio backdrop, soft key light from the upper left with a gentle rim light, subtle reflections on the marble. The camera makes one complete 360-degree orbit around the pawn while slowly rising.",
465
- "The video captures a lively scene on the ocean, with three boats in motion. The first boat, a large white yacht, is seen in the distance, sailing towards the horizon. The second and third boats are closer to the viewer, both moving at high speeds, leaving trails of white water behind them. The ocean is a deep blue, and the sky above is clear, suggesting a sunny day. The boats are the main focus of the video, with their movement and the water's reaction to their speed creating a dynamic and exciting scene. The overall style of the video is a dynamic aerial shot, capturing the movement and beauty of the ocean and the boats.",
466
- "In the video, a woman with long dark hair and glasses is seen enjoying a meal. She is wearing a black jacket and is seated in a room with a white wall. In the first frame, she is holding a bowl of food and appears to be in the process of eating. In the second frame, she is seen taking a bite of her food. In the third frame, she is seen holding a spoon and appears to be in the process of eating again. The video captures the woman's enjoyment of her meal and the casual atmosphere of the setting.",
467
- ]
468
-
469
- INTRO = """# 🎬 Video-Coder
470
- **Video-Coder-9B** is a coding agent: given a scene description it writes a Blender Python program, runs it, looks at the
471
- rendered frames, fixes what is wrong, and submits a **draft video**, an untextured 3D render whose layout, motion and
472
- camera then guide a video generator ([MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3)).
473
-
474
- [πŸ“„ Paper](https://huggingface.co/papers/2611.09894) Β· [πŸ’» Code](https://github.com/CYWang735/Video-Coder) Β·
475
- [πŸ€— Model](https://huggingface.co/Video-Coder/Video-Coder-9B) Β· [🌐 Project page](https://cywang735.github.io/Video-Coder/)
476
-
477
- A run takes several minutes: each agent turn is a GPU call and every `run_blender` renders 5–10 s of 720p video on CPU.
478
- """
479
-
480
  CSS = """
481
- #col-container { max-width: 1300px; margin: 0 auto; }
482
  .dark .gradio-container { color: var(--body-text-color); }
483
  """
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  with gr.Blocks(title="Video-Coder") as demo:
486
  with gr.Column(elem_id="col-container"):
487
- gr.Markdown(INTRO)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  with gr.Row():
489
- with gr.Column(scale=5):
490
- prompt = gr.Textbox(label="Scene description", lines=4,
491
- placeholder="Describe the objects, how they move and what the camera does…")
492
- run = gr.Button("Write & render draft video", variant="primary")
493
- status = gr.Markdown("")
494
- draft = gr.Video(label="Draft video (Blender, white model)", autoplay=True)
495
- with gr.Accordion("Agent settings", open=False):
496
- max_steps = gr.Slider(4, 40, value=20, step=1, label="Max agent steps (model calls)")
497
- max_new_tokens = gr.Slider(1024, 12288, value=6144, step=256, label="Max new tokens per step")
498
- with gr.Accordion("Stage 2: final video with MiniMax-H3 (optional)", open=False):
499
- gr.Markdown(f"Sends the draft and your description to MiniMax-H3 Ref2VA with the authors' white-model "
500
- f"prompt template, through the public [{H3_SPACE}](https://huggingface.co/spaces/{H3_SPACE}) "
501
- "Space (it uses your own ZeroGPU quota, takes a few minutes).")
502
- final_btn = gr.Button("Generate final video")
503
- final = gr.Video(label="Final video (MiniMax-H3)")
504
- with gr.Column(scale=6):
505
- chat = gr.Chatbot(label="Agent trace", height=640, layout="panel")
506
- with gr.Accordion("Blender script (main.py)", open=True):
507
- code = gr.Code(language="python", label="main.py", interactive=False)
508
-
509
- gr.Examples(examples=[[e] for e in EXAMPLES], inputs=[prompt], outputs=[chat, code, draft, status],
510
- fn=generate, cache_examples=False, run_on_click=True, examples_per_page=5)
511
-
512
- run.click(generate, [prompt, max_steps, max_new_tokens], [chat, code, draft, status],
513
- api_name="generate", concurrency_limit=2)
514
- prompt.submit(generate, [prompt, max_steps, max_new_tokens], [chat, code, draft, status],
515
- api_name=False, concurrency_limit=2)
516
- final_btn.click(render_final, [prompt, draft], [final], api_name="render_final")
517
 
518
  if __name__ == "__main__":
519
- demo.queue(default_concurrency_limit=4).launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True, show_error=True)
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
 
3
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
4
 
5
+ import spaces # noqa: E402 (must come before torch)
6
 
7
+ import base64 # noqa: E402
8
  import difflib # noqa: E402
9
+ import hashlib # noqa: E402
10
+ import io # noqa: E402
11
  import json # noqa: E402
12
  import re # noqa: E402
13
  import shutil # noqa: E402
14
+ import signal # noqa: E402
15
  import subprocess # noqa: E402
16
  import sys # noqa: E402
 
17
  import tempfile # noqa: E402
18
  import threading # noqa: E402
19
  import time # noqa: E402
 
22
 
23
  import gradio as gr # noqa: E402
24
  import torch # noqa: E402
 
25
  from jinja2 import StrictUndefined, Template # noqa: E402
26
+ from PIL import Image # noqa: E402
27
  from transformers import AutoModelForImageTextToText, AutoProcessor, TextIteratorStreamer # noqa: E402
28
 
29
+ ROOT = Path(__file__).resolve().parent
30
+ HARNESS_ROOT = ROOT / "harness"
31
  sys.path.insert(0, str(HARNESS_ROOT))
32
+ for _tool in (HARNESS_ROOT / "bin").iterdir(): # the Hub does not keep the executable bit
33
+ _tool.chmod(0o755)
34
+
35
+ from video_coder_harness.blender_exec import execute as blender_execute # noqa: E402
36
+ from video_coder_harness.guard import ( # noqa: E402
37
+ BLOCKED,
38
+ REPEAT_WARNING,
39
+ _normalize_output,
40
+ harness_violation,
41
+ )
42
  from video_coder_harness.static_check import check_file # noqa: E402
43
 
44
  MODEL_ID = "Video-Coder/Video-Coder-9B"
 
 
 
45
 
46
  # --------------------------------------------------------------------------------------------------------------------
47
+ # Blender 4.5 (bpy 4.5.14 on Python 3.11, same as the authors' bpy-agent.sif sandbox), rendered on CPU with Mesa
48
+ # llvmpipe. Installed into its own venv at startup because the Space itself runs Python 3.12.
49
  # --------------------------------------------------------------------------------------------------------------------
50
+ BPY_ENV = Path(os.environ.get("BPY_ENV", str(Path.home() / "bpyenv")))
51
+ BPY_PY = BPY_ENV / "bin" / "python"
52
 
53
 
54
  def setup_bpy() -> None:
55
+ if BPY_PY.exists():
56
+ r = subprocess.run([str(BPY_PY), "-c", "import bpy; print(bpy.app.version_string)"],
57
+ capture_output=True, text=True)
58
+ if r.returncode == 0:
59
+ print("bpy ready:", r.stdout.strip())
60
+ return
61
  t0 = time.time()
62
+ subprocess.run([sys.executable, "-m", "uv", "venv", "-q", "-p", "3.11", "--clear", str(BPY_ENV)], check=True)
63
+ subprocess.run([sys.executable, "-m", "uv", "pip", "install", "-q", "-p", str(BPY_PY), "bpy==4.5.14"],
64
+ check=True)
65
+ r = subprocess.run([str(BPY_PY), "-c", "import bpy; print(bpy.app.version_string)"],
66
+ capture_output=True, text=True)
67
+ print(f"bpy install took {time.time() - t0:.0f}s:", r.stdout.strip(), r.stderr[-2000:])
68
 
69
 
70
  setup_bpy()
71
 
72
+ # Harness settings: configs/teacher.yaml + configs/uni_container_visual.yaml (the RL / inference setting).
73
+ HARNESS = {
74
+ "blender_version": "4.5",
75
+ "blender_available": True,
76
+ "bpy_python": str(BPY_PY),
77
+ "bpy_libs": "/usr",
78
+ "exec_timeout": 600,
79
+ "summary_samples": 6,
80
+ "content_samples": 8,
 
 
 
 
 
 
 
81
  "visual_feedback": True,
82
+ "preview_frames": 8,
83
+ "preview_width": 640,
84
+ "judge": {"enabled": False},
85
+ "output_video": "output.mp4",
86
+ "fps": 24,
87
+ "frame_count": 120,
88
+ "max_frame_count": 240,
89
+ "resolution": [1280, 720],
90
+ "script_name": "main.py",
91
  }
92
+ SCRIPT = HARNESS["script_name"]
93
+ CMD_TIMEOUT = 900
94
+ REPEAT_WARN, REPEAT_STOP, REPEAT_SIM = 3, 6, 0.9
95
+ MAX_FORMAT_ERRORS = 3
96
+ RUNS_DIR = Path(tempfile.gettempdir()) / "video_coder_runs"
97
+ RUNS_DIR.mkdir(parents=True, exist_ok=True)
98
+ _SECRET_ENV = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
99
+ HARNESS_MARKERS = [str(HARNESS_ROOT), str(BPY_ENV)]
100
+
101
+ SYSTEM_PROMPT = (
102
+ "You are an expert Blender technical artist and Python programmer. You write Blender Python (bpy) scripts that "
103
+ "build and animate 3D scenes and render them as untextured white-model (clay) videos. You work in a Linux shell "
104
+ "through a bash tool."
105
+ )
106
+ # The exact user prompt of the SFT trajectories (Video-Coder/Train-Data sft_data.json), with the scene as __TASK__.
107
+ INSTANCE_TEMPLATE = (HARNESS_ROOT / "instance_template.txt").read_text()
108
+ TOOLS = [{
109
  "type": "function",
110
  "function": {
111
  "name": "bash",
 
116
  "required": ["command"],
117
  },
118
  },
119
+ }]
120
+ OBSERVATION_TEMPLATE = Template("""{%- if output.output | length < 10000 -%}
121
+ {
122
+ "returncode": {{ output.returncode }},
123
+ "output": {{ output.output | tojson }}
124
+ {%- if output.exception_info %}, "exception_info": {{ output.exception_info | tojson }}{% endif %}
125
  }
126
+ {%- else -%}
127
+ {
128
+ "returncode": {{ output.returncode }},
129
+ "output_head": {{ output.output[:5000] | tojson }},
130
+ "output_tail": {{ output.output[-5000:] | tojson }},
131
+ "elided_chars": {{ output.output | length - 10000 }},
132
+ "warning": "Output too long."
133
+ {%- if output.exception_info %}, "exception_info": {{ output.exception_info | tojson }}{% endif %}
134
+ }
135
+ {%- endif -%}
136
+ """, undefined=StrictUndefined)
137
+ FORMAT_ERROR_TEMPLATE = Template("""{% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
138
+ Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with exactly one bash tool call.
139
+ {%- else -%}
140
+ Tool call error:
141
+
142
+ <error>
143
+ {{error}}
144
+ </error>
145
+
146
+ Every response must call the 'bash' tool exactly once with {"command": "..."}.
147
+ To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` on its own.
148
+ {%- endif %}
149
+ """)
150
+ MM_TAG = re.compile(r"(?s)<MSWEA_MULTIMODAL_CONTENT><CONTENT_TYPE>(.+?)</CONTENT_TYPE>(.+?)</MSWEA_MULTIMODAL_CONTENT>")
151
+ TOOL_CALL_RE = re.compile(r"<tool_call>\s*<function=([^>\s]+)>(.*?)</function>\s*</tool_call>", re.S)
152
+ PARAM_RE = re.compile(r"<parameter=([^>\s]+)>\n?(.*?)\n?</parameter>", re.S)
153
+ END_TOKENS = ("<|im_end|>", "<|endoftext|>")
 
 
 
 
 
 
 
 
 
 
154
 
155
  # --------------------------------------------------------------------------------------------------------------------
156
+ # Model: Video-Coder-9B (Qwen3.5-9B, vision-language) in bf16, fully on the GPU.
157
  # --------------------------------------------------------------------------------------------------------------------
158
  processor = AutoProcessor.from_pretrained(MODEL_ID)
159
+ tokenizer = processor.tokenizer
160
  model = AutoModelForImageTextToText.from_pretrained(MODEL_ID, dtype=torch.bfloat16).to("cuda").eval()
 
 
161
 
162
+ GPU_CHUNK_SECONDS = 90 # one GPU call generates for at most this long; long turns continue in the next call
 
163
 
164
 
165
+ @spaces.GPU(duration=120)
166
+ def llm_chunk(messages: list, images: list, prefix: str = "", max_new_tokens: int = 16384,
167
+ temperature: float = 1.0, top_p: float = 0.95):
168
+ """Continue the agent's current turn for up to GPU_CHUNK_SECONDS; yields (text so far, finished, n_tokens)."""
169
+ prompt = tokenizer.apply_chat_template(messages, tools=TOOLS, add_generation_prompt=True, tokenize=False)
170
+ prompt += prefix
171
+ inputs = processor(text=[prompt], images=images or None, return_tensors="pt").to("cuda")
172
+ n_in = inputs["input_ids"].shape[1]
173
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=False)
174
  result = {}
175
 
176
+ def _run():
177
  try:
178
+ out = model.generate(**inputs, streamer=streamer, max_new_tokens=int(max_new_tokens), do_sample=True,
179
+ temperature=float(temperature), top_p=float(top_p), top_k=0, use_cache=True,
180
+ max_time=GPU_CHUNK_SECONDS)
181
+ result["n"] = int(out.shape[1] - n_in)
182
+ except Exception as e: # surfaced below
 
183
  result["error"] = f"{type(e).__name__}: {e}"
184
  streamer.end()
185
 
186
+ th = threading.Thread(target=_run, daemon=True)
187
+ th.start()
188
  text = ""
189
+ last = 0.0
190
+ for piece in streamer:
191
+ text += piece
192
+ if time.time() - last > 0.4:
193
+ last = time.time()
194
+ yield text, False, None
195
+ th.join()
196
  if "error" in result:
197
+ raise gr.Error(result["error"])
198
+ finished = any(t in text for t in END_TOKENS)
199
+ yield text, finished, result.get("n", 0)
 
 
 
200
 
201
 
202
  # --------------------------------------------------------------------------------------------------------------------
203
+ # Agent loop: mini-swe-agent DefaultAgent + video_coder_harness GuardedAgent semantics, re-implemented without the
204
+ # litellm/vLLM gateway (the model is called in-process).
205
  # --------------------------------------------------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  def agent_env() -> dict:
207
+ env = dict(os.environ)
208
+ env.update({k: "" for k in os.environ if _SECRET_ENV.search(k)})
209
+ env.update({"PAGER": "cat", "MANPAGER": "cat", "PIP_PROGRESS_BAR": "off", "TQDM_DISABLE": "1"})
210
  env["PATH"] = f"{HARNESS_ROOT / 'bin'}:{os.environ.get('PATH', '')}"
211
  env["VCSFT_PYTHON"] = sys.executable
212
  env["VCSFT_HARNESS"] = json.dumps(HARNESS)
213
  return env
214
 
215
 
216
+ def run_command(command: str, cwd: str) -> dict:
217
+ """mini-swe-agent LocalEnvironment.execute."""
218
+ proc = subprocess.Popen(command, shell=True, text=True, cwd=cwd, env=agent_env(), encoding="utf-8",
219
+ errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
220
+ start_new_session=True)
 
 
 
221
  try:
222
+ stdout, _ = proc.communicate(timeout=CMD_TIMEOUT)
223
+ return {"output": stdout, "returncode": proc.returncode, "exception_info": ""}
224
+ except subprocess.TimeoutExpired as e:
225
+ os.killpg(proc.pid, signal.SIGKILL)
226
+ stdout, _ = proc.communicate()
227
+ return {"output": stdout or "", "returncode": -1,
228
+ "exception_info": f"An error occurred while executing the command: {e}"}
229
+
230
+
231
+ def is_submit(output: dict) -> bool:
232
+ lines = output.get("output", "").lstrip().splitlines()
233
+ return bool(lines) and lines[0].strip() == "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" and output["returncode"] == 0
234
+
235
+
236
+ def split_images(text: str):
237
+ """video_coder_harness.models.split_images: cut frame images out of the tool output."""
238
  images = []
239
 
240
  def repl(m):
241
  if m.group(1).strip() != "image_url":
242
  return m.group(0)
243
  before = text[: m.start()].rstrip().splitlines()
244
+ caption = before[-1].strip() if before else ""
245
+ images.append((caption, m.group(2).strip()))
246
  return f"[image {len(images)} attached below]"
247
 
248
+ return MM_TAG.sub(repl, text), images
 
 
 
 
 
 
 
 
249
 
250
 
251
+ def data_url_to_pil(url: str) -> Image.Image:
252
+ return Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).convert("RGB")
253
 
254
 
255
+ def parse_response(raw: str):
256
+ """Split a Qwen3.5 response into (reasoning, content, tool calls) like vLLM's qwen3 reasoning + qwen3_coder parsers."""
257
+ text = raw
258
+ for t in END_TOKENS:
259
+ text = text.split(t)[0]
260
+ reasoning = ""
261
+ if "</think>" in text:
262
+ reasoning, text = text.split("</think>", 1)
263
+ reasoning = reasoning.replace("<think>", "").strip()
264
+ calls = []
265
+ for m in TOOL_CALL_RE.finditer(text):
266
+ args = {k: v for k, v in PARAM_RE.findall(m.group(2))}
267
+ calls.append({"name": m.group(1).strip(), "arguments": args})
268
+ content = TOOL_CALL_RE.sub("", text)
269
+ content = content.split("<tool_call>")[0].strip()
270
+ return reasoning, content, calls
271
+
272
+
273
+ def sha(path: Path) -> str | None:
274
+ return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
275
+
276
 
277
+ def short(text: str, n: int = 60) -> str:
278
+ lines = text.splitlines()
279
+ return text if len(lines) <= n else "\n".join(lines[:n]) + f"\n... ({len(lines) - n} more lines)"
280
 
281
+
282
+ def read_script(workdir: Path) -> str:
283
+ p = workdir / SCRIPT
284
  return p.read_text(errors="replace") if p.is_file() else ""
285
 
286
 
287
+ def new_state(prompt: str, max_steps: int, temperature: float, top_p: float, max_new_tokens: int) -> dict:
288
+ workdir = (RUNS_DIR / uuid.uuid4().hex).resolve()
289
+ workdir.mkdir(parents=True)
290
+ return {
291
+ "workdir": str(workdir),
292
+ "messages": [
293
+ {"role": "system", "content": SYSTEM_PROMPT},
294
+ {"role": "user", "content": INSTANCE_TEMPLATE.replace("__TASK__", prompt.strip())},
295
+ ],
296
+ "image_urls": [], # frames shown to the model, in order (JPEG data URLs)
297
+ "chat": [],
298
+ "video": None,
299
+ "preview": None,
300
+ "last_ok": None, # (script hash, video) of the last run_blender that reported RUN OK
301
+ "last_key": None,
302
+ "repeats": 0,
303
+ "n_format_errors": 0,
304
+ "step": 1,
305
+ "raw": "", # current (possibly unfinished) model turn
306
+ "used": 0,
307
+ "max_steps": int(max_steps),
308
+ "temperature": float(temperature),
309
+ "top_p": float(top_p),
310
+ "max_new_tokens": int(max_new_tokens),
311
+ "done": False,
312
+ "status": "Starting…",
313
+ "t_start": time.time(),
314
+ }
315
+
316
+
317
+ def ui(state: dict, tick=None):
318
+ workdir = Path(state["workdir"])
319
+ msg = f"**{state['status']}** Β· {time.time() - state['t_start']:.0f}s elapsed"
320
+ return (state, state["chat"], state["video"], read_script(workdir), state["preview"], msg,
321
+ gr.update() if tick is None else tick)
322
+
323
+
324
+ def start(prompt: str, max_steps: int = 16, temperature: float = 1.0, top_p: float = 0.95,
325
+ max_new_tokens: int = 16384):
326
+ """Start a Video-Coder agent run for a scene description. The run then proceeds turn by turn: call `advance`
327
+ with the returned state (same session) until its status says it finished.
328
 
329
  Args:
330
+ prompt: description of the scene / video to build.
331
+ max_steps: maximum number of agent turns (one bash command each).
332
+ temperature: sampling temperature (the authors sample with 1.0).
333
+ top_p: nucleus sampling (the authors use 0.95).
334
+ max_new_tokens: token budget of a single agent turn.
 
335
  """
336
  if not prompt or not prompt.strip():
337
+ raise gr.Error("Please enter a scene description.")
338
+ state = new_state(prompt, max_steps, temperature, top_p, max_new_tokens)
339
+ return ui(state, tick=time.time())
340
+
341
+
342
+ def _finish_turn(state: dict):
343
+ """Parse the finished model turn, run its command(s) in the harness; a generator of UI updates."""
344
+ workdir = Path(state["workdir"])
345
+ step, raw = state["step"], state["raw"]
346
+ chat, messages = state["chat"], state["messages"]
347
+ if chat and chat[-1].get("metadata", {}).get("status") == "pending":
348
+ chat.pop()
349
+ reasoning, content, calls = parse_response(raw)
350
+ state["raw"], state["used"] = "", 0
351
+ if reasoning:
352
+ chat.append({"role": "assistant", "content": reasoning,
353
+ "metadata": {"title": f"πŸ’­ Step {step}: reasoning", "status": "done"}})
354
+ msg = {"role": "assistant", "content": content}
355
+ if reasoning:
356
+ msg["reasoning_content"] = reasoning
357
+ if calls:
358
+ msg["tool_calls"] = [{"type": "function", "function": {"name": c["name"], "arguments": c["arguments"]}}
359
+ for c in calls]
360
+ messages.append(msg)
361
+
362
+ error = None
363
+ if not calls:
364
+ error = "No tool calls found in the response. Every response MUST include at least one tool call."
365
+ elif any(c["name"] != "bash" or "command" not in c["arguments"] for c in calls):
366
+ error = "Unknown tool or missing 'command' argument."
367
+ if error:
368
+ state["n_format_errors"] += 1
369
+ finish_reason = "length" if not any(t in raw for t in END_TOKENS) else "stop"
370
+ err = FORMAT_ERROR_TEMPLATE.render(error=error, finish_reason=finish_reason, has_tool_calls=bool(calls))
371
+ if content:
372
+ chat.append({"role": "assistant", "content": content})
373
+ chat.append({"role": "user", "content": f"⚠️ {err}"})
374
+ messages.append({"role": "user", "content": err})
375
+ if state["n_format_errors"] >= MAX_FORMAT_ERRORS:
376
+ return "FormatError"
377
+ return None
378
+ state["n_format_errors"] = 0
379
+
380
+ for call in calls:
381
+ command = call["arguments"]["command"]
382
+ shown = f"{content}\n\n" if content else ""
383
+ chat.append({"role": "assistant", "content": f"{shown}```bash\n{short(command)}\n```"})
384
+ first = command.strip().splitlines()[0][:60] if command.strip() else ""
385
+ state["status"] = f"Step {step}: running `{first}`"
386
+ yield
387
+ reason = harness_violation(command, HARNESS_MARKERS, str(workdir))
388
+ if reason:
389
+ out = {"output": BLOCKED.format(reason=reason), "returncode": 1, "exception_info": ""}
390
+ else:
391
+ out = run_command(command, str(workdir))
392
+ if is_submit(out):
393
+ return "Submitted"
394
+ key = (command, _normalize_output(out.get("output", "")))
395
+ last = state["last_key"]
396
+ same = (last is not None and key[1] == last[1]
397
+ and difflib.SequenceMatcher(None, key[0], last[0]).ratio() >= REPEAT_SIM)
398
+ state["repeats"] = state["repeats"] + 1 if same else 1
399
+ state["last_key"] = key
400
+ if state["repeats"] >= REPEAT_WARN:
401
+ out["output"] = (out.get("output") or "") + REPEAT_WARNING.format(n=state["repeats"], stop=REPEAT_STOP)
402
+
403
+ text, imgs = split_images(out.get("output") or "")
404
+ obs = OBSERVATION_TEMPLATE.render(output={**out, "output": text})
405
+ if imgs:
406
+ parts = [{"type": "text", "text": obs}]
407
+ for k, (caption, url) in enumerate(imgs, 1):
408
+ parts += [{"type": "text", "text": f"image {k}: {caption}" if caption else f"image {k}:"},
409
+ {"type": "image"}]
410
+ state["image_urls"].append(url)
411
+ messages.append({"role": "tool", "content": parts})
412
+ else:
413
  messages.append({"role": "tool", "content": obs})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
 
415
+ if command.strip().startswith("run_blender"):
416
+ vid = workdir / HARNESS["output_video"]
417
+ if vid.is_file():
418
+ keep = workdir / f"render_step{step}.mp4"
419
+ shutil.copy(vid, keep)
420
+ state["video"] = str(keep)
421
+ if text.startswith("RUN OK"):
422
+ state["last_ok"] = (sha(workdir / SCRIPT), str(keep))
423
+ if (workdir / "preview.png").is_file():
424
+ keep_png = workdir / f"preview_step{step}.png"
425
+ shutil.copy(workdir / "preview.png", keep_png)
426
+ state["preview"] = str(keep_png)
427
+ chat.append({"role": "assistant", "content": f"```\n{short(text.strip(), 40)}\n```",
428
+ "metadata": {"title": f"πŸ› οΈ Output (exit code {out['returncode']})", "status": "done"}})
429
+ if state["repeats"] >= REPEAT_STOP:
430
+ return "RepeatedActions"
431
+ return None
432
+
433
+
434
+ def _accept(state: dict):
435
+ """Harness acceptance: static check + the submitted script re-rendered from scratch (final_execution)."""
436
+ workdir = Path(state["workdir"])
437
+ script = workdir / SCRIPT
438
+ state["chat"].append({"role": "assistant", "content": "βœ… Submitted. Validating the final script…"})
439
+ if not script.is_file():
440
+ state["status"] = f"Submitted, but no {SCRIPT} was written"
441
+ return
442
+ check = check_file(script)
443
+ if not check["ok"]:
444
+ state["status"] = f"Submitted, but the static check failed: {check['errors']}"
445
+ return
446
+ last_ok = state["last_ok"]
447
+ if last_ok and last_ok[0] == sha(script): # Workbench rendering is deterministic: reuse the identical render
448
+ state["video"] = last_ok[1]
449
  else:
450
+ state["status"] = "Re-rendering the submitted script"
451
+ yield
452
+ final_dir = workdir / "final_run"
453
+ final_dir.mkdir(exist_ok=True)
454
+ shutil.copy(script, final_dir / SCRIPT)
455
+ report = blender_execute(final_dir / SCRIPT, final_dir, HARNESS)
456
+ if (final_dir / HARNESS["output_video"]).is_file():
457
+ state["video"] = str(final_dir / HARNESS["output_video"])
458
+ if (final_dir / "preview.png").is_file():
459
+ state["preview"] = str(final_dir / "preview.png")
460
+ if not report["ok"]:
461
+ state["status"] = f"Submitted, but the final render has problems: {report['problems']}"
462
+ return
463
+ state["status"] = "Accepted βœ… β€” draft video rendered from the agent's Blender script"
464
+
465
+
466
+ def advance(state: dict):
467
+ """Run the next piece of the agent: one GPU generation chunk of the current turn and, once the turn is complete,
468
+ its bash command in the Video-Coder harness. Returns a new tick while the run is still going.
469
 
470
+ Args:
471
+ state: the run state returned by `start` / the previous `advance`.
472
+ """
473
+ if not state or state.get("done"):
474
+ yield ui(state) if state else (state, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
475
+ gr.update())
476
+ return
477
+ step = state["step"]
478
+ chat = state["chat"]
479
+ if not (chat and chat[-1].get("metadata", {}).get("status") == "pending"):
480
+ chat.append({"role": "assistant", "content": "",
481
+ "metadata": {"title": f"πŸ’­ Step {step}: thinking…", "status": "pending"}})
482
+ live = chat[-1]
483
+ state["status"] = f"Step {step}/{state['max_steps']}: Video-Coder is thinking"
484
+ yield ui(state)
485
+
486
+ # ---- one GPU call (the ZeroGPU token of a request is short-lived, so each request makes exactly one) ----
487
+ images = [data_url_to_pil(u) for u in state["image_urls"]]
488
+ base = state["raw"]
489
+ finished, n = False, None
490
+ t0 = time.time()
491
+ for text, finished, n in llm_chunk(state["messages"], images, base, state["max_new_tokens"] - state["used"],
492
+ state["temperature"], state["top_p"]):
493
+ state["raw"] = base + text
494
+ live["content"] = state["raw"][-3000:].replace("<think>", "")
495
+ state["status"] = f"Step {step}/{state['max_steps']}: Video-Coder is writing ({len(state['raw'])} chars)"
496
+ yield ui(state)
497
+ state["used"] += n or 0
498
+ print(f"step {step}: chunk {time.time() - t0:.1f}s, {n} tokens, finished={finished}")
499
+ if not finished and state["used"] < state["max_new_tokens"]:
500
+ yield ui(state, tick=time.time()) # continue this turn in the next request
501
+ return
502
 
503
+ # ---- the turn is complete: execute it ----
504
+ exit_status = None
505
+ gen = _finish_turn(state)
506
+ while True:
507
+ try:
508
+ next(gen)
509
+ yield ui(state)
510
+ except StopIteration as stop:
511
+ exit_status = stop.value
512
+ break
513
+ state["step"] += 1
514
+ if exit_status is None and state["step"] > state["max_steps"]:
515
+ exit_status = "StepLimit"
516
+ if exit_status is None:
517
+ state["status"] = f"Step {step}/{state['max_steps']} done"
518
+ yield ui(state, tick=time.time())
519
+ return
520
+ state["done"] = True
521
+ if exit_status == "Submitted":
522
+ for _ in _accept(state):
523
+ yield ui(state)
524
+ else:
525
+ state["status"] = {
526
+ "StepLimit": "Step limit reached before the agent submitted (showing the latest render)",
527
+ "RepeatedActions": "Stopped: the agent repeated the same command",
528
+ "FormatError": "Stopped after repeated format errors",
529
+ }[exit_status]
530
+ yield ui(state)
531
 
 
532
 
533
+ def stop_run(state: dict):
534
+ """Stop the current run."""
535
+ if state:
536
+ state["done"] = True
537
+ state["status"] = "Stopped"
538
+ return ui(state)
539
+ return (state, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
 
 
 
 
 
 
 
 
 
540
 
541
 
542
  # --------------------------------------------------------------------------------------------------------------------
543
  # UI
544
  # --------------------------------------------------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  CSS = """
546
+ #col-container { max-width: 1280px; margin: 0 auto; }
547
  .dark .gradio-container { color: var(--body-text-color); }
548
  """
549
 
550
+ EXAMPLES = [
551
+ "The video captures a small bird perched on a tree branch. The bird, with its brown and white feathers, yellow "
552
+ "beak, and black and white head, is the main focus of the video. The bird is seen in three different positions on "
553
+ "the branch, each time facing a different direction. The background of the video is filled with green foliage, "
554
+ "providing a natural and serene setting for the bird. The style of the video is a close-up shot.",
555
+ "A single polished walnut-wood chess pawn stands on a small square pedestal of white Carrara marble. No other "
556
+ "objects are in the scene. The camera makes one complete 360-degree orbit around the pawn while slowly rising.",
557
+ "The video shows a sequence of three identical white cars driving on a road. The first car is in the foreground, "
558
+ "the second car is slightly behind and to the right, and the third car is in the background. The cars are moving "
559
+ "from left to right across the frame. The road appears to be a city street with a dark grey surface. The "
560
+ "background features a city skyline with buildings of various heights, and the sky is a gradient of orange and "
561
+ "yellow, suggesting either sunrise or sunset. The style of the video is realistic with a focus on the cars and "
562
+ "their movement.",
563
+ "a man standing in front of a machine.",
564
+ "The video captures a lively scene on the ocean, with three boats in motion. The first boat, a large white yacht, "
565
+ "is seen in the distance, sailing towards the horizon. The second and third boats are closer to the viewer, both "
566
+ "moving at high speeds, leaving trails of white water behind them. The ocean is a deep blue, and the sky above is "
567
+ "clear, suggesting a sunny day. The boats are the main focus of the video, with their movement and the water's "
568
+ "reaction to their speed creating a dynamic and exciting scene. The overall style of the video is a dynamic "
569
+ "aerial shot, capturing the movement and beauty of the ocean and the boats.",
570
+ ]
571
+
572
  with gr.Blocks(title="Video-Coder") as demo:
573
  with gr.Column(elem_id="col-container"):
574
+ gr.Markdown(
575
+ "# 🎬 Video-Coder: agentic coding for video generation\n"
576
+ "**Video-Coder-9B** writes and runs a Blender Python program for your scene description, inspects the "
577
+ "rendered frames, fixes what is wrong and submits a **draft video** (white-model 3D render with the "
578
+ "layout, motion and camera of the scene) β€” the structure/motion signal the paper feeds to a downstream "
579
+ "video generator.\n\n"
580
+ "[πŸ“– Paper](https://huggingface.co/papers/2611.09894) Β· [🌐 Project page](https://cywang735.github.io/"
581
+ "Video-Coder/) Β· [πŸ’» Code](https://github.com/CYWang735/Video-Coder) Β· [πŸ€— Model](https://huggingface.co/"
582
+ "Video-Coder/Video-Coder-9B)\n\n"
583
+ "⏳ A full agent run takes several minutes: the first turn writes a long script, and every `run_blender` "
584
+ "renders the animation with Blender on CPU. The trajectory streams live; keep this tab open."
585
+ )
586
+ with gr.Row():
587
+ prompt = gr.Textbox(label="Scene description", lines=3, scale=4,
588
+ placeholder="Describe the scene and its motion, e.g. 'a red ball rolls down a ramp "
589
+ "and knocks over three dominoes'")
590
+ with gr.Column(scale=1, min_width=180):
591
+ run_btn = gr.Button("Generate draft video", variant="primary")
592
+ stop_btn = gr.Button("Stop", variant="stop")
593
+ status = gr.Markdown()
594
  with gr.Row():
595
+ with gr.Column(scale=1):
596
+ video_out = gr.Video(label="Draft video (Blender render)", autoplay=True)
597
+ preview_out = gr.Image(label="Latest render: frames 1, β…“, β…”, last", type="filepath")
598
+ with gr.Column(scale=1):
599
+ with gr.Tabs():
600
+ with gr.Tab("Agent trajectory"):
601
+ chat_out = gr.Chatbot(label="Video-Coder harness", height=640, show_label=False)
602
+ with gr.Tab("main.py"):
603
+ code_out = gr.Code(label="Blender script written by the agent", language="python",
604
+ lines=30)
605
+ with gr.Accordion("Advanced settings", open=False):
606
+ max_steps = gr.Slider(2, 40, value=16, step=1, label="Max agent steps (one bash command each)")
607
+ temperature = gr.Slider(0.1, 1.5, value=1.0, step=0.05, label="Temperature")
608
+ top_p = gr.Slider(0.5, 1.0, value=0.95, step=0.01, label="Top-p")
609
+ max_new_tokens = gr.Slider(2048, 32768, value=16384, step=1024, label="Max tokens per agent turn")
610
+
611
+ state = gr.State(None)
612
+ tick = gr.Number(value=0, visible=False)
613
+ outputs = [state, chat_out, video_out, code_out, preview_out, status, tick]
614
+ inputs = [prompt, max_steps, temperature, top_p, max_new_tokens]
615
+ run_btn.click(start, inputs=inputs, outputs=outputs, api_name="start")
616
+ prompt.submit(start, inputs=inputs, outputs=outputs, api_visibility="private")
617
+ # Each agent step is its own request (fresh ZeroGPU token); the tick change chains the next one.
618
+ adv = tick.change(advance, inputs=[state], outputs=outputs, api_name="advance", concurrency_limit=None)
619
+ stop_btn.click(stop_run, inputs=[state], outputs=outputs, cancels=[adv], api_name="stop")
620
+ gr.Examples(examples=[[e] for e in EXAMPLES], inputs=[prompt], outputs=outputs, fn=start,
621
+ cache_examples=False, run_on_click=True)
 
622
 
623
  if __name__ == "__main__":
624
+ demo.queue().launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)
packages.txt CHANGED
@@ -3,6 +3,7 @@ libxrender1
3
  libxxf86vm1
4
  libxfixes3
5
  libxi6
 
6
  libxkbcommon0
7
  libsm6
8
  libgl1
 
3
  libxxf86vm1
4
  libxfixes3
5
  libxi6
6
+ libxext6
7
  libxkbcommon0
8
  libsm6
9
  libgl1
requirements.txt CHANGED
@@ -1,9 +1,8 @@
1
  transformers>=5.17.0
2
- accelerate
3
  torchvision
 
4
  flash-linear-attention
5
- pillow
6
- pyyaml
7
- jinja2
8
  imageio-ffmpeg
9
  uv
 
 
 
1
  transformers>=5.17.0
 
2
  torchvision
3
+ accelerate
4
  flash-linear-attention
 
 
 
5
  imageio-ffmpeg
6
  uv
7
+ pillow
8
+ pyyaml