Spaces:
Running on Zero
Running on Zero
Video-Coder agent demo
Browse files- README.md +13 -13
- app.py +399 -500
- harness/guard.py +133 -0
- harness/teacher.yaml +178 -0
- packages.txt +0 -1
- 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
|
| 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:
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
camera).
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 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 (
|
| 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 |
-
|
| 30 |
-
HARNESS_ROOT =
|
| 31 |
sys.path.insert(0, str(HARNESS_ROOT))
|
| 32 |
-
|
| 33 |
-
|
| 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
|
| 48 |
-
#
|
| 49 |
# --------------------------------------------------------------------------------------------------------------------
|
| 50 |
-
BPY_ENV = Path(os.environ.get("BPY_ENV", str(Path.home() / "
|
| 51 |
-
|
| 52 |
|
| 53 |
|
| 54 |
def setup_bpy() -> None:
|
| 55 |
-
if
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
if r.returncode == 0:
|
| 59 |
-
print("bpy ready:", r.stdout.strip())
|
| 60 |
-
return
|
| 61 |
t0 = time.time()
|
| 62 |
-
subprocess.run([
|
| 63 |
-
subprocess.run([
|
| 64 |
-
|
| 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 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
"visual_feedback": True,
|
| 82 |
-
"preview_frames": 8,
|
| 83 |
-
"
|
| 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 |
-
|
| 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 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
"""
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
{{
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
# --------------------------------------------------------------------------------------------------------------------
|
| 156 |
-
#
|
| 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 |
-
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
-
@spaces.GPU(duration=
|
| 166 |
-
def
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
n_in = inputs["input_ids"].shape[1]
|
| 173 |
-
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=False)
|
| 174 |
result = {}
|
| 175 |
|
| 176 |
-
def
|
| 177 |
try:
|
| 178 |
-
out = model.generate(
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
| 183 |
result["error"] = f"{type(e).__name__}: {e}"
|
| 184 |
streamer.end()
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
text = ""
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
text
|
| 192 |
-
|
| 193 |
-
last = time.time()
|
| 194 |
-
yield text, False, None
|
| 195 |
-
th.join()
|
| 196 |
if "error" in result:
|
| 197 |
-
raise gr.Error(result[
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
# --------------------------------------------------------------------------------------------------------------------
|
| 203 |
-
#
|
| 204 |
-
# litellm/vLLM gateway (the model is called in-process).
|
| 205 |
# --------------------------------------------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def agent_env() -> dict:
|
| 207 |
-
env =
|
| 208 |
-
env
|
| 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:
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
| 221 |
try:
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 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 |
-
|
| 245 |
-
images.append((caption, m.group(2).strip()))
|
| 246 |
return f"[image {len(images)} attached below]"
|
| 247 |
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
|
| 251 |
-
def
|
| 252 |
-
return
|
| 253 |
|
| 254 |
|
| 255 |
-
def
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 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 |
-
|
| 283 |
-
p = workdir / SCRIPT
|
| 284 |
return p.read_text(errors="replace") if p.is_file() else ""
|
| 285 |
|
| 286 |
|
| 287 |
-
def
|
| 288 |
-
|
| 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:
|
| 331 |
-
max_steps: maximum number of agent turns (
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
| 335 |
"""
|
| 336 |
if not prompt or not prompt.strip():
|
| 337 |
-
raise gr.Error("
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
if
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
messages.append({"role": "tool", "content": obs})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 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 |
-
|
| 451 |
-
|
| 452 |
-
|
| 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 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 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 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
|
| 541 |
|
| 542 |
# --------------------------------------------------------------------------------------------------------------------
|
| 543 |
# UI
|
| 544 |
# --------------------------------------------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
CSS = """
|
| 546 |
-
#col-container { max-width:
|
| 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=
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
|
|
|
| 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
|