multimodalart HF Staff commited on
Commit
3c27bc5
·
verified ·
1 Parent(s): 7d2a109

Video-Coder agent demo

Browse files
Files changed (6) hide show
  1. README.md +13 -13
  2. app.py +399 -500
  3. harness/guard.py +133 -0
  4. harness/teacher.yaml +178 -0
  5. packages.txt +0 -1
  6. requirements.txt +4 -3
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 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.
 
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.
 
 
 
app.py CHANGED
@@ -1,19 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
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,90 +30,64 @@ from pathlib import Path # 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,505 +98,422 @@ TOOLS = [{
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
- "A single polished walnut-wood chess pawn stands on a small square pedestal of white Carrara marble. No other "
552
- "objects are in the scene. The camera makes one complete 360-degree orbit around the pawn while slowly rising.",
553
- "The video shows a sequence of three identical white cars driving on a road. The first car is in the foreground, "
554
- "the second car is slightly behind and to the right, and the third car is in the background. The cars are moving "
555
- "from left to right across the frame. The road appears to be a city street with a dark grey surface. The "
556
- "background features a city skyline with buildings of various heights, and the sky is a gradient of orange and "
557
- "yellow, suggesting either sunrise or sunset. The style of the video is realistic with a focus on the cars and "
558
- "their movement.",
559
- "a man standing in front of a machine.",
560
- "The video captures a lively scene on the ocean, with three boats in motion. The first boat, a large white yacht, "
561
- "is seen in the distance, sailing towards the horizon. The second and third boats are closer to the viewer, both "
562
- "moving at high speeds, leaving trails of white water behind them. The ocean is a deep blue, and the sky above is "
563
- "clear, suggesting a sunny day. The boats are the main focus of the video, with their movement and the water's "
564
- "reaction to their speed creating a dynamic and exciting scene. The overall style of the video is a dynamic "
565
- "aerial shot, capturing the movement and beauty of the ocean and the boats.",
566
- ]
567
-
568
  with gr.Blocks(title="Video-Coder") as demo:
569
  with gr.Column(elem_id="col-container"):
570
- gr.Markdown(
571
- "# 🎬 Video-Coder: agentic coding for video generation\n"
572
- "**Video-Coder-9B** writes and runs a Blender Python program for your scene description, inspects the "
573
- "rendered frames, fixes what is wrong and submits a **draft video** (white-model 3D render with the "
574
- "layout, motion and camera of the scene) — the structure/motion signal the paper feeds to a downstream "
575
- "video generator.\n\n"
576
- "[📖 Paper](https://huggingface.co/papers/2611.09894) · [🌐 Project page](https://cywang735.github.io/"
577
- "Video-Coder/) · [💻 Code](https://github.com/CYWang735/Video-Coder) · [🤗 Model](https://huggingface.co/"
578
- "Video-Coder/Video-Coder-9B)\n\n"
579
- "⏳ A full agent run takes several minutes: the first turn writes a long script, and every `run_blender` "
580
- "renders the animation with Blender on CPU. The trajectory streams live; keep this tab open."
581
- )
582
- with gr.Row():
583
- prompt = gr.Textbox(label="Scene description", lines=3, scale=4,
584
- placeholder="Describe the scene and its motion, e.g. 'a red ball rolls down a ramp "
585
- "and knocks over three dominoes'")
586
- with gr.Column(scale=1, min_width=180):
587
- run_btn = gr.Button("Generate draft video", variant="primary")
588
- stop_btn = gr.Button("Stop", variant="stop")
589
- status = gr.Markdown()
590
  with gr.Row():
591
- with gr.Column(scale=1):
592
- video_out = gr.Video(label="Draft video (Blender render)", autoplay=True)
593
- preview_out = gr.Image(label="Latest render: frames 1, ⅓, ⅔, last", type="filepath")
594
- with gr.Column(scale=1):
595
- with gr.Tabs():
596
- with gr.Tab("Agent trajectory"):
597
- chat_out = gr.Chatbot(label="Video-Coder harness", height=640, show_label=False)
598
- with gr.Tab("main.py"):
599
- code_out = gr.Code(label="Blender script written by the agent", language="python",
600
- lines=30)
601
- with gr.Accordion("Advanced settings", open=False):
602
- max_steps = gr.Slider(2, 40, value=16, step=1, label="Max agent steps (one bash command each)")
603
- temperature = gr.Slider(0.1, 1.5, value=1.0, step=0.05, label="Temperature")
604
- top_p = gr.Slider(0.5, 1.0, value=0.95, step=0.01, label="Top-p")
605
- max_new_tokens = gr.Slider(2048, 32768, value=16384, step=1024, label="Max tokens per agent turn")
606
-
607
- state = gr.State(None)
608
- tick = gr.Number(value=0, visible=False)
609
- outputs = [state, chat_out, video_out, code_out, preview_out, status, tick]
610
- inputs = [prompt, max_steps, temperature, top_p, max_new_tokens]
611
- run_btn.click(start, inputs=inputs, outputs=outputs, api_name="start")
612
- prompt.submit(start, inputs=inputs, outputs=outputs, api_visibility="private")
613
- # Each agent step is its own request (fresh ZeroGPU token); the tick change chains the next one.
614
- adv = tick.change(advance, inputs=[state], outputs=outputs, api_name="advance", concurrency_limit=None)
615
- stop_btn.click(stop_run, inputs=[state], outputs=outputs, cancels=[adv], api_name="stop")
616
- gr.Examples(examples=[[e] for e in EXAMPLES], inputs=[prompt], outputs=outputs, fn=start,
617
- cache_examples=False, run_on_click=True)
 
618
 
619
  if __name__ == "__main__":
620
- demo.queue().launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)
 
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
 
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
  "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)
harness/guard.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Harness-boundary guard and repetition helpers, copied from Video-Coder's video_coder_harness/agent.py
2
+ (GuardedAgent) without the mini-swe-agent dependency. Apache-2.0, https://github.com/CYWang735/Video-Coder"""
3
+
4
+ import difflib
5
+ import re
6
+ import shlex
7
+
8
+
9
+ TOOLS = ("run_blender", "check_script")
10
+ # Shell-level rules: applied to the command with here-document bodies removed, so the Python written into main.py
11
+ # (comments like "frame -> /tmp", "x > 0") is not mistaken for shell redirections or commands.
12
+ _INSPECT = re.compile(rf"^\s*(?:sudo\s+)?(?:type|which|command\s+-v|cat|less|more|head|tail|sed|awk|file|readlink|"
13
+ rf"realpath|ls|stat|strings|od|xxd)\b[^;&|]*\b({'|'.join(TOOLS)})\b")
14
+ _ENV_CMD = re.compile(r"(?m)(?:^|[;&|(]|\$\()\s*(?:env|printenv|set|export\s+-p|declare\s+-[px])\s*(?:$|[|;&>)])")
15
+ # writes to an absolute path (redirection, tee, cp/mv/ln target, mkdir/touch), except /dev/null and /dev/std*
16
+ _ABS_WRITE = re.compile(r"(?:(?<![0-9&<])>>?|\btee(?:\s+-a)?|\b(?:mkdir|touch)(?:\s+-p)?)\s*['\"]?"
17
+ r"(/(?!dev/(?:null|stdout|stderr)\b)[^\s'\";|&]*)")
18
+ _ABS_TARGET = re.compile(r"\b(?:cp|mv|ln|install|rsync)\b[^\n;|&]*\s['\"]?(/(?!dev/null\b)[^\s'\";|&]+)['\"]?\s*(?:$|[;|&\n])")
19
+ # Whole-text rules (here-document bodies included, so `python3 - <<EOF ... open(...)` is covered too):
20
+ # harness variables and locations that hold credentials or lie outside the task directory.
21
+ _HARNESS_VAR = re.compile(r"\bVCSFT_")
22
+ _SENSITIVE = re.compile(r"(~/|\$\{?HOME\b|/home/|/root/|\.ssh/|\.netrc\b|\.config/|\.env\b|\bOPENAI_API_KEY\b|"
23
+ r"\b[A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD)\b|\bsk-[A-Za-z0-9_-]{8,})")
24
+ _HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
25
+
26
+
27
+ def strip_heredocs(command: str) -> str:
28
+ """The shell text of a command without here-document bodies (the delimiter lines are dropped too)."""
29
+ lines, out, i = command.split("\n"), [], 0
30
+ while i < len(lines):
31
+ line = lines[i]
32
+ out.append(line)
33
+ i += 1
34
+ for m in _HEREDOC.finditer(line):
35
+ while i < len(lines) and lines[i].strip() != m.group(2):
36
+ i += 1
37
+ i += 1 # the closing delimiter
38
+ return "\n".join(out)
39
+
40
+
41
+ # Python code (also inside here-documents) that writes to an absolute path.
42
+ _PY_ABS_WRITE = [
43
+ re.compile(r"""\bopen\(\s*[rbuf]*(['"])(/[^'"]*)\1\s*,\s*[rbuf]*(['"])[^'"]*[wax+][^'"]*\3"""),
44
+ re.compile(r"""\bPath\(\s*(['"])(/[^'"]*)\1\s*\)\s*\.\s*(?:write_text|write_bytes|mkdir|touch|unlink|rename|replace)\b"""),
45
+ re.compile(r"""\bos\.(?:makedirs|mkdir|rename|replace|remove|unlink|rmdir)\(\s*(['"])(/[^'"]*)\1"""),
46
+ re.compile(r"""\bfilepath\s*=\s*[rbuf]*(['"])(/[^'"]*)\1"""),
47
+ ]
48
+ _PARENT_DIR = re.compile(r"(?<![\w.])\.\./")
49
+ _SEPARATORS = set("();<>|&")
50
+
51
+
52
+ def in_dir(path: str, directory: str) -> bool:
53
+ """True if `path` is `directory` itself or lies below it ('/w-other' is not inside '/w')."""
54
+ d = directory.rstrip("/")
55
+ return bool(d) and (path == d or path.startswith(d + "/"))
56
+
57
+
58
+ def _mask_workdir(text: str, workdir: str) -> str:
59
+ """Replace the task directory by '.' in `text`, but not a longer sibling name such as '<workdir>-other'."""
60
+ if not workdir:
61
+ return text
62
+ return re.sub(re.escape(workdir.rstrip("/")) + r"(?![A-Za-z0-9_.-])", ".", text)
63
+
64
+
65
+ def _is_separator(token: str | None) -> bool:
66
+ return bool(token) and all(c in _SEPARATORS for c in token)
67
+
68
+
69
+ def _shell_path_violation(shell: str, workdir: str) -> str:
70
+ """Rules on shell words: `..` path components and `cd` to somewhere outside the task directory."""
71
+ text = shell.replace("\\\n", " ").replace("\n", " ; ")
72
+ try:
73
+ lex = shlex.shlex(text, posix=True, punctuation_chars=True)
74
+ lex.whitespace_split = True
75
+ tokens = list(lex)
76
+ except ValueError: # unbalanced quotes etc.: the regex rules still apply
77
+ return ""
78
+ for i, tok in enumerate(tokens):
79
+ if tok == ".." or tok.startswith("../") or "/../" in tok or tok.endswith("/.."):
80
+ return "it reaches outside the task directory with '..'"
81
+ if tok in ("cd", "pushd") and (i == 0 or _is_separator(tokens[i - 1])):
82
+ nxt = tokens[i + 1] if i + 1 < len(tokens) else ""
83
+ target = "" if _is_separator(nxt) else nxt
84
+ if not target or target.startswith(("~", "$", "-")):
85
+ return f"`{tok}` without a path inside the task directory leaves it"
86
+ if target.startswith("/") and not target.startswith("/dev/") and not in_dir(target, workdir):
87
+ return f"it changes into a directory outside the task directory ({target})"
88
+ return ""
89
+
90
+
91
+ def harness_violation(command: str, harness_markers: list[str], workdir: str = "") -> str:
92
+ """Reason why a command crosses the harness boundary, or "" if it is fine."""
93
+ for marker in harness_markers:
94
+ if marker and marker in command:
95
+ return f"it references the harness installation ({marker})"
96
+ if _HARNESS_VAR.search(command):
97
+ return "it reads the harness environment variables"
98
+ m = _SENSITIVE.search(_mask_workdir(command, workdir)) # the task directory itself is fine
99
+ if m:
100
+ return f"it references files or variables outside the task directory ({m.group(1)})"
101
+ if _PARENT_DIR.search(command):
102
+ return "it reaches outside the task directory with '../'"
103
+ for pattern in _PY_ABS_WRITE: # whole text: covers `python3 -c` and here-documents
104
+ for m in pattern.finditer(command):
105
+ path = m.group(2)
106
+ if not (path.startswith("/dev/") or in_dir(path, workdir)):
107
+ return f"it writes outside the task directory ({path})"
108
+ shell = strip_heredocs(command)
109
+ for line in shell.splitlines():
110
+ if _INSPECT.search(line):
111
+ return f"it inspects how {', '.join(TOOLS)} are implemented"
112
+ if _ENV_CMD.search(shell):
113
+ return "it reads the environment variables"
114
+ for pattern in (_ABS_WRITE, _ABS_TARGET):
115
+ for m in pattern.finditer(shell):
116
+ if not in_dir(m.group(1), workdir):
117
+ return f"it writes outside the task directory ({m.group(1)})"
118
+ return _shell_path_violation(shell, workdir)
119
+
120
+
121
+ BLOCKED = ("BLOCKED: this command was not executed because {reason}. Work only in the current directory and use "
122
+ "only check_script and run_blender; do not inspect or modify the harness or run Blender any other way. "
123
+ "For a quick experiment, write a small script in the current directory and run `run_blender "
124
+ "experiment.py`; its print() output appears in the blender log.")
125
+ REPEAT_WARNING = ("\n\nWARNING: this is the {n}th consecutive near-identical command with the same result. Repeating "
126
+ "it will not help: read the output, then change the script or your approach. The task ends after "
127
+ "{stop} repetitions.")
128
+
129
+
130
+
131
+
132
+ def _normalize_output(text: str) -> str:
133
+ return re.sub(r"\d+(?:\.\d+)?", "#", text or "")
harness/teacher.yaml ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Teacher trajectory generation: mini-swe-agent (tool-call mode) + OpenAI official API.
2
+ # The system/instance templates below become the SFT prompts, so the student must later run
3
+ # with the same templates. Changing them requires a new output directory.
4
+
5
+ harness:
6
+ # Blender version the scripts must target. Set this to the version you will execute/render with
7
+ # later; the prompt tells the teacher to use exactly this API. Required for non-dry runs.
8
+ blender_version: "4.5"
9
+ # true: the agent gets `run_blender` (real execution + render) and a sample is accepted only if the harness
10
+ # re-executes the final script from scratch and it renders a valid video. false: static checks only.
11
+ blender_available: true
12
+ bpy_python: ${WORK}/envs/bpy45/bin/python # Python 3.11 + bpy 4.5.14
13
+ bpy_libs: ${WORK}/envs/bpy45-mesa # libGL/X11 libs + Mesa llvmpipe EGL (Workbench on CPU)
14
+ exec_timeout: 600 # seconds per execution, render included
15
+ summary_samples: 6 # frames sampled for the scene summary shown to the agent
16
+ content_samples: 8 # frames sampled for the empty / static / out-of-view checks
17
+ # run_blender returns evenly spaced frames of the render as images, so the agent inspects its own result
18
+ # (needs a video_coder_harness.models.Visual* model class)
19
+ visual_feedback: true
20
+ preview_frames: 8
21
+ preview_width: 640
22
+ # optional VLM check of the final render (video_coder_harness/judge.py); off: acceptance uses deterministic checks only
23
+ judge:
24
+ enabled: false
25
+ model: openai/gpt-6-astra
26
+ frames: 8
27
+ width: 640
28
+ min_score: 8
29
+ attempts: 2
30
+ output_video: output.mp4
31
+ fps: 24
32
+ frame_count: 120 # shortest video: 120 frames = 5 s at 24 fps
33
+ max_frame_count: 240 # longest video: 240 frames = 10 s; the model chooses the length in between
34
+ resolution: [1280, 720]
35
+ script_name: main.py
36
+
37
+ model:
38
+ # Native Responses API. Do not use `litellm` (Chat Completions): for gpt-6 models litellm bridges it to the
39
+ # Responses API and drops the function_call items, so every step fails with "No tool calls found".
40
+ # The Responses path also carries the model's encrypted reasoning across turns.
41
+ model_class: video_coder_harness.models.VisualResponseModel # litellm_response + images in tool results (run_blender preview)
42
+ model_name: openai/gpt-6-astra # litellm "<provider>/<model id>"; confirm the exact id on your OpenAI account
43
+ cost_tracking: ignore_errors # litellm may not know the price of a new model; budget is enforced by max_calls
44
+ # litellm_model_registry: configs/model_registry.json # optional: register prices to get real cost numbers
45
+ model_kwargs:
46
+ drop_params: true
47
+ parallel_tool_calls: false # one command per step gives cleaner trajectories
48
+ # reasoning_effort: high
49
+ observation_template: |
50
+ {%- if output.output | length < 10000 -%}
51
+ {
52
+ "returncode": {{ output.returncode }},
53
+ "output": {{ output.output | tojson }}
54
+ {%- if output.exception_info %}, "exception_info": {{ output.exception_info | tojson }}{% endif %}
55
+ }
56
+ {%- else -%}
57
+ {
58
+ "returncode": {{ output.returncode }},
59
+ "output_head": {{ output.output[:5000] | tojson }},
60
+ "output_tail": {{ output.output[-5000:] | tojson }},
61
+ "elided_chars": {{ output.output | length - 10000 }},
62
+ "warning": "Output too long."
63
+ {%- if output.exception_info %}, "exception_info": {{ output.exception_info | tojson }}{% endif %}
64
+ }
65
+ {%- endif -%}
66
+ format_error_template: |
67
+ {% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
68
+ 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.
69
+ {%- else -%}
70
+ Tool call error:
71
+
72
+ <error>
73
+ {{error}}
74
+ </error>
75
+
76
+ Every response must call the 'bash' tool exactly once with {"command": "..."}.
77
+ To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` on its own.
78
+ {%- endif %}
79
+
80
+ environment:
81
+ environment_class: local
82
+ timeout: 900 # per command; run_blender includes a full render
83
+ env:
84
+ PAGER: cat
85
+ MANPAGER: cat
86
+ PIP_PROGRESS_BAR: "off"
87
+ TQDM_DISABLE: "1"
88
+
89
+ agent:
90
+ step_limit: 30
91
+ cost_limit: 0 # per-task cost limit is meaningless without price data; use step_limit
92
+ wall_time_limit_seconds: 1800
93
+ max_consecutive_format_errors: 3
94
+ repeat_warn: 3 # warn from the 3rd consecutive near-identical command with the same result
95
+ repeat_stop: 6 # ... and end the task (exit status RepeatedActions) at the 6th
96
+ system_template: |
97
+ You are an expert Blender technical artist and Python programmer. You write Blender Python (bpy) scripts that build and animate 3D scenes and render them as untextured white-model (clay) videos. You work in a Linux shell through a bash tool.
98
+ instance_template: |
99
+ ## Task
100
+
101
+ Write a Blender {{ blender_version }} Python script that builds the following scene and renders it as an animated white-model video:
102
+
103
+ <scene_description>
104
+ {{ task }}
105
+ </scene_description>
106
+
107
+
108
+ ## Script contract
109
+
110
+ - Write exactly one self-contained script: `{{ script_name }}` in the current directory.
111
+ - It is executed headless as `blender --background --factory-startup --python {{ script_name }}` with Blender {{ blender_version }}, from the directory the video must be written to. Use only the Blender {{ blender_version }} API and the Python standard library; no external assets, files or add-ons.
112
+ - Start by removing every object in the default scene, then build the scene from scratch.
113
+ - Geometry: model with primitives, modifiers, `bmesh` or `mesh.from_pydata`. Name objects meaningfully. Keep the total scene under ~500k faces. Objects must not intersect unless the description implies contact.
114
+ {% if max_frame_count > frame_count -%}
115
+ - Animation: the video lasts {{ (frame_count / fps) | round | int }}-{{ (max_frame_count / fps) | round | int }} seconds at {{ fps }} fps, i.e. {{ frame_count }}-{{ max_frame_count }} frames; choose the length that suits the action. Set `scene.frame_start = 1` and `scene.frame_end` to that length, and animate with keyframes, drivers, constraints or physics so that the motion described actually happens within that range. Bake any physics simulation before rendering.
116
+ {%- else -%}
117
+ - Animation: the video has {{ frame_count }} frames at {{ fps }} fps. Set `scene.frame_start = 1` and `scene.frame_end = {{ frame_count }}`, and animate with keyframes, drivers, constraints or physics so that the motion described actually happens within that range. Bake any physics simulation before rendering.
118
+ {%- endif %}
119
+ - Camera: create a camera, assign it to `scene.camera`, and frame the action so that it stays in view for all frames (animate the camera if the description asks for camera motion).
120
+ - Seed any randomness (`random.seed(...)`) so the result is deterministic. Prefer `bpy.data` / low-level API over `bpy.ops` where the operator needs UI context.
121
+ - Do not import subprocess, socket, shutil, urllib or similar modules, and do not call os.system/eval/exec.
122
+
123
+ ## White-model render (required, at the end of the script)
124
+
125
+ Every video in this dataset uses the same look, so configure exactly this and then render:
126
+
127
+ - `scene.render.engine = 'BLENDER_WORKBENCH'`
128
+ - `scene.display.shading`: `light = 'STUDIO'`, `color_type = 'SINGLE'`, `single_color = (0.8, 0.8, 0.8)`, `show_shadows = True`, `show_cavity = True`, `cavity_type = 'WORLD'`. No materials or textures are needed; Workbench studio lighting ignores scene lights.
129
+ - Resolution {{ resolution[0] }}x{{ resolution[1] }} at `resolution_percentage = 100`, `scene.render.fps = {{ fps }}`.
130
+ - Output: `scene.render.image_settings.file_format = 'FFMPEG'`, `scene.render.ffmpeg.format = 'MPEG4'`, `scene.render.ffmpeg.codec = 'H264'`, `scene.render.filepath = os.path.join(os.getcwd(), '{{ output_video }}')`.
131
+ - Last statement: `bpy.ops.render.render(animation=True)`.
132
+ - Write no other files: no .blend files, images or caches outside the render. Do not quit Blender.
133
+
134
+ ## Tools
135
+
136
+ {% if blender_available -%}
137
+ Blender {{ blender_version }} is available through two commands (do not install anything):
138
+
139
+ ```bash
140
+ check_script {{ script_name }} # fast static check: syntax, bpy import, camera, render call, forbidden calls
141
+ run_blender {{ script_name }} # executes the script exactly like the harness and renders {{ output_video }} (CPU, ~20-120 s)
142
+ ```
143
+
144
+ `run_blender` reports the Python traceback if the script fails, whether {{ output_video }} has {% if max_frame_count > frame_count %}{{ frame_count }}-{{ max_frame_count }} frames matching the scene frame range{% else %}{{ frame_count }} frames{% endif %} at {{ resolution[0] }}x{{ resolution[1] }}, and a scene summary: object counts, animated objects, world positions of moving meshes at sampled frames, and meshes whose origin is outside the camera view. Use the numbers to verify that the motion, timing and framing match the description.{% if visual_feedback %} It also returns {{ preview_frames }} frames sampled evenly from the rendered video as images, with their frame numbers and times.{% endif %} It reports a problem when most frames are empty, nothing moves, or no object is in view.
145
+ {%- else -%}
146
+ Blender is NOT installed in this environment and must not be installed (no `pip install bpy`, no downloads). You cannot execute the script, so reason carefully about the Blender {{ blender_version }} API: exact operator names, property names and argument names.
147
+ The only verification tool is the static checker:
148
+
149
+ ```bash
150
+ check_script {{ script_name }}
151
+ ```
152
+
153
+ It checks syntax, required structure (bpy import, camera, render call) and forbidden calls. Passing it does not prove the script runs, so review the code yourself before submitting.
154
+ {%- endif %}
155
+
156
+ ## Workflow
157
+
158
+ 1. Think & Plan: Before generating any code, explicitly reason about the scene: objects, their dimensions and positions in meters, the motion over time (frame timing), and camera placement/framing.
159
+ 2. Write `{{ script_name }}` with a heredoc: `cat <<'EOF' > {{ script_name }}` ... `EOF`.
160
+ 3. Run `check_script {{ script_name }}` and fix every error.
161
+ {% if blender_available -%}
162
+ 4. Run `run_blender {{ script_name }}`. Fix every error and problem it reports, and compare the scene summary against the description (positions, timing, clearances, camera framing).{% if visual_feedback %}
163
+ Then inspect the returned frames one by one against the description and write down every concrete problem you see: missing, unrecognizable or wrongly counted objects; wrong arrangement; motion that is missing, in the wrong order or at the wrong time; objects cut off, outside the frame or too small; objects passing through each other; wrong camera behavior. Fix the problems in the script and run `run_blender` again.{% endif %}
164
+ Repeat until it reports RUN OK{% if visual_feedback %} and the frames clearly show every element of the description{% else %} and the numbers match{% endif %}. If a command keeps giving the same result, change the script or your approach instead of repeating it; repeated identical commands end the task.
165
+ 5. Submit with `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as a command on its own. You cannot continue after that. The harness then re-executes `{{ script_name }}` from scratch; the task only counts if it renders a valid video{% if judge_enabled %} and a reviewer who compares frames of the video against the description confirms that every element it describes (objects, arrangement, motion, camera) is clearly shown{% endif %}.
166
+ {%- else -%}
167
+ 4. Re-read the script (`nl -ba {{ script_name }}`) and check API usage, coordinates, keyframe timing and camera framing against the description; fix problems with a rewrite or `sed -i`.
168
+ 5. When the script is final and passes the check, submit with `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as a command on its own. You cannot continue after that.
169
+ {%- endif %}
170
+
171
+ ## Command rules
172
+
173
+ - Each command runs in a fresh subshell in the task directory; `cd` and environment variables do not persist.
174
+ - Only create or modify files in the current directory.
175
+ - Use only `check_script` and `run_blender` to check and execute scripts. Do not inspect or modify the harness itself (how these commands are implemented, `VCSFT_*` environment variables, the Blender/bpy installation) and do not run Blender or bpy any other way; such commands are blocked.
176
+ - For a quick experiment, write a small script in the current directory and run `run_blender experiment.py`: whatever it `print()`s appears in the blender log. Delete experiment files before submitting.
177
+
178
+
packages.txt CHANGED
@@ -3,7 +3,6 @@ libxrender1
3
  libxxf86vm1
4
  libxfixes3
5
  libxi6
6
- libxext6
7
  libxkbcommon0
8
  libsm6
9
  libgl1
 
3
  libxxf86vm1
4
  libxfixes3
5
  libxi6
 
6
  libxkbcommon0
7
  libsm6
8
  libgl1
requirements.txt CHANGED
@@ -1,8 +1,9 @@
1
  transformers>=5.17.0
2
- torchvision
3
  accelerate
 
4
  flash-linear-attention
5
- imageio-ffmpeg
6
- uv
7
  pillow
8
  pyyaml
 
 
 
 
1
  transformers>=5.17.0
 
2
  accelerate
3
+ torchvision
4
  flash-linear-attention
 
 
5
  pillow
6
  pyyaml
7
+ jinja2
8
+ imageio-ffmpeg
9
+ uv