Spaces:
Running on Zero
Running on Zero
Video-Coder demo
Browse files- README.md +25 -7
- app.py +540 -0
- harness/LICENSE +202 -0
- harness/bin/check_script +6 -0
- harness/bin/run_blender +6 -0
- harness/instance_template.txt +64 -0
- harness/video_coder_harness/__init__.py +0 -0
- harness/video_coder_harness/blender_exec.py +260 -0
- harness/video_coder_harness/guard.py +137 -0
- harness/video_coder_harness/run_bpy.py +134 -0
- harness/video_coder_harness/static_check.py +123 -0
- harness/video_coder_harness/video.py +74 -0
- packages.txt +13 -0
- requirements.txt +8 -0
README.md
CHANGED
|
@@ -1,13 +1,31 @@
|
|
| 1 |
---
|
| 2 |
-
title: Video
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.31.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Video-Coder
|
| 3 |
+
emoji: 🎬
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
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.
|
app.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 20 |
+
import uuid # noqa: E402
|
| 21 |
+
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 |
+
|
| 33 |
+
from video_coder_harness.blender_exec import execute as blender_execute # noqa: E402
|
| 34 |
+
from video_coder_harness.guard import ( # noqa: E402
|
| 35 |
+
BLOCKED,
|
| 36 |
+
REPEAT_WARNING,
|
| 37 |
+
_normalize_output,
|
| 38 |
+
harness_violation,
|
| 39 |
+
)
|
| 40 |
+
from video_coder_harness.static_check import check_file # noqa: E402
|
| 41 |
+
|
| 42 |
+
MODEL_ID = "Video-Coder/Video-Coder-9B"
|
| 43 |
+
|
| 44 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 45 |
+
# Blender 4.5 (bpy 4.5.14 on Python 3.11, same as the authors' bpy-agent.sif sandbox), rendered on CPU with Mesa
|
| 46 |
+
# llvmpipe. Installed into its own venv at startup because the Space itself runs Python 3.12.
|
| 47 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 48 |
+
BPY_ENV = Path(os.environ.get("BPY_ENV", str(Path.home() / "bpyenv")))
|
| 49 |
+
BPY_PY = BPY_ENV / "bin" / "python"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def setup_bpy() -> None:
|
| 53 |
+
if BPY_PY.exists():
|
| 54 |
+
r = subprocess.run([str(BPY_PY), "-c", "import bpy; print(bpy.app.version_string)"],
|
| 55 |
+
capture_output=True, text=True)
|
| 56 |
+
if r.returncode == 0:
|
| 57 |
+
print("bpy ready:", r.stdout.strip())
|
| 58 |
+
return
|
| 59 |
+
t0 = time.time()
|
| 60 |
+
subprocess.run([sys.executable, "-m", "uv", "venv", "-q", "-p", "3.11", "--clear", str(BPY_ENV)], check=True)
|
| 61 |
+
subprocess.run([sys.executable, "-m", "uv", "pip", "install", "-q", "-p", str(BPY_PY), "bpy==4.5.14"],
|
| 62 |
+
check=True)
|
| 63 |
+
r = subprocess.run([str(BPY_PY), "-c", "import bpy; print(bpy.app.version_string)"],
|
| 64 |
+
capture_output=True, text=True)
|
| 65 |
+
print(f"bpy install took {time.time() - t0:.0f}s:", r.stdout.strip(), r.stderr[-2000:])
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
setup_bpy()
|
| 69 |
+
|
| 70 |
+
# Harness settings: configs/teacher.yaml + configs/uni_container_visual.yaml (the RL / inference setting).
|
| 71 |
+
HARNESS = {
|
| 72 |
+
"blender_version": "4.5",
|
| 73 |
+
"blender_available": True,
|
| 74 |
+
"bpy_python": str(BPY_PY),
|
| 75 |
+
"bpy_libs": "/usr",
|
| 76 |
+
"exec_timeout": 600,
|
| 77 |
+
"summary_samples": 6,
|
| 78 |
+
"content_samples": 8,
|
| 79 |
+
"visual_feedback": True,
|
| 80 |
+
"preview_frames": 8,
|
| 81 |
+
"preview_width": 640,
|
| 82 |
+
"judge": {"enabled": False},
|
| 83 |
+
"output_video": "output.mp4",
|
| 84 |
+
"fps": 24,
|
| 85 |
+
"frame_count": 120,
|
| 86 |
+
"max_frame_count": 240,
|
| 87 |
+
"resolution": [1280, 720],
|
| 88 |
+
"script_name": "main.py",
|
| 89 |
+
}
|
| 90 |
+
SCRIPT = HARNESS["script_name"]
|
| 91 |
+
CMD_TIMEOUT = 900
|
| 92 |
+
REPEAT_WARN, REPEAT_STOP, REPEAT_SIM = 3, 6, 0.9
|
| 93 |
+
MAX_FORMAT_ERRORS = 3
|
| 94 |
+
RUNS_DIR = Path(tempfile.gettempdir()) / "video_coder_runs"
|
| 95 |
+
RUNS_DIR.mkdir(parents=True, exist_ok=True)
|
| 96 |
+
_SECRET_ENV = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
|
| 97 |
+
HARNESS_MARKERS = [str(HARNESS_ROOT), str(BPY_ENV)]
|
| 98 |
+
|
| 99 |
+
SYSTEM_PROMPT = (
|
| 100 |
+
"You are an expert Blender technical artist and Python programmer. You write Blender Python (bpy) scripts that "
|
| 101 |
+
"build and animate 3D scenes and render them as untextured white-model (clay) videos. You work in a Linux shell "
|
| 102 |
+
"through a bash tool."
|
| 103 |
+
)
|
| 104 |
+
# The exact user prompt of the SFT trajectories (Video-Coder/Train-Data sft_data.json), with the scene as __TASK__.
|
| 105 |
+
INSTANCE_TEMPLATE = (HARNESS_ROOT / "instance_template.txt").read_text()
|
| 106 |
+
TOOLS = [{
|
| 107 |
+
"type": "function",
|
| 108 |
+
"function": {
|
| 109 |
+
"name": "bash",
|
| 110 |
+
"description": "Execute a bash command",
|
| 111 |
+
"parameters": {
|
| 112 |
+
"type": "object",
|
| 113 |
+
"properties": {"command": {"type": "string", "description": "The bash command to execute"}},
|
| 114 |
+
"required": ["command"],
|
| 115 |
+
},
|
| 116 |
+
},
|
| 117 |
+
}]
|
| 118 |
+
OBSERVATION_TEMPLATE = Template("""{%- if output.output | length < 10000 -%}
|
| 119 |
+
{
|
| 120 |
+
"returncode": {{ output.returncode }},
|
| 121 |
+
"output": {{ output.output | tojson }}
|
| 122 |
+
{%- if output.exception_info %}, "exception_info": {{ output.exception_info | tojson }}{% endif %}
|
| 123 |
+
}
|
| 124 |
+
{%- else -%}
|
| 125 |
+
{
|
| 126 |
+
"returncode": {{ output.returncode }},
|
| 127 |
+
"output_head": {{ output.output[:5000] | tojson }},
|
| 128 |
+
"output_tail": {{ output.output[-5000:] | tojson }},
|
| 129 |
+
"elided_chars": {{ output.output | length - 10000 }},
|
| 130 |
+
"warning": "Output too long."
|
| 131 |
+
{%- if output.exception_info %}, "exception_info": {{ output.exception_info | tojson }}{% endif %}
|
| 132 |
+
}
|
| 133 |
+
{%- endif -%}
|
| 134 |
+
""", undefined=StrictUndefined)
|
| 135 |
+
FORMAT_ERROR_TEMPLATE = Template("""{% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
|
| 136 |
+
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.
|
| 137 |
+
{%- else -%}
|
| 138 |
+
Tool call error:
|
| 139 |
+
|
| 140 |
+
<error>
|
| 141 |
+
{{error}}
|
| 142 |
+
</error>
|
| 143 |
+
|
| 144 |
+
Every response must call the 'bash' tool exactly once with {"command": "..."}.
|
| 145 |
+
To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` on its own.
|
| 146 |
+
{%- endif %}
|
| 147 |
+
""")
|
| 148 |
+
MM_TAG = re.compile(r"(?s)<MSWEA_MULTIMODAL_CONTENT><CONTENT_TYPE>(.+?)</CONTENT_TYPE>(.+?)</MSWEA_MULTIMODAL_CONTENT>")
|
| 149 |
+
TOOL_CALL_RE = re.compile(r"<tool_call>\s*<function=([^>\s]+)>(.*?)</function>\s*</tool_call>", re.S)
|
| 150 |
+
PARAM_RE = re.compile(r"<parameter=([^>\s]+)>\n?(.*?)\n?</parameter>", re.S)
|
| 151 |
+
END_TOKENS = ("<|im_end|>", "<|endoftext|>")
|
| 152 |
+
|
| 153 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 154 |
+
# Model: Video-Coder-9B (Qwen3.5-9B, vision-language) in bf16, fully on the GPU.
|
| 155 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 156 |
+
processor = AutoProcessor.from_pretrained(MODEL_ID)
|
| 157 |
+
tokenizer = processor.tokenizer
|
| 158 |
+
model = AutoModelForImageTextToText.from_pretrained(MODEL_ID, dtype=torch.bfloat16).to("cuda").eval()
|
| 159 |
+
|
| 160 |
+
GPU_CHUNK_SECONDS = 100 # one GPU call generates for at most this long; long turns continue in the next call
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@spaces.GPU(duration=120)
|
| 164 |
+
def llm_chunk(messages: list, images: list, prefix: str = "", max_new_tokens: int = 16384,
|
| 165 |
+
temperature: float = 1.0, top_p: float = 0.95):
|
| 166 |
+
"""Continue the agent's current turn for up to GPU_CHUNK_SECONDS; yields (text so far, finished, n_tokens)."""
|
| 167 |
+
prompt = tokenizer.apply_chat_template(messages, tools=TOOLS, add_generation_prompt=True, tokenize=False)
|
| 168 |
+
prompt += prefix
|
| 169 |
+
inputs = processor(text=[prompt], images=images or None, return_tensors="pt").to("cuda")
|
| 170 |
+
n_in = inputs["input_ids"].shape[1]
|
| 171 |
+
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=False)
|
| 172 |
+
result = {}
|
| 173 |
+
|
| 174 |
+
def _run():
|
| 175 |
+
try:
|
| 176 |
+
out = model.generate(**inputs, streamer=streamer, max_new_tokens=int(max_new_tokens), do_sample=True,
|
| 177 |
+
temperature=float(temperature), top_p=float(top_p), top_k=0, use_cache=True,
|
| 178 |
+
max_time=GPU_CHUNK_SECONDS)
|
| 179 |
+
result["n"] = int(out.shape[1] - n_in)
|
| 180 |
+
except Exception as e: # surfaced below
|
| 181 |
+
result["error"] = f"{type(e).__name__}: {e}"
|
| 182 |
+
streamer.end()
|
| 183 |
+
|
| 184 |
+
th = threading.Thread(target=_run, daemon=True)
|
| 185 |
+
th.start()
|
| 186 |
+
text = ""
|
| 187 |
+
last = 0.0
|
| 188 |
+
for piece in streamer:
|
| 189 |
+
text += piece
|
| 190 |
+
if time.time() - last > 0.4:
|
| 191 |
+
last = time.time()
|
| 192 |
+
yield text, False, None
|
| 193 |
+
th.join()
|
| 194 |
+
if "error" in result:
|
| 195 |
+
raise gr.Error(result["error"])
|
| 196 |
+
finished = any(t in text for t in END_TOKENS)
|
| 197 |
+
yield text, finished, result.get("n", 0)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 201 |
+
# Agent loop: mini-swe-agent DefaultAgent + video_coder_harness GuardedAgent semantics, re-implemented without the
|
| 202 |
+
# litellm/vLLM gateway (the model is called in-process).
|
| 203 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 204 |
+
def agent_env() -> dict:
|
| 205 |
+
env = dict(os.environ)
|
| 206 |
+
env.update({k: "" for k in os.environ if _SECRET_ENV.search(k)})
|
| 207 |
+
env.update({"PAGER": "cat", "MANPAGER": "cat", "PIP_PROGRESS_BAR": "off", "TQDM_DISABLE": "1"})
|
| 208 |
+
env["PATH"] = f"{HARNESS_ROOT / 'bin'}:{os.environ.get('PATH', '')}"
|
| 209 |
+
env["VCSFT_PYTHON"] = sys.executable
|
| 210 |
+
env["VCSFT_HARNESS"] = json.dumps(HARNESS)
|
| 211 |
+
return env
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def run_command(command: str, cwd: str) -> dict:
|
| 215 |
+
"""mini-swe-agent LocalEnvironment.execute."""
|
| 216 |
+
proc = subprocess.Popen(command, shell=True, text=True, cwd=cwd, env=agent_env(), encoding="utf-8",
|
| 217 |
+
errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
| 218 |
+
start_new_session=True)
|
| 219 |
+
try:
|
| 220 |
+
stdout, _ = proc.communicate(timeout=CMD_TIMEOUT)
|
| 221 |
+
return {"output": stdout, "returncode": proc.returncode, "exception_info": ""}
|
| 222 |
+
except subprocess.TimeoutExpired as e:
|
| 223 |
+
os.killpg(proc.pid, signal.SIGKILL)
|
| 224 |
+
stdout, _ = proc.communicate()
|
| 225 |
+
return {"output": stdout or "", "returncode": -1,
|
| 226 |
+
"exception_info": f"An error occurred while executing the command: {e}"}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def is_submit(output: dict) -> bool:
|
| 230 |
+
lines = output.get("output", "").lstrip().splitlines()
|
| 231 |
+
return bool(lines) and lines[0].strip() == "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" and output["returncode"] == 0
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def split_images(text: str):
|
| 235 |
+
"""video_coder_harness.models.split_images: cut frame images out of the tool output."""
|
| 236 |
+
images = []
|
| 237 |
+
|
| 238 |
+
def repl(m):
|
| 239 |
+
if m.group(1).strip() != "image_url":
|
| 240 |
+
return m.group(0)
|
| 241 |
+
before = text[: m.start()].rstrip().splitlines()
|
| 242 |
+
caption = before[-1].strip() if before else ""
|
| 243 |
+
images.append((caption, m.group(2).strip()))
|
| 244 |
+
return f"[image {len(images)} attached below]"
|
| 245 |
+
|
| 246 |
+
return MM_TAG.sub(repl, text), images
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def data_url_to_pil(url: str) -> Image.Image:
|
| 250 |
+
return Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).convert("RGB")
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def parse_response(raw: str):
|
| 254 |
+
"""Split a Qwen3.5 response into (reasoning, content, tool calls) like vLLM's qwen3 reasoning + qwen3_coder parsers."""
|
| 255 |
+
text = raw
|
| 256 |
+
for t in END_TOKENS:
|
| 257 |
+
text = text.split(t)[0]
|
| 258 |
+
reasoning = ""
|
| 259 |
+
if "</think>" in text:
|
| 260 |
+
reasoning, text = text.split("</think>", 1)
|
| 261 |
+
reasoning = reasoning.replace("<think>", "").strip()
|
| 262 |
+
calls = []
|
| 263 |
+
for m in TOOL_CALL_RE.finditer(text):
|
| 264 |
+
args = {k: v for k, v in PARAM_RE.findall(m.group(2))}
|
| 265 |
+
calls.append({"name": m.group(1).strip(), "arguments": args})
|
| 266 |
+
content = TOOL_CALL_RE.sub("", text)
|
| 267 |
+
content = content.split("<tool_call>")[0].strip()
|
| 268 |
+
return reasoning, content, calls
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def sha(path: Path) -> str | None:
|
| 272 |
+
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def short(text: str, n: int = 60) -> str:
|
| 276 |
+
lines = text.splitlines()
|
| 277 |
+
return text if len(lines) <= n else "\n".join(lines[:n]) + f"\n... ({len(lines) - n} more lines)"
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def read_script(workdir: Path) -> str:
|
| 281 |
+
p = workdir / SCRIPT
|
| 282 |
+
return p.read_text(errors="replace") if p.is_file() else ""
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def run_agent(prompt: str, max_steps: int = 12, temperature: float = 1.0, top_p: float = 0.95,
|
| 286 |
+
max_new_tokens: int = 16384):
|
| 287 |
+
"""Turn a scene description into an animated 3D "draft video": Video-Coder-9B writes a Blender script, renders it,
|
| 288 |
+
inspects the frames, fixes problems and submits.
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
prompt: description of the scene / video to build.
|
| 292 |
+
max_steps: maximum number of agent turns (one bash command each).
|
| 293 |
+
temperature: sampling temperature (the authors sample with 1.0).
|
| 294 |
+
top_p: nucleus sampling (the authors use 0.95).
|
| 295 |
+
max_new_tokens: token budget of a single agent turn.
|
| 296 |
+
|
| 297 |
+
Returns:
|
| 298 |
+
(agent trajectory, draft video, main.py, latest render preview, status)
|
| 299 |
+
"""
|
| 300 |
+
if not prompt or not prompt.strip():
|
| 301 |
+
raise gr.Error("Please enter a scene description.")
|
| 302 |
+
workdir = (RUNS_DIR / uuid.uuid4().hex).resolve()
|
| 303 |
+
workdir.mkdir(parents=True)
|
| 304 |
+
messages = [
|
| 305 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 306 |
+
{"role": "user", "content": INSTANCE_TEMPLATE.replace("__TASK__", prompt.strip())},
|
| 307 |
+
]
|
| 308 |
+
images: list = []
|
| 309 |
+
chat: list = []
|
| 310 |
+
video = None
|
| 311 |
+
preview = None
|
| 312 |
+
last_ok = None # (script hash, video path) of the last run_blender that reported RUN OK
|
| 313 |
+
last_key = None
|
| 314 |
+
repeats = 0
|
| 315 |
+
n_format_errors = 0
|
| 316 |
+
exit_status = "StepLimit"
|
| 317 |
+
t_start = time.time()
|
| 318 |
+
|
| 319 |
+
def status(msg):
|
| 320 |
+
return f"**{msg}** · {time.time() - t_start:.0f}s elapsed"
|
| 321 |
+
|
| 322 |
+
def ui(msg):
|
| 323 |
+
return chat, video, read_script(workdir), preview, status(msg)
|
| 324 |
+
|
| 325 |
+
yield ui("Starting…")
|
| 326 |
+
for step in range(1, int(max_steps) + 1):
|
| 327 |
+
# ---- model turn (possibly split over several GPU calls) ----
|
| 328 |
+
raw, used = "", 0
|
| 329 |
+
live = {"role": "assistant", "content": "", "metadata": {"title": f"💭 Step {step}: thinking…",
|
| 330 |
+
"status": "pending"}}
|
| 331 |
+
chat.append(live)
|
| 332 |
+
while True:
|
| 333 |
+
finished, n = False, None
|
| 334 |
+
base = raw
|
| 335 |
+
for text, finished, n in llm_chunk(messages, images, base, max_new_tokens - used, temperature, top_p):
|
| 336 |
+
raw = base + text
|
| 337 |
+
live["content"] = raw[-3000:].replace("<think>", "")
|
| 338 |
+
yield ui(f"Step {step}/{max_steps}: Video-Coder is writing ({len(raw)} chars)")
|
| 339 |
+
used += n or 0
|
| 340 |
+
if finished or used >= max_new_tokens:
|
| 341 |
+
break
|
| 342 |
+
chat.pop()
|
| 343 |
+
reasoning, content, calls = parse_response(raw)
|
| 344 |
+
if reasoning:
|
| 345 |
+
chat.append({"role": "assistant", "content": reasoning,
|
| 346 |
+
"metadata": {"title": f"💭 Step {step}: reasoning", "status": "done"}})
|
| 347 |
+
msg = {"role": "assistant", "content": content}
|
| 348 |
+
if reasoning:
|
| 349 |
+
msg["reasoning_content"] = reasoning
|
| 350 |
+
if calls:
|
| 351 |
+
msg["tool_calls"] = [{"type": "function", "function": {"name": c["name"], "arguments": c["arguments"]}}
|
| 352 |
+
for c in calls]
|
| 353 |
+
messages.append(msg)
|
| 354 |
+
|
| 355 |
+
# ---- format errors ----
|
| 356 |
+
error = None
|
| 357 |
+
if not calls:
|
| 358 |
+
error = "No tool calls found in the response. Every response MUST include at least one tool call."
|
| 359 |
+
elif any(c["name"] != "bash" or "command" not in c["arguments"] for c in calls):
|
| 360 |
+
error = "Unknown tool or missing 'command' argument."
|
| 361 |
+
if error:
|
| 362 |
+
n_format_errors += 1
|
| 363 |
+
finish_reason = "length" if not any(t in raw for t in END_TOKENS) else "stop"
|
| 364 |
+
err = FORMAT_ERROR_TEMPLATE.render(error=error, finish_reason=finish_reason, has_tool_calls=bool(calls))
|
| 365 |
+
if content:
|
| 366 |
+
chat.append({"role": "assistant", "content": content})
|
| 367 |
+
chat.append({"role": "user", "content": f"⚠️ {err}"})
|
| 368 |
+
messages.append({"role": "user", "content": err})
|
| 369 |
+
yield ui(f"Step {step}: format error")
|
| 370 |
+
if n_format_errors >= MAX_FORMAT_ERRORS:
|
| 371 |
+
exit_status = "FormatError"
|
| 372 |
+
break
|
| 373 |
+
continue
|
| 374 |
+
n_format_errors = 0
|
| 375 |
+
|
| 376 |
+
# ---- execute the bash command(s) ----
|
| 377 |
+
submitted = False
|
| 378 |
+
stop = False
|
| 379 |
+
for call in calls:
|
| 380 |
+
command = call["arguments"]["command"]
|
| 381 |
+
shown = f"{content}\n\n" if content else ""
|
| 382 |
+
chat.append({"role": "assistant", "content": f"{shown}```bash\n{short(command)}\n```"})
|
| 383 |
+
yield ui(f"Step {step}: running `{command.strip().splitlines()[0][:60]}`")
|
| 384 |
+
reason = harness_violation(command, HARNESS_MARKERS, str(workdir))
|
| 385 |
+
if reason:
|
| 386 |
+
out = {"output": BLOCKED.format(reason=reason), "returncode": 1, "exception_info": ""}
|
| 387 |
+
else:
|
| 388 |
+
out = run_command(command, str(workdir))
|
| 389 |
+
if is_submit(out):
|
| 390 |
+
submitted = True
|
| 391 |
+
break
|
| 392 |
+
key = (command, _normalize_output(out.get("output", "")))
|
| 393 |
+
same = (last_key is not None and key[1] == last_key[1]
|
| 394 |
+
and difflib.SequenceMatcher(None, key[0], last_key[0]).ratio() >= REPEAT_SIM)
|
| 395 |
+
repeats = repeats + 1 if same else 1
|
| 396 |
+
last_key = key
|
| 397 |
+
if repeats >= REPEAT_WARN:
|
| 398 |
+
out["output"] = (out.get("output") or "") + REPEAT_WARNING.format(n=repeats, stop=REPEAT_STOP)
|
| 399 |
+
if repeats >= REPEAT_STOP:
|
| 400 |
+
stop = True
|
| 401 |
+
|
| 402 |
+
text, imgs = split_images(out.get("output") or "")
|
| 403 |
+
obs = OBSERVATION_TEMPLATE.render(output={**out, "output": text})
|
| 404 |
+
if imgs:
|
| 405 |
+
parts = [{"type": "text", "text": obs}]
|
| 406 |
+
for k, (caption, url) in enumerate(imgs, 1):
|
| 407 |
+
parts += [{"type": "text", "text": f"image {k}: {caption}" if caption else f"image {k}:"},
|
| 408 |
+
{"type": "image"}]
|
| 409 |
+
images.append(data_url_to_pil(url))
|
| 410 |
+
messages.append({"role": "tool", "content": parts})
|
| 411 |
+
else:
|
| 412 |
+
messages.append({"role": "tool", "content": obs})
|
| 413 |
+
|
| 414 |
+
if command.strip().startswith("run_blender"):
|
| 415 |
+
vid = workdir / HARNESS["output_video"]
|
| 416 |
+
if vid.is_file():
|
| 417 |
+
keep = workdir / f".render_step{step}.mp4"
|
| 418 |
+
shutil.copy(vid, keep)
|
| 419 |
+
video = str(keep)
|
| 420 |
+
if text.startswith("RUN OK"):
|
| 421 |
+
last_ok = (sha(workdir / SCRIPT), str(keep))
|
| 422 |
+
if (workdir / "preview.png").is_file():
|
| 423 |
+
keep_png = workdir / f".preview_step{step}.png"
|
| 424 |
+
shutil.copy(workdir / "preview.png", keep_png)
|
| 425 |
+
preview = str(keep_png)
|
| 426 |
+
chat.append({"role": "assistant", "content": f"```\n{short(text.strip(), 40)}\n```",
|
| 427 |
+
"metadata": {"title": f"🛠️ Output (exit code {out['returncode']})", "status": "done"}})
|
| 428 |
+
yield ui(f"Step {step}/{max_steps} done")
|
| 429 |
+
if submitted:
|
| 430 |
+
exit_status = "Submitted"
|
| 431 |
+
break
|
| 432 |
+
if stop:
|
| 433 |
+
exit_status = "RepeatedActions"
|
| 434 |
+
break
|
| 435 |
+
|
| 436 |
+
# ---- acceptance: static check + the final script re-rendered from scratch (harness final_execution) ----
|
| 437 |
+
script = workdir / SCRIPT
|
| 438 |
+
if exit_status == "Submitted" and script.is_file():
|
| 439 |
+
chat.append({"role": "assistant", "content": "✅ Submitted. Validating the final script…"})
|
| 440 |
+
yield ui("Validating the submitted script")
|
| 441 |
+
check = check_file(script)
|
| 442 |
+
if not check["ok"]:
|
| 443 |
+
yield ui(f"Submitted, but the static check failed: {check['errors']}")
|
| 444 |
+
return
|
| 445 |
+
if last_ok and last_ok[0] == sha(script): # rendering is deterministic: reuse the identical render
|
| 446 |
+
video = last_ok[1]
|
| 447 |
+
else:
|
| 448 |
+
yield ui("Re-rendering the submitted script")
|
| 449 |
+
final_dir = workdir / ".final_run"
|
| 450 |
+
final_dir.mkdir(exist_ok=True)
|
| 451 |
+
shutil.copy(script, final_dir / SCRIPT)
|
| 452 |
+
report = blender_execute(final_dir / SCRIPT, final_dir, HARNESS)
|
| 453 |
+
if (final_dir / HARNESS["output_video"]).is_file():
|
| 454 |
+
video = str(final_dir / HARNESS["output_video"])
|
| 455 |
+
if (final_dir / "preview.png").is_file():
|
| 456 |
+
preview = str(final_dir / "preview.png")
|
| 457 |
+
if not report["ok"]:
|
| 458 |
+
yield ui(f"Submitted, but the final render has problems: {report['problems']}")
|
| 459 |
+
return
|
| 460 |
+
yield ui("Accepted ✅ — draft video rendered from the agent's Blender script")
|
| 461 |
+
else:
|
| 462 |
+
note = {"StepLimit": "Step limit reached before the agent submitted (showing the latest render)",
|
| 463 |
+
"RepeatedActions": "Stopped: the agent repeated the same command",
|
| 464 |
+
"FormatError": "Stopped after repeated format errors"}[exit_status]
|
| 465 |
+
yield ui(note)
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 469 |
+
# UI
|
| 470 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 471 |
+
CSS = """
|
| 472 |
+
#col-container { max-width: 1280px; margin: 0 auto; }
|
| 473 |
+
.dark .gradio-container { color: var(--body-text-color); }
|
| 474 |
+
"""
|
| 475 |
+
|
| 476 |
+
EXAMPLES = [
|
| 477 |
+
"a man standing in front of a machine.",
|
| 478 |
+
"The video shows a sequence of three identical white cars driving on a road. The first car is in the foreground, "
|
| 479 |
+
"the second car is slightly behind and to the right, and the third car is in the background. The cars are moving "
|
| 480 |
+
"from left to right across the frame. The road appears to be a city street with a dark grey surface. The "
|
| 481 |
+
"background features a city skyline with buildings of various heights, and the sky is a gradient of orange and "
|
| 482 |
+
"yellow, suggesting either sunrise or sunset. The style of the video is realistic with a focus on the cars and "
|
| 483 |
+
"their movement.",
|
| 484 |
+
"The video captures a lively scene on the ocean, with three boats in motion. The first boat, a large white yacht, "
|
| 485 |
+
"is seen in the distance, sailing towards the horizon. The second and third boats are closer to the viewer, both "
|
| 486 |
+
"moving at high speeds, leaving trails of white water behind them. The ocean is a deep blue, and the sky above is "
|
| 487 |
+
"clear, suggesting a sunny day. The boats are the main focus of the video, with their movement and the water's "
|
| 488 |
+
"reaction to their speed creating a dynamic and exciting scene. The overall style of the video is a dynamic "
|
| 489 |
+
"aerial shot, capturing the movement and beauty of the ocean and the boats.",
|
| 490 |
+
"A single polished walnut-wood chess pawn stands on a small square pedestal of white Carrara marble. No other "
|
| 491 |
+
"objects are in the scene. The camera makes one complete 360-degree orbit around the pawn while slowly rising.",
|
| 492 |
+
]
|
| 493 |
+
|
| 494 |
+
with gr.Blocks(title="Video-Coder") as demo:
|
| 495 |
+
with gr.Column(elem_id="col-container"):
|
| 496 |
+
gr.Markdown(
|
| 497 |
+
"# 🎬 Video-Coder: agentic coding for video generation\n"
|
| 498 |
+
"**Video-Coder-9B** writes and runs a Blender Python program for your scene description, inspects the "
|
| 499 |
+
"rendered frames, fixes what is wrong and submits a **draft video** (white-model 3D render with the "
|
| 500 |
+
"layout, motion and camera of the scene) — the structure/motion signal the paper feeds to a downstream "
|
| 501 |
+
"video generator.\n\n"
|
| 502 |
+
"[📖 Paper](https://huggingface.co/papers/2611.09894) · [🌐 Project page](https://cywang735.github.io/"
|
| 503 |
+
"Video-Coder/) · [💻 Code](https://github.com/CYWang735/Video-Coder) · [🤗 Model](https://huggingface.co/"
|
| 504 |
+
"Video-Coder/Video-Coder-9B)\n\n"
|
| 505 |
+
"⏳ A full agent run takes several minutes (each turn generates a long script; each render runs Blender "
|
| 506 |
+
"on CPU). The trajectory streams live below."
|
| 507 |
+
)
|
| 508 |
+
with gr.Row():
|
| 509 |
+
prompt = gr.Textbox(label="Scene description", lines=3, scale=4,
|
| 510 |
+
placeholder="Describe the scene and its motion, e.g. 'a red ball rolls down a ramp "
|
| 511 |
+
"and knocks over three dominoes'")
|
| 512 |
+
run_btn = gr.Button("Generate draft video", variant="primary", scale=1)
|
| 513 |
+
status = gr.Markdown()
|
| 514 |
+
with gr.Row():
|
| 515 |
+
with gr.Column(scale=1):
|
| 516 |
+
video_out = gr.Video(label="Draft video (Blender render)", autoplay=True)
|
| 517 |
+
preview_out = gr.Image(label="Latest render: frames 1, ⅓, ⅔, last", type="filepath")
|
| 518 |
+
with gr.Column(scale=1):
|
| 519 |
+
with gr.Tabs():
|
| 520 |
+
with gr.Tab("Agent trajectory"):
|
| 521 |
+
chat_out = gr.Chatbot(label="Video-Coder harness", height=640, show_label=False)
|
| 522 |
+
with gr.Tab("main.py"):
|
| 523 |
+
code_out = gr.Code(label="Blender script written by the agent", language="python",
|
| 524 |
+
lines=30)
|
| 525 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 526 |
+
max_steps = gr.Slider(2, 30, value=12, step=1, label="Max agent steps (one bash command each)")
|
| 527 |
+
temperature = gr.Slider(0.1, 1.5, value=1.0, step=0.05, label="Temperature")
|
| 528 |
+
top_p = gr.Slider(0.5, 1.0, value=0.95, step=0.01, label="Top-p")
|
| 529 |
+
max_new_tokens = gr.Slider(2048, 32768, value=16384, step=1024, label="Max tokens per agent turn")
|
| 530 |
+
|
| 531 |
+
outputs = [chat_out, video_out, code_out, preview_out, status]
|
| 532 |
+
run_btn.click(run_agent, inputs=[prompt, max_steps, temperature, top_p, max_new_tokens], outputs=outputs,
|
| 533 |
+
api_name="generate", concurrency_limit=2)
|
| 534 |
+
prompt.submit(run_agent, inputs=[prompt, max_steps, temperature, top_p, max_new_tokens], outputs=outputs,
|
| 535 |
+
api_visibility="private", concurrency_limit=2)
|
| 536 |
+
gr.Examples(examples=[[e] for e in EXAMPLES], inputs=[prompt], outputs=outputs, fn=run_agent,
|
| 537 |
+
cache_examples=False, run_on_click=True)
|
| 538 |
+
|
| 539 |
+
if __name__ == "__main__":
|
| 540 |
+
demo.queue().launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)
|
harness/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Apache License
|
| 3 |
+
Version 2.0, January 2004
|
| 4 |
+
http://www.apache.org/licenses/
|
| 5 |
+
|
| 6 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 7 |
+
|
| 8 |
+
1. Definitions.
|
| 9 |
+
|
| 10 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 11 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 12 |
+
|
| 13 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 14 |
+
the copyright owner that is granting the License.
|
| 15 |
+
|
| 16 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 17 |
+
other entities that control, are controlled by, or are under common
|
| 18 |
+
control with that entity. For the purposes of this definition,
|
| 19 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 20 |
+
direction or management of such entity, whether by contract or
|
| 21 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 22 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 23 |
+
|
| 24 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 25 |
+
exercising permissions granted by this License.
|
| 26 |
+
|
| 27 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 28 |
+
including but not limited to software source code, documentation
|
| 29 |
+
source, and configuration files.
|
| 30 |
+
|
| 31 |
+
"Object" form shall mean any form resulting from mechanical
|
| 32 |
+
transformation or translation of a Source form, including but
|
| 33 |
+
not limited to compiled object code, generated documentation,
|
| 34 |
+
and conversions to other media types.
|
| 35 |
+
|
| 36 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 37 |
+
Object form, made available under the License, as indicated by a
|
| 38 |
+
copyright notice that is included in or attached to the work
|
| 39 |
+
(an example is provided in the Appendix below).
|
| 40 |
+
|
| 41 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 42 |
+
form, that is based on (or derived from) the Work and for which the
|
| 43 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 44 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 45 |
+
of this License, Derivative Works shall not include works that remain
|
| 46 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 47 |
+
the Work and Derivative Works thereof.
|
| 48 |
+
|
| 49 |
+
"Contribution" shall mean any work of authorship, including
|
| 50 |
+
the original version of the Work and any modifications or additions
|
| 51 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 52 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 53 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 54 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 55 |
+
means any form of electronic, verbal, or written communication sent
|
| 56 |
+
to the Licensor or its representatives, including but not limited to
|
| 57 |
+
communication on electronic mailing lists, source code control systems,
|
| 58 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 59 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 60 |
+
excluding communication that is conspicuously marked or otherwise
|
| 61 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 62 |
+
|
| 63 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 64 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 65 |
+
subsequently incorporated within the Work.
|
| 66 |
+
|
| 67 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 68 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 69 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 70 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 71 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 72 |
+
Work and such Derivative Works in Source or Object form.
|
| 73 |
+
|
| 74 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 75 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 76 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 77 |
+
(except as stated in this section) patent license to make, have made,
|
| 78 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 79 |
+
where such license applies only to those patent claims licensable
|
| 80 |
+
by such Contributor that are necessarily infringed by their
|
| 81 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 82 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 83 |
+
institute patent litigation against any entity (including a
|
| 84 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 85 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 86 |
+
or contributory patent infringement, then any patent licenses
|
| 87 |
+
granted to You under this License for that Work shall terminate
|
| 88 |
+
as of the date such litigation is filed.
|
| 89 |
+
|
| 90 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 91 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 92 |
+
modifications, and in Source or Object form, provided that You
|
| 93 |
+
meet the following conditions:
|
| 94 |
+
|
| 95 |
+
(a) You must give any other recipients of the Work or
|
| 96 |
+
Derivative Works a copy of this License; and
|
| 97 |
+
|
| 98 |
+
(b) You must cause any modified files to carry prominent notices
|
| 99 |
+
stating that You changed the files; and
|
| 100 |
+
|
| 101 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 102 |
+
that You distribute, all copyright, patent, trademark, and
|
| 103 |
+
attribution notices from the Source form of the Work,
|
| 104 |
+
excluding those notices that do not pertain to any part of
|
| 105 |
+
the Derivative Works; and
|
| 106 |
+
|
| 107 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 108 |
+
distribution, then any Derivative Works that You distribute must
|
| 109 |
+
include a readable copy of the attribution notices contained
|
| 110 |
+
within such NOTICE file, excluding those notices that do not
|
| 111 |
+
pertain to any part of the Derivative Works, in at least one
|
| 112 |
+
of the following places: within a NOTICE text file distributed
|
| 113 |
+
as part of the Derivative Works; within the Source form or
|
| 114 |
+
documentation, if provided along with the Derivative Works; or,
|
| 115 |
+
within a display generated by the Derivative Works, if and
|
| 116 |
+
wherever such third-party notices normally appear. The contents
|
| 117 |
+
of the NOTICE file are for informational purposes only and
|
| 118 |
+
do not modify the License. You may add Your own attribution
|
| 119 |
+
notices within Derivative Works that You distribute, alongside
|
| 120 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 121 |
+
that such additional attribution notices cannot be construed
|
| 122 |
+
as modifying the License.
|
| 123 |
+
|
| 124 |
+
You may add Your own copyright statement to Your modifications and
|
| 125 |
+
may provide additional or different license terms and conditions
|
| 126 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 127 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 128 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 129 |
+
the conditions stated in this License.
|
| 130 |
+
|
| 131 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 132 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 133 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 134 |
+
this License, without any additional terms or conditions.
|
| 135 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 136 |
+
the terms of any separate license agreement you may have executed
|
| 137 |
+
with Licensor regarding such Contributions.
|
| 138 |
+
|
| 139 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 140 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 141 |
+
except as required for reasonable and customary use in describing the
|
| 142 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 143 |
+
|
| 144 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 145 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 146 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 147 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 148 |
+
implied, including, without limitation, any warranties or conditions
|
| 149 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 150 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 151 |
+
appropriateness of using or redistributing the Work and assume any
|
| 152 |
+
risks associated with Your exercise of permissions under this License.
|
| 153 |
+
|
| 154 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 155 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 156 |
+
unless required by applicable law (such as deliberate and grossly
|
| 157 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 158 |
+
liable to You for damages, including any direct, indirect, special,
|
| 159 |
+
incidental, or consequential damages of any character arising as a
|
| 160 |
+
result of this License or out of the use or inability to use the
|
| 161 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 162 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 163 |
+
other commercial damages or losses), even if such Contributor
|
| 164 |
+
has been advised of the possibility of such damages.
|
| 165 |
+
|
| 166 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 167 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 168 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 169 |
+
or other liability obligations and/or rights consistent with this
|
| 170 |
+
License. However, in accepting such obligations, You may act only
|
| 171 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 172 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 173 |
+
defend, and hold each Contributor harmless for any liability
|
| 174 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 175 |
+
of your accepting any such warranty or additional liability.
|
| 176 |
+
|
| 177 |
+
END OF TERMS AND CONDITIONS
|
| 178 |
+
|
| 179 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 180 |
+
|
| 181 |
+
To apply the Apache License to your work, attach the following
|
| 182 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 183 |
+
replaced with your own identifying information. (Don't include
|
| 184 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 185 |
+
comment syntax for the file format. We also recommend that a
|
| 186 |
+
file or class name and description of purpose be included on the
|
| 187 |
+
same "printed page" as the copyright notice for easier
|
| 188 |
+
identification within third-party archives.
|
| 189 |
+
|
| 190 |
+
Copyright [yyyy] [name of copyright owner]
|
| 191 |
+
|
| 192 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 193 |
+
you may not use this file except in compliance with the License.
|
| 194 |
+
You may obtain a copy of the License at
|
| 195 |
+
|
| 196 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 197 |
+
|
| 198 |
+
Unless required by applicable law or agreed to in writing, software
|
| 199 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 200 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 201 |
+
See the License for the specific language governing permissions and
|
| 202 |
+
limitations under the License.
|
harness/bin/check_script
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Agent-facing wrapper: static checks for a bpy script (default main.py in the current directory).
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
root="$(cd "$(dirname "$0")/.." && pwd)"
|
| 5 |
+
py="${VCSFT_PYTHON:-$root/.venv/bin/python}"
|
| 6 |
+
PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" exec "$py" -m video_coder_harness.static_check "$@"
|
harness/bin/run_blender
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Agent-facing wrapper: execute a bpy script with Blender 4.5 in the current directory (it renders the video here).
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
root="$(cd "$(dirname "$0")/.." && pwd)"
|
| 5 |
+
py="${VCSFT_PYTHON:-$root/.venv/bin/python}"
|
| 6 |
+
PYTHONPATH="$root${PYTHONPATH:+:$PYTHONPATH}" exec "$py" -m video_coder_harness.blender_exec "$@"
|
harness/instance_template.txt
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Task
|
| 2 |
+
|
| 3 |
+
Write a Blender 4.5 Python script that builds the following scene and renders it as an animated white-model video:
|
| 4 |
+
|
| 5 |
+
<scene_description>
|
| 6 |
+
__TASK__
|
| 7 |
+
</scene_description>
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
## Script contract
|
| 11 |
+
|
| 12 |
+
- Write exactly one self-contained script: `main.py` in the current directory.
|
| 13 |
+
- It is executed headless as `blender --background --factory-startup --python main.py` with Blender 4.5, from the directory the video must be written to. Use only the Blender 4.5 API and the Python standard library; no external assets, files or add-ons.
|
| 14 |
+
- Start by removing every object in the default scene, then build the scene from scratch.
|
| 15 |
+
- 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.
|
| 16 |
+
- Animation: the video lasts 5-10 seconds at 24 fps, i.e. 120-240 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.
|
| 17 |
+
- 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).
|
| 18 |
+
- 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.
|
| 19 |
+
- Do not import subprocess, socket, shutil, urllib or similar modules, and do not call os.system/eval/exec.
|
| 20 |
+
|
| 21 |
+
## White-model render (required, at the end of the script)
|
| 22 |
+
|
| 23 |
+
Every video in this dataset uses the same look, so configure exactly this and then render:
|
| 24 |
+
|
| 25 |
+
- `scene.render.engine = 'BLENDER_WORKBENCH'`
|
| 26 |
+
- `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.
|
| 27 |
+
- Resolution 1280x720 at `resolution_percentage = 100`, `scene.render.fps = 24`.
|
| 28 |
+
- 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.mp4')`.
|
| 29 |
+
- Last statement: `bpy.ops.render.render(animation=True)`.
|
| 30 |
+
- Write no other files: no .blend files, images or caches outside the render. Do not quit Blender.
|
| 31 |
+
|
| 32 |
+
## Tools
|
| 33 |
+
|
| 34 |
+
Blender 4.5 is available through two commands (do not install anything):
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
check_script main.py # fast static check: syntax, bpy import, camera, render call, forbidden calls
|
| 38 |
+
run_blender main.py # executes the script exactly like the harness and renders output.mp4 (CPU, ~20-120 s)
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
`run_blender` reports the Python traceback if the script fails, whether output.mp4 has 120-240 frames matching the scene frame range at 1280x720, 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. It also returns 8 frames sampled evenly from the rendered video as images, with their frame numbers and times. It reports a problem when most frames are empty, nothing moves, or no object is in view.
|
| 42 |
+
|
| 43 |
+
## Workflow
|
| 44 |
+
|
| 45 |
+
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.
|
| 46 |
+
2. Write `main.py` with a heredoc: `cat <<'EOF' > main.py` ... `EOF`.
|
| 47 |
+
3. Run `check_script main.py` and fix every error.
|
| 48 |
+
4. Run `run_blender main.py`. Fix every error and problem it reports, and compare the scene summary against the description (positions, timing, clearances, camera framing).
|
| 49 |
+
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.
|
| 50 |
+
Repeat until it reports RUN OK and the frames clearly show every element of the description. If a command keeps giving the same result, change the script or your approach instead of repeating it; repeated identical commands end the task.
|
| 51 |
+
5. Submit with `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as a command on its own: the whole command is exactly that `echo`, never chained to another command with `&&`, `;` or `|`, and never in the same command as `check_script` or `run_blender`. You cannot continue after that. The harness then re-executes `main.py` from scratch; the task only counts if it renders a valid video.
|
| 52 |
+
|
| 53 |
+
## Command rules
|
| 54 |
+
|
| 55 |
+
- Make exactly ONE tool call per response: a single `bash` call with a single command (or one short chain that serves one purpose). Never put several tool calls in the same response; read the result of each command before deciding the next one. Every response must contain that tool call: never answer with text only, not even when you are finished (finishing means calling the submit command).
|
| 56 |
+
- Every tool call must be preceded by written reasoning in plain text in the same response: at least one sentence on what the previous result showed and what this command is for. This applies to every command without exception, including `check_script`, `run_blender` and the submit command; a response that contains only a tool call and no text is not allowed.
|
| 57 |
+
- Submit by itself: the command `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` must be the only command of its response. Do not append it to `check_script`, `run_blender` or any other command, and do not write that word anywhere else, not even in a comment. Run the final `run_blender` first, read its result, and only then send the submit command in a separate response.
|
| 58 |
+
- Each command runs in a fresh subshell in the task directory; `cd` and environment variables do not persist, so do not use `cd`.
|
| 59 |
+
- Only create or modify files in the current directory.
|
| 60 |
+
- Do not explore or probe the environment. Everything you need is described above. Do not run `pwd`, `which`, `whereis`, `type`, `env`, `printenv`, `echo $PATH`, `ls` of any other directory, `--help` or `--version` of any command, `pip list`, or `import` checks for Python packages, and do not look for Blender, ffmpeg, PIL or numpy. Your script may use only `bpy` and the Python standard library. Your first command is the one that writes `main.py`.
|
| 61 |
+
- Never print absolute paths of the machine, environment variables or the contents of `PATH`. If a command could print them, do not run it.
|
| 62 |
+
- 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.
|
| 63 |
+
- Fix problems directly in `main.py`: change only the lines that are wrong (`sed -i`, or a short `python3` edit) and rerun `run_blender main.py`. Do not rewrite the whole file for a small fix.
|
| 64 |
+
- Throwaway experiment scripts are a last resort, for when the `run_blender` report is not enough to understand a problem: write a small script in the current directory and run `run_blender experiment.py` (whatever it `print()`s appears in the blender log). Use at most two experiments, and delete the experiment files before submitting.
|
harness/video_coder_harness/__init__.py
ADDED
|
File without changes
|
harness/video_coder_harness/blender_exec.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Execute a generated script with bpy (Blender 4.5, Workbench via Mesa llvmpipe on CPU) and check the video it renders.
|
| 2 |
+
|
| 3 |
+
Shared by the agent's `run_blender` tool and by the harness's final re-execution, so both judge a script the same way.
|
| 4 |
+
Harness settings come from the config (`harness:`) or, inside agent shells, from the VCSFT_HARNESS env var (JSON).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import argparse
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
import subprocess
|
| 12 |
+
import sys
|
| 13 |
+
import tempfile
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from video_coder_harness.video import contact_sheet, frame_stats, probe, sample_frames
|
| 18 |
+
|
| 19 |
+
RUN_BPY = Path(__file__).resolve().parent / "run_bpy.py"
|
| 20 |
+
_SECRET_ENV = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
|
| 21 |
+
# Blender's per-frame render progress; dropped from the log shown to the agent.
|
| 22 |
+
_NOISE = re.compile(r"^(Fra:|Append frame|Time: |\s*Saving:|Saved: |Read prefs|Blender quit|$)")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def bpy_env(harness: dict) -> dict:
|
| 26 |
+
libs = os.path.expandvars(harness["bpy_libs"])
|
| 27 |
+
env = {k: v for k, v in os.environ.items() if not _SECRET_ENV.search(k)}
|
| 28 |
+
env |= {
|
| 29 |
+
"LD_LIBRARY_PATH": f"{libs}/lib",
|
| 30 |
+
"__EGL_VENDOR_LIBRARY_DIRS": f"{libs}/share/glvnd/egl_vendor.d",
|
| 31 |
+
"EGL_PLATFORM": "surfaceless",
|
| 32 |
+
"LIBGL_ALWAYS_SOFTWARE": "1",
|
| 33 |
+
"GALLIUM_DRIVER": "llvmpipe",
|
| 34 |
+
"PYTHONNOUSERSITE": "1",
|
| 35 |
+
}
|
| 36 |
+
env.pop("PYTHONPATH", None)
|
| 37 |
+
return env
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def execute(script: str | Path, cwd: str | Path, harness: dict, preview: bool = True) -> dict:
|
| 41 |
+
"""Run `script` in `cwd`; it is expected to render harness['output_video'] there."""
|
| 42 |
+
cwd, script = Path(cwd).resolve(), Path(script).resolve()
|
| 43 |
+
video_path = cwd / harness["output_video"]
|
| 44 |
+
if video_path.exists(): # never judge a stale video from an earlier run
|
| 45 |
+
video_path.unlink()
|
| 46 |
+
timeout = harness["exec_timeout"]
|
| 47 |
+
report: dict = {"script": script.name}
|
| 48 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 49 |
+
result_file = Path(tmp) / "result.json"
|
| 50 |
+
cmd = [os.path.expandvars(harness["bpy_python"]), str(RUN_BPY), str(script),
|
| 51 |
+
"--cwd", str(cwd), "--result", str(result_file), "--samples", str(harness.get("summary_samples", 6))]
|
| 52 |
+
t0 = time.time()
|
| 53 |
+
try:
|
| 54 |
+
proc = subprocess.run(cmd, cwd=cwd, env=bpy_env(harness), capture_output=True, text=True,
|
| 55 |
+
errors="replace", timeout=timeout)
|
| 56 |
+
log = proc.stdout + proc.stderr
|
| 57 |
+
run = json.loads(result_file.read_text()) if result_file.is_file() else {}
|
| 58 |
+
if not run:
|
| 59 |
+
report["error"] = crash_message(proc.returncode)
|
| 60 |
+
except subprocess.TimeoutExpired as e:
|
| 61 |
+
log = (e.stdout or "") if isinstance(e.stdout, str) else (e.stdout or b"").decode(errors="replace")
|
| 62 |
+
run, report["error"] = {}, f"timed out after {timeout}s (render included)"
|
| 63 |
+
report["seconds"] = round(time.time() - t0, 1)
|
| 64 |
+
|
| 65 |
+
report["executed"] = bool(run.get("ok"))
|
| 66 |
+
if run.get("traceback"):
|
| 67 |
+
# relative paths: workspace locations must not end up in training data
|
| 68 |
+
report["traceback"] = _script_frames(run["traceback"], script.name).replace(f"{script.parent}/", "")
|
| 69 |
+
report["log_tail"] = [ln for ln in log.splitlines() if not _NOISE.match(ln)][-25:]
|
| 70 |
+
report["files_written"] = run.get("files_written", [])
|
| 71 |
+
if "scene" in run:
|
| 72 |
+
report["scene"] = _compact_scene(run["scene"])
|
| 73 |
+
|
| 74 |
+
video = probe(video_path)
|
| 75 |
+
report["video"] = video
|
| 76 |
+
want_w, want_h = harness["resolution"]
|
| 77 |
+
problems = []
|
| 78 |
+
if not report["executed"]:
|
| 79 |
+
problems.append("script did not execute successfully")
|
| 80 |
+
if not video.get("exists"):
|
| 81 |
+
problems.append(f"{harness['output_video']} was not rendered")
|
| 82 |
+
else:
|
| 83 |
+
if video.get("decode_error"):
|
| 84 |
+
problems.append(f"video cannot be decoded: {video['decode_error']}")
|
| 85 |
+
lo = harness["frame_count"]
|
| 86 |
+
hi = harness.get("max_frame_count", lo)
|
| 87 |
+
n = video.get("n_frames")
|
| 88 |
+
if not (n is not None and lo <= n <= hi):
|
| 89 |
+
want = f"{lo}" if lo == hi else f"{lo}-{hi}"
|
| 90 |
+
problems.append(f"video has {n} frames, expected {want}")
|
| 91 |
+
frame_range = (report.get("scene") or {}).get("frame_range")
|
| 92 |
+
if lo != hi and frame_range and n != frame_range[1] - frame_range[0] + 1:
|
| 93 |
+
problems.append(f"video has {n} frames but the scene frame range is {frame_range}")
|
| 94 |
+
if video.get("fps") and abs(video["fps"] - harness["fps"]) > 0.01:
|
| 95 |
+
problems.append(f"video is {video['fps']:g} fps, expected {harness['fps']} (set scene.render.fps)")
|
| 96 |
+
if (video.get("width"), video.get("height")) != (want_w, want_h):
|
| 97 |
+
problems.append(f"video is {video.get('width')}x{video.get('height')}, expected {want_w}x{want_h}")
|
| 98 |
+
if n and not video.get("decode_error"):
|
| 99 |
+
stats = frame_stats(video_path, n, samples=harness.get("content_samples", 8))
|
| 100 |
+
report["frame_stats"] = stats
|
| 101 |
+
problems += content_problems(stats, run.get("scene"))
|
| 102 |
+
extra = [f for f in report["files_written"] if f != harness["output_video"]]
|
| 103 |
+
if extra:
|
| 104 |
+
problems.append(f"script wrote files other than {harness['output_video']}: {extra}")
|
| 105 |
+
report["problems"] = problems
|
| 106 |
+
report["ok"] = not problems
|
| 107 |
+
if preview and video.get("n_frames"):
|
| 108 |
+
n = video["n_frames"]
|
| 109 |
+
frames = sorted({0, n // 3, 2 * n // 3, n - 1})
|
| 110 |
+
if contact_sheet(video_path, cwd / "preview.png", frames):
|
| 111 |
+
report["preview"] = {"file": "preview.png", "frames": [f + 1 for f in frames]}
|
| 112 |
+
return report
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
_SIGNALS = {-6: "SIGABRT", -11: "SIGSEGV", -9: "SIGKILL", -7: "SIGBUS", -8: "SIGFPE"}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def crash_message(returncode: int) -> str:
|
| 119 |
+
if returncode < 0:
|
| 120 |
+
sig = _SIGNALS.get(returncode, f"signal {-returncode}")
|
| 121 |
+
return (f"Blender crashed ({sig}) before the script finished, so there is no Python traceback; the blender "
|
| 122 |
+
"log tail may show the cause. Usual causes: invalid mesh data (bmesh/mesh elements used after "
|
| 123 |
+
"they were removed, or a stale bmesh index table: call ensure_lookup_table() after adding "
|
| 124 |
+
"elements), or using an object or datablock after deleting it. Bisect by commenting out parts of "
|
| 125 |
+
"the script.")
|
| 126 |
+
return f"Blender process exited with code {returncode} without a result"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# A Workbench frame showing only background has a pixel std of ~0.6 (64x36 gray); scenes with geometry are > 3.
|
| 130 |
+
BLANK_STD = 2.0
|
| 131 |
+
STATIC_DIFF = 0.5
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def content_problems(stats: dict, scene: dict | None) -> list[str]:
|
| 135 |
+
"""Hard checks on what the camera actually sees: mostly empty frames, no visible motion, nothing in view."""
|
| 136 |
+
problems = []
|
| 137 |
+
std, diff, frames = stats["std"], stats["diff"], stats["frames"]
|
| 138 |
+
blank = [f for f, s in zip(frames, std) if s < BLANK_STD]
|
| 139 |
+
if std and len(blank) > len(std) // 2:
|
| 140 |
+
problems.append(f"the rendered frames are empty (only background) at sampled frames {blank}: "
|
| 141 |
+
"the camera does not see the scene")
|
| 142 |
+
if diff and max(diff) < STATIC_DIFF:
|
| 143 |
+
problems.append("the video is static: nothing visibly moves between the sampled frames")
|
| 144 |
+
if scene:
|
| 145 |
+
n_mesh = (scene.get("objects_by_type") or {}).get("MESH", 0)
|
| 146 |
+
outside = scene.get("mesh_origins_out_of_camera_view") or {}
|
| 147 |
+
n_sampled = len(scene.get("sampled_frames") or [])
|
| 148 |
+
if n_mesh and len(outside) >= n_mesh and all(len(v) == n_sampled for v in outside.values()):
|
| 149 |
+
problems.append("no mesh is inside the camera view in any sampled frame: aim the camera at the scene")
|
| 150 |
+
return problems
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
HARNESS_DIR = str(Path(__file__).resolve().parent)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _script_frames(tb: str, script_name: str) -> str:
|
| 157 |
+
"""Keep the traceback from the first frame inside the generated script; drop runner/runpy frames and frames
|
| 158 |
+
of the harness's own guards (video_coder_harness/), whose paths must not leak into trajectories."""
|
| 159 |
+
lines = tb.splitlines()
|
| 160 |
+
start = next((i for i, ln in enumerate(lines) if script_name in ln and ln.lstrip().startswith("File")), None)
|
| 161 |
+
if start is None:
|
| 162 |
+
return tb
|
| 163 |
+
kept, skipping = [], False
|
| 164 |
+
for ln in lines[start:]:
|
| 165 |
+
if ln.lstrip().startswith("File "):
|
| 166 |
+
skipping = HARNESS_DIR in ln
|
| 167 |
+
elif not ln.startswith(" "): # the final "ExcType: message" line
|
| 168 |
+
skipping = False
|
| 169 |
+
if not skipping:
|
| 170 |
+
kept.append(ln)
|
| 171 |
+
return "Traceback (most recent call last):\n" + "\n".join(kept)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _compact_scene(scene: dict) -> dict:
|
| 175 |
+
moving = scene.get("moving_mesh_positions", {})
|
| 176 |
+
return {
|
| 177 |
+
"frame_range": scene.get("frame_range"),
|
| 178 |
+
"fps": scene.get("fps"),
|
| 179 |
+
"camera": scene.get("camera"),
|
| 180 |
+
"objects_by_type": scene.get("objects_by_type"),
|
| 181 |
+
"n_faces_evaluated": scene.get("n_faces_evaluated"),
|
| 182 |
+
"animated_objects": scene.get("animated_objects"),
|
| 183 |
+
"sampled_frames": scene.get("sampled_frames"),
|
| 184 |
+
"moving_mesh_world_positions (frame, x, y, z)": {k: moving[k] for k in list(moving)[:8]},
|
| 185 |
+
"n_moving_meshes": len(moving),
|
| 186 |
+
"mesh_origins_outside_camera_view": {k: f"{len(v)} of {len(scene.get('sampled_frames', []))} sampled frames"
|
| 187 |
+
for k, v in scene.get("mesh_origins_out_of_camera_view", {}).items()},
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def format_report(r: dict) -> str:
|
| 192 |
+
head = "RUN OK: script executed and rendered a valid video" if r["ok"] else "RUN FAILED"
|
| 193 |
+
out = [head, f"seconds: {r['seconds']}"]
|
| 194 |
+
if r.get("error"):
|
| 195 |
+
out.append(f"error: {r['error']}")
|
| 196 |
+
for p in r["problems"]:
|
| 197 |
+
out.append(f"problem: {p}")
|
| 198 |
+
if r.get("traceback"):
|
| 199 |
+
out += ["", r["traceback"].rstrip()]
|
| 200 |
+
if r.get("log_tail"):
|
| 201 |
+
out += ["", "blender log (tail):", *r["log_tail"]]
|
| 202 |
+
v = r["video"]
|
| 203 |
+
if v.get("exists"):
|
| 204 |
+
out += ["", "video: " + json.dumps({k: v[k] for k in ("n_frames", "width", "height", "fps", "codec", "bytes") if k in v})]
|
| 205 |
+
if r.get("scene"):
|
| 206 |
+
out += ["", "scene summary:"]
|
| 207 |
+
for key, value in r["scene"].items():
|
| 208 |
+
if isinstance(value, dict) and value and key != "objects_by_type":
|
| 209 |
+
out.append(f" {key}:")
|
| 210 |
+
out += [f" {name}: {json.dumps(v)}" for name, v in value.items()]
|
| 211 |
+
else:
|
| 212 |
+
out.append(f" {key}: {json.dumps(value)}")
|
| 213 |
+
return "\n".join(out)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def harness_from_env() -> dict:
|
| 217 |
+
raw = os.environ.get("VCSFT_HARNESS")
|
| 218 |
+
if not raw:
|
| 219 |
+
sys.exit("VCSFT_HARNESS is not set (run_blender is meant to be called inside a generation workspace)")
|
| 220 |
+
return json.loads(raw)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def main() -> None:
|
| 224 |
+
p = argparse.ArgumentParser(description="Execute a bpy script with Blender 4.5 and check the rendered video.")
|
| 225 |
+
p.add_argument("script", nargs="?", default="main.py")
|
| 226 |
+
p.add_argument("--config", help="read harness settings from this config instead of $VCSFT_HARNESS")
|
| 227 |
+
p.add_argument("--json", action="store_true")
|
| 228 |
+
args = p.parse_args()
|
| 229 |
+
if args.config:
|
| 230 |
+
import yaml
|
| 231 |
+
|
| 232 |
+
harness = yaml.safe_load(Path(args.config).read_text())["harness"]
|
| 233 |
+
else:
|
| 234 |
+
harness = harness_from_env()
|
| 235 |
+
if not Path(args.script).is_file():
|
| 236 |
+
sys.exit(f"{args.script} not found")
|
| 237 |
+
r = execute(args.script, Path.cwd(), harness)
|
| 238 |
+
text = json.dumps(r, indent=2) if args.json else format_report(r)
|
| 239 |
+
if harness.get("visual_feedback") and r["video"].get("n_frames") and not args.json:
|
| 240 |
+
text += "\n\n" + frames_attachment(Path.cwd() / harness["output_video"], r["video"]["n_frames"],
|
| 241 |
+
r["video"].get("fps") or harness["fps"], harness)
|
| 242 |
+
print(text)
|
| 243 |
+
sys.exit(0 if r["ok"] else 1)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def frames_attachment(video: Path, n_frames: int, fps: float, harness: dict) -> str:
|
| 247 |
+
"""Evenly spaced frames of the render as inline images (mini-swe-agent multimodal tags), one per frame.
|
| 248 |
+
|
| 249 |
+
video_coder_harness.models turns each tag into a real image in the tool result, preceded by its caption line.
|
| 250 |
+
"""
|
| 251 |
+
frames = sample_frames(video, n_frames, harness.get("preview_frames", 8), harness.get("preview_width", 640))
|
| 252 |
+
lines = [f"rendered frames ({len(frames)} evenly spaced, in order):"]
|
| 253 |
+
for frame, url in frames:
|
| 254 |
+
lines.append(f"Frame {frame} (t = {(frame - 1) / fps:.2f} s):")
|
| 255 |
+
lines.append(f"<MSWEA_MULTIMODAL_CONTENT><CONTENT_TYPE>image_url</CONTENT_TYPE>{url}</MSWEA_MULTIMODAL_CONTENT>")
|
| 256 |
+
return "\n".join(lines)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
if __name__ == "__main__":
|
| 260 |
+
main()
|
harness/video_coder_harness/guard.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""(Ported from video_coder_harness/agent.py; the GuardedAgent loop itself lives in app.py.) Two guards for clean trajectories.
|
| 2 |
+
|
| 3 |
+
- Repetition: consecutive near-identical commands with the same result first get a warning appended to their
|
| 4 |
+
output, and the run stops (exit status RepeatedActions) if the agent keeps repeating.
|
| 5 |
+
- Harness boundary: commands that inspect the harness (VCSFT_* variables, the implementation of check_script /
|
| 6 |
+
run_blender, the bpy installation) or write outside the task directory are not executed; the agent gets a
|
| 7 |
+
BLOCKED message instead. Blocked commands are counted so such trajectories can be left out of SFT data.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import difflib
|
| 11 |
+
import re
|
| 12 |
+
import shlex
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
TOOLS = ("run_blender", "check_script")
|
| 16 |
+
# Shell-level rules: applied to the command with here-document bodies removed, so the Python written into main.py
|
| 17 |
+
# (comments like "frame -> /tmp", "x > 0") is not mistaken for shell redirections or commands.
|
| 18 |
+
_INSPECT = re.compile(rf"^\s*(?:sudo\s+)?(?:type|which|command\s+-v|cat|less|more|head|tail|sed|awk|file|readlink|"
|
| 19 |
+
rf"realpath|ls|stat|strings|od|xxd)\b[^;&|]*\b({'|'.join(TOOLS)})\b")
|
| 20 |
+
_ENV_CMD = re.compile(r"(?m)(?:^|[;&|(]|\$\()\s*(?:env|printenv|set|export\s+-p|declare\s+-[px])\s*(?:$|[|;&>)])")
|
| 21 |
+
# writes to an absolute path (redirection, tee, cp/mv/ln target, mkdir/touch), except /dev/null and /dev/std*
|
| 22 |
+
_ABS_WRITE = re.compile(r"(?:(?<![0-9&<])>>?|\btee(?:\s+-a)?|\b(?:mkdir|touch)(?:\s+-p)?)\s*['\"]?"
|
| 23 |
+
r"(/(?!dev/(?:null|stdout|stderr)\b)[^\s'\";|&]*)")
|
| 24 |
+
_ABS_TARGET = re.compile(r"\b(?:cp|mv|ln|install|rsync)\b[^\n;|&]*\s['\"]?(/(?!dev/null\b)[^\s'\";|&]+)['\"]?\s*(?:$|[;|&\n])")
|
| 25 |
+
# Whole-text rules (here-document bodies included, so `python3 - <<EOF ... open(...)` is covered too):
|
| 26 |
+
# harness variables and locations that hold credentials or lie outside the task directory.
|
| 27 |
+
_HARNESS_VAR = re.compile(r"\bVCSFT_")
|
| 28 |
+
_SENSITIVE = re.compile(r"(~/|\$\{?HOME\b|/home/|/root/|\.ssh/|\.netrc\b|\.config/|\.env\b|\bOPENAI_API_KEY\b|"
|
| 29 |
+
r"\b[A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD)\b|\bsk-[A-Za-z0-9_-]{8,})")
|
| 30 |
+
_HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def strip_heredocs(command: str) -> str:
|
| 34 |
+
"""The shell text of a command without here-document bodies (the delimiter lines are dropped too)."""
|
| 35 |
+
lines, out, i = command.split("\n"), [], 0
|
| 36 |
+
while i < len(lines):
|
| 37 |
+
line = lines[i]
|
| 38 |
+
out.append(line)
|
| 39 |
+
i += 1
|
| 40 |
+
for m in _HEREDOC.finditer(line):
|
| 41 |
+
while i < len(lines) and lines[i].strip() != m.group(2):
|
| 42 |
+
i += 1
|
| 43 |
+
i += 1 # the closing delimiter
|
| 44 |
+
return "\n".join(out)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Python code (also inside here-documents) that writes to an absolute path.
|
| 48 |
+
_PY_ABS_WRITE = [
|
| 49 |
+
re.compile(r"""\bopen\(\s*[rbuf]*(['"])(/[^'"]*)\1\s*,\s*[rbuf]*(['"])[^'"]*[wax+][^'"]*\3"""),
|
| 50 |
+
re.compile(r"""\bPath\(\s*(['"])(/[^'"]*)\1\s*\)\s*\.\s*(?:write_text|write_bytes|mkdir|touch|unlink|rename|replace)\b"""),
|
| 51 |
+
re.compile(r"""\bos\.(?:makedirs|mkdir|rename|replace|remove|unlink|rmdir)\(\s*(['"])(/[^'"]*)\1"""),
|
| 52 |
+
re.compile(r"""\bfilepath\s*=\s*[rbuf]*(['"])(/[^'"]*)\1"""),
|
| 53 |
+
]
|
| 54 |
+
_PARENT_DIR = re.compile(r"(?<![\w.])\.\./")
|
| 55 |
+
_SEPARATORS = set("();<>|&")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def in_dir(path: str, directory: str) -> bool:
|
| 59 |
+
"""True if `path` is `directory` itself or lies below it ('/w-other' is not inside '/w')."""
|
| 60 |
+
d = directory.rstrip("/")
|
| 61 |
+
return bool(d) and (path == d or path.startswith(d + "/"))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _mask_workdir(text: str, workdir: str) -> str:
|
| 65 |
+
"""Replace the task directory by '.' in `text`, but not a longer sibling name such as '<workdir>-other'."""
|
| 66 |
+
if not workdir:
|
| 67 |
+
return text
|
| 68 |
+
return re.sub(re.escape(workdir.rstrip("/")) + r"(?![A-Za-z0-9_.-])", ".", text)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _is_separator(token: str | None) -> bool:
|
| 72 |
+
return bool(token) and all(c in _SEPARATORS for c in token)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _shell_path_violation(shell: str, workdir: str) -> str:
|
| 76 |
+
"""Rules on shell words: `..` path components and `cd` to somewhere outside the task directory."""
|
| 77 |
+
text = shell.replace("\\\n", " ").replace("\n", " ; ")
|
| 78 |
+
try:
|
| 79 |
+
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
|
| 80 |
+
lex.whitespace_split = True
|
| 81 |
+
tokens = list(lex)
|
| 82 |
+
except ValueError: # unbalanced quotes etc.: the regex rules still apply
|
| 83 |
+
return ""
|
| 84 |
+
for i, tok in enumerate(tokens):
|
| 85 |
+
if tok == ".." or tok.startswith("../") or "/../" in tok or tok.endswith("/.."):
|
| 86 |
+
return "it reaches outside the task directory with '..'"
|
| 87 |
+
if tok in ("cd", "pushd") and (i == 0 or _is_separator(tokens[i - 1])):
|
| 88 |
+
nxt = tokens[i + 1] if i + 1 < len(tokens) else ""
|
| 89 |
+
target = "" if _is_separator(nxt) else nxt
|
| 90 |
+
if not target or target.startswith(("~", "$", "-")):
|
| 91 |
+
return f"`{tok}` without a path inside the task directory leaves it"
|
| 92 |
+
if target.startswith("/") and not target.startswith("/dev/") and not in_dir(target, workdir):
|
| 93 |
+
return f"it changes into a directory outside the task directory ({target})"
|
| 94 |
+
return ""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def harness_violation(command: str, harness_markers: list[str], workdir: str = "") -> str:
|
| 98 |
+
"""Reason why a command crosses the harness boundary, or "" if it is fine."""
|
| 99 |
+
for marker in harness_markers:
|
| 100 |
+
if marker and marker in command:
|
| 101 |
+
return f"it references the harness installation ({marker})"
|
| 102 |
+
if _HARNESS_VAR.search(command):
|
| 103 |
+
return "it reads the harness environment variables"
|
| 104 |
+
m = _SENSITIVE.search(_mask_workdir(command, workdir)) # the task directory itself is fine
|
| 105 |
+
if m:
|
| 106 |
+
return f"it references files or variables outside the task directory ({m.group(1)})"
|
| 107 |
+
if _PARENT_DIR.search(command):
|
| 108 |
+
return "it reaches outside the task directory with '../'"
|
| 109 |
+
for pattern in _PY_ABS_WRITE: # whole text: covers `python3 -c` and here-documents
|
| 110 |
+
for m in pattern.finditer(command):
|
| 111 |
+
path = m.group(2)
|
| 112 |
+
if not (path.startswith("/dev/") or in_dir(path, workdir)):
|
| 113 |
+
return f"it writes outside the task directory ({path})"
|
| 114 |
+
shell = strip_heredocs(command)
|
| 115 |
+
for line in shell.splitlines():
|
| 116 |
+
if _INSPECT.search(line):
|
| 117 |
+
return f"it inspects how {', '.join(TOOLS)} are implemented"
|
| 118 |
+
if _ENV_CMD.search(shell):
|
| 119 |
+
return "it reads the environment variables"
|
| 120 |
+
for pattern in (_ABS_WRITE, _ABS_TARGET):
|
| 121 |
+
for m in pattern.finditer(shell):
|
| 122 |
+
if not in_dir(m.group(1), workdir):
|
| 123 |
+
return f"it writes outside the task directory ({m.group(1)})"
|
| 124 |
+
return _shell_path_violation(shell, workdir)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
BLOCKED = ("BLOCKED: this command was not executed because {reason}. Work only in the current directory and use "
|
| 128 |
+
"only check_script and run_blender; do not inspect or modify the harness or run Blender any other way. "
|
| 129 |
+
"For a quick experiment, write a small script in the current directory and run `run_blender "
|
| 130 |
+
"experiment.py`; its print() output appears in the blender log.")
|
| 131 |
+
REPEAT_WARNING = ("\n\nWARNING: this is the {n}th consecutive near-identical command with the same result. Repeating "
|
| 132 |
+
"it will not help: read the output, then change the script or your approach. The task ends after "
|
| 133 |
+
"{stop} repetitions.")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _normalize_output(text: str) -> str:
|
| 137 |
+
return re.sub(r"\d+(?:\.\d+)?", "#", text or "")
|
harness/video_coder_harness/run_bpy.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Execute a generated script with the `bpy` module and print a JSON scene summary.
|
| 2 |
+
|
| 3 |
+
Run with the bpy environment (Python 3.11 + bpy 4.5), not the project venv:
|
| 4 |
+
LD_LIBRARY_PATH=$WORK/envs/bpy45-libs/lib $WORK/envs/bpy45/bin/python video_coder_harness/run_bpy.py main.py
|
| 5 |
+
The script runs in --cwd (default: a fresh temporary directory); exit code 0 = executed without exception.
|
| 6 |
+
Normally called through video_coder_harness.blender_exec, which sets up the environment, timeout and video checks.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import runpy
|
| 13 |
+
import sys
|
| 14 |
+
import tempfile
|
| 15 |
+
import time
|
| 16 |
+
import traceback
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def summarize(bpy, samples: int) -> dict:
|
| 21 |
+
from bpy_extras.object_utils import world_to_camera_view
|
| 22 |
+
|
| 23 |
+
scene = bpy.context.scene
|
| 24 |
+
objects = list(scene.objects)
|
| 25 |
+
by_type: dict[str, int] = {}
|
| 26 |
+
for o in objects:
|
| 27 |
+
by_type[o.type] = by_type.get(o.type, 0) + 1
|
| 28 |
+
animated = sorted(o.name for o in objects if o.animation_data and o.animation_data.action)
|
| 29 |
+
start, end = scene.frame_start, scene.frame_end
|
| 30 |
+
frames = sorted({start, end, *[round(start + i * (end - start) / (samples - 1)) for i in range(samples)]})
|
| 31 |
+
|
| 32 |
+
meshes = [o for o in objects if o.type == "MESH"]
|
| 33 |
+
positions: dict[str, list] = {}
|
| 34 |
+
out_of_view: dict[str, list[int]] = {}
|
| 35 |
+
n_faces = 0
|
| 36 |
+
depsgraph = bpy.context.evaluated_depsgraph_get()
|
| 37 |
+
for f in frames:
|
| 38 |
+
scene.frame_set(f)
|
| 39 |
+
for o in meshes:
|
| 40 |
+
loc = o.matrix_world.translation
|
| 41 |
+
if o.name in animated or (o.parent and o.parent.name in animated):
|
| 42 |
+
positions.setdefault(o.name, []).append([f, *[round(v, 3) for v in loc]])
|
| 43 |
+
if scene.camera:
|
| 44 |
+
p = world_to_camera_view(scene, scene.camera, loc)
|
| 45 |
+
if not (0 <= p.x <= 1 and 0 <= p.y <= 1 and p.z > 0):
|
| 46 |
+
out_of_view.setdefault(o.name, []).append(f)
|
| 47 |
+
for o in meshes:
|
| 48 |
+
n_faces += len(o.evaluated_get(depsgraph).data.polygons)
|
| 49 |
+
return {
|
| 50 |
+
"blender": bpy.app.version_string,
|
| 51 |
+
"frame_range": [start, end],
|
| 52 |
+
"fps": scene.render.fps,
|
| 53 |
+
"camera": scene.camera.name if scene.camera else None,
|
| 54 |
+
"n_objects": len(objects),
|
| 55 |
+
"objects_by_type": by_type,
|
| 56 |
+
"n_faces_evaluated": n_faces,
|
| 57 |
+
"animated_objects": animated,
|
| 58 |
+
"sampled_frames": frames,
|
| 59 |
+
"moving_mesh_positions": positions,
|
| 60 |
+
"mesh_origins_out_of_camera_view": out_of_view,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def install_guards(bpy) -> None:
|
| 65 |
+
"""Turn the most common hard crash into a Python error with a line number.
|
| 66 |
+
|
| 67 |
+
Mesh.from_pydata does not check indices: an edge or face that references a vertex that does not exist
|
| 68 |
+
corrupts Blender's memory and kills the process (SIGABRT/SIGSEGV) with no traceback.
|
| 69 |
+
"""
|
| 70 |
+
original = bpy.types.Mesh.from_pydata
|
| 71 |
+
|
| 72 |
+
def checked_from_pydata(self, vertices, edges, faces, *args, **kwargs):
|
| 73 |
+
n = len(vertices)
|
| 74 |
+
for kind, items, min_len in (("edge", edges, 2), ("face", faces, 3)):
|
| 75 |
+
for i, item in enumerate(items):
|
| 76 |
+
idx = list(item)
|
| 77 |
+
if len(idx) < min_len:
|
| 78 |
+
raise ValueError(f"from_pydata: {kind} {i} has {len(idx)} vertices, needs at least {min_len}")
|
| 79 |
+
bad = [v for v in idx if not (isinstance(v, int) and 0 <= v < n)]
|
| 80 |
+
if bad:
|
| 81 |
+
raise ValueError(f"from_pydata: {kind} {i} uses vertex index {bad[0]!r}, "
|
| 82 |
+
f"but the mesh has only {n} vertices (valid indices 0..{n - 1})")
|
| 83 |
+
if len(set(idx)) != len(idx):
|
| 84 |
+
raise ValueError(f"from_pydata: {kind} {i} repeats a vertex: {tuple(idx)}")
|
| 85 |
+
return original(self, vertices, edges, faces, *args, **kwargs)
|
| 86 |
+
|
| 87 |
+
bpy.types.Mesh.from_pydata = checked_from_pydata
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def main() -> None:
|
| 91 |
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 92 |
+
p.add_argument("script")
|
| 93 |
+
p.add_argument("--samples", type=int, default=6, help="frames sampled for motion/visibility checks")
|
| 94 |
+
p.add_argument("--cwd", help="directory to run the script in (it renders its output here)")
|
| 95 |
+
p.add_argument("--result", help="write the JSON result to this file instead of stdout")
|
| 96 |
+
args = p.parse_args()
|
| 97 |
+
script = Path(args.script).resolve()
|
| 98 |
+
|
| 99 |
+
import bpy
|
| 100 |
+
|
| 101 |
+
bpy.ops.wm.read_factory_settings(use_empty=False) # same starting point as `blender --factory-startup`
|
| 102 |
+
install_guards(bpy)
|
| 103 |
+
result = {"script": str(script)}
|
| 104 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 105 |
+
cwd = Path(args.cwd).resolve() if args.cwd else Path(tmp)
|
| 106 |
+
os.chdir(cwd)
|
| 107 |
+
before = set(os.listdir(cwd))
|
| 108 |
+
t0 = time.time()
|
| 109 |
+
try:
|
| 110 |
+
runpy.run_path(str(script), run_name="__main__")
|
| 111 |
+
result["ok"] = True
|
| 112 |
+
except BaseException: # noqa: BLE001 (SystemExit from the script counts as failure too)
|
| 113 |
+
result["ok"] = False
|
| 114 |
+
result["traceback"] = traceback.format_exc(limit=8)
|
| 115 |
+
result["exec_seconds"] = round(time.time() - t0, 2)
|
| 116 |
+
result["files_written"] = sorted(set(os.listdir(cwd)) - before)
|
| 117 |
+
if result["ok"]:
|
| 118 |
+
try:
|
| 119 |
+
result["scene"] = summarize(bpy, args.samples)
|
| 120 |
+
except Exception: # noqa: BLE001 (a broken scene must not hide the execution result)
|
| 121 |
+
result["scene_error"] = traceback.format_exc(limit=3)
|
| 122 |
+
text = json.dumps(result, indent=2, ensure_ascii=False)
|
| 123 |
+
if args.result:
|
| 124 |
+
Path(args.result).write_text(text)
|
| 125 |
+
else:
|
| 126 |
+
print(text)
|
| 127 |
+
# Skip bpy teardown: after a failed script it can hang on leaked data until the harness timeout.
|
| 128 |
+
sys.stdout.flush()
|
| 129 |
+
sys.stderr.flush()
|
| 130 |
+
os._exit(0 if result["ok"] else 1)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
main()
|
harness/video_coder_harness/static_check.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Static checks for a generated bpy script, used both as an agent tool and as the acceptance gate.
|
| 2 |
+
|
| 3 |
+
Without a Blender backend this is the only verification, so it is deliberately conservative:
|
| 4 |
+
errors reject the sample, warnings are recorded but do not.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import argparse
|
| 8 |
+
import ast
|
| 9 |
+
import json
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
MAX_BYTES = 200_000
|
| 14 |
+
|
| 15 |
+
FORBIDDEN_MODULES = {
|
| 16 |
+
"subprocess", "socket", "requests", "urllib", "http", "ftplib", "smtplib",
|
| 17 |
+
"multiprocessing", "ctypes", "shutil", "webbrowser", "asyncio",
|
| 18 |
+
}
|
| 19 |
+
FORBIDDEN_CALLS = {
|
| 20 |
+
"os.system", "os.popen", "os.remove", "os.unlink", "os.rmdir", "os.removedirs",
|
| 21 |
+
"os.rename", "os.replace", "os.kill", "os.fork", "os.execv", "os.execvp",
|
| 22 |
+
"exec", "eval", "compile", "__import__",
|
| 23 |
+
}
|
| 24 |
+
# The script renders the video itself (bpy.ops.render.render); saving and process lifetime belong to the harness.
|
| 25 |
+
REQUIRED_RENDER_CALL = "bpy.ops.render.render"
|
| 26 |
+
FORBIDDEN_BPY_OPS = {
|
| 27 |
+
"bpy.ops.render.opengl",
|
| 28 |
+
"bpy.ops.wm.save_as_mainfile", "bpy.ops.wm.save_mainfile", "bpy.ops.wm.quit_blender",
|
| 29 |
+
"bpy.ops.wm.open_mainfile", "bpy.ops.wm.read_homefile",
|
| 30 |
+
}
|
| 31 |
+
ANIMATION_MARKERS = {
|
| 32 |
+
"keyframe_insert", "driver_add", "fcurves", "keyframe_points",
|
| 33 |
+
"rigidbody", "rigid_body", "frame_change_pre", "frame_change_post", "constraints",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _dotted(node: ast.AST) -> str:
|
| 38 |
+
parts = []
|
| 39 |
+
while isinstance(node, ast.Attribute):
|
| 40 |
+
parts.append(node.attr)
|
| 41 |
+
node = node.value
|
| 42 |
+
if isinstance(node, ast.Name):
|
| 43 |
+
parts.append(node.id)
|
| 44 |
+
return ".".join(reversed(parts))
|
| 45 |
+
return ""
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def check_source(source: str) -> dict:
|
| 49 |
+
errors: list[str] = []
|
| 50 |
+
warnings: list[str] = []
|
| 51 |
+
if len(source.encode()) > MAX_BYTES:
|
| 52 |
+
errors.append(f"script is larger than {MAX_BYTES} bytes")
|
| 53 |
+
try:
|
| 54 |
+
tree = ast.parse(source)
|
| 55 |
+
except SyntaxError as e:
|
| 56 |
+
return {"ok": False, "errors": [f"SyntaxError: {e.msg} (line {e.lineno})"], "warnings": [], "stats": {}}
|
| 57 |
+
|
| 58 |
+
imports: set[str] = set()
|
| 59 |
+
calls: set[str] = set()
|
| 60 |
+
attrs: set[str] = set()
|
| 61 |
+
assigned_attrs: set[str] = set()
|
| 62 |
+
for node in ast.walk(tree):
|
| 63 |
+
if isinstance(node, ast.Import):
|
| 64 |
+
imports.update(a.name.split(".")[0] for a in node.names)
|
| 65 |
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
| 66 |
+
imports.add(node.module.split(".")[0])
|
| 67 |
+
elif isinstance(node, ast.Call):
|
| 68 |
+
calls.add(_dotted(node.func))
|
| 69 |
+
elif isinstance(node, ast.Attribute):
|
| 70 |
+
attrs.add(node.attr)
|
| 71 |
+
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
| 72 |
+
attrs.add(node.value) # e.g. keyframe_insert(data_path=...) or getattr(obj, "constraints")
|
| 73 |
+
if isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
|
| 74 |
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
| 75 |
+
assigned_attrs.update(t.attr for t in targets if isinstance(t, ast.Attribute))
|
| 76 |
+
|
| 77 |
+
if "bpy" not in imports:
|
| 78 |
+
errors.append("script does not import bpy")
|
| 79 |
+
for mod in sorted(imports & FORBIDDEN_MODULES):
|
| 80 |
+
errors.append(f"forbidden import: {mod}")
|
| 81 |
+
for call in sorted(calls & (FORBIDDEN_CALLS | FORBIDDEN_BPY_OPS)):
|
| 82 |
+
errors.append(f"forbidden call: {call} (only the video render may write output)")
|
| 83 |
+
if REQUIRED_RENDER_CALL not in calls:
|
| 84 |
+
errors.append("no render: the script must end with bpy.ops.render.render(animation=True)")
|
| 85 |
+
has_camera = "camera" in assigned_attrs or "bpy.ops.object.camera_add" in calls
|
| 86 |
+
if not has_camera:
|
| 87 |
+
errors.append("no camera: create a camera and assign it to scene.camera")
|
| 88 |
+
if not (attrs & ANIMATION_MARKERS):
|
| 89 |
+
warnings.append("no animation detected (keyframes, drivers, constraints or physics)")
|
| 90 |
+
if "open" in calls:
|
| 91 |
+
warnings.append("script calls open(); external files are not available")
|
| 92 |
+
|
| 93 |
+
stats = {"lines": source.count("\n") + 1, "bytes": len(source.encode()), "n_bpy_ops_calls": sum(c.startswith("bpy.ops.") for c in calls)}
|
| 94 |
+
return {"ok": not errors, "errors": errors, "warnings": warnings, "stats": stats}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def check_file(path: Path) -> dict:
|
| 98 |
+
if not path.is_file():
|
| 99 |
+
return {"ok": False, "errors": [f"{path} not found"], "warnings": [], "stats": {}}
|
| 100 |
+
return check_source(path.read_text(encoding="utf-8", errors="replace"))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def main() -> None:
|
| 104 |
+
parser = argparse.ArgumentParser(description="Static checks for a Blender bpy script.")
|
| 105 |
+
parser.add_argument("script", nargs="?", default="main.py")
|
| 106 |
+
parser.add_argument("--json", action="store_true")
|
| 107 |
+
args = parser.parse_args()
|
| 108 |
+
result = check_file(Path(args.script))
|
| 109 |
+
if args.json:
|
| 110 |
+
print(json.dumps(result, indent=2))
|
| 111 |
+
else:
|
| 112 |
+
print("STATIC CHECK " + ("PASSED" if result["ok"] else "FAILED"))
|
| 113 |
+
for e in result["errors"]:
|
| 114 |
+
print(f"error: {e}")
|
| 115 |
+
for w in result["warnings"]:
|
| 116 |
+
print(f"warning: {w}")
|
| 117 |
+
if result["stats"]:
|
| 118 |
+
print(f"stats: {json.dumps(result['stats'])}")
|
| 119 |
+
sys.exit(0 if result["ok"] else 1)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
main()
|
harness/video_coder_harness/video.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inspect a rendered video with the ffmpeg binary bundled by imageio-ffmpeg (no system ffmpeg on the cluster)."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import re
|
| 5 |
+
import subprocess
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import imageio_ffmpeg
|
| 9 |
+
|
| 10 |
+
FFMPEG = imageio_ffmpeg.get_ffmpeg_exe()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def probe(path: str | Path) -> dict:
|
| 14 |
+
"""Decode the whole video stream: returns frame count, size and fps, or an error."""
|
| 15 |
+
path = Path(path)
|
| 16 |
+
if not path.is_file():
|
| 17 |
+
return {"exists": False}
|
| 18 |
+
info: dict = {"exists": True, "bytes": path.stat().st_size}
|
| 19 |
+
proc = subprocess.run([FFMPEG, "-hide_banner", "-nostdin", "-i", str(path), "-map", "0:v:0", "-f", "null", "-"],
|
| 20 |
+
capture_output=True, text=True, timeout=300)
|
| 21 |
+
err = proc.stderr
|
| 22 |
+
if m := re.search(r"Video: (\w+).*?, (\d{2,5})x(\d{2,5})", err):
|
| 23 |
+
info["codec"], info["width"], info["height"] = m.group(1), int(m.group(2)), int(m.group(3))
|
| 24 |
+
if m := re.search(r"([\d.]+) fps", err):
|
| 25 |
+
info["fps"] = float(m.group(1))
|
| 26 |
+
frames = re.findall(r"frame=\s*(\d+)", err)
|
| 27 |
+
info["n_frames"] = int(frames[-1]) if frames else 0
|
| 28 |
+
if proc.returncode != 0 or not frames:
|
| 29 |
+
info["decode_error"] = err.strip().splitlines()[-1] if err.strip() else f"ffmpeg exit {proc.returncode}"
|
| 30 |
+
return info
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def frame_stats(path: str | Path, n_frames: int, samples: int = 6, w: int = 64, h: int = 36) -> dict:
|
| 34 |
+
"""Grayscale statistics of `samples` evenly spaced frames, downscaled to w x h.
|
| 35 |
+
|
| 36 |
+
std: per-frame pixel standard deviation (≈0 when the camera sees only background).
|
| 37 |
+
diff: mean absolute pixel difference between consecutive sampled frames (≈0 when nothing moves).
|
| 38 |
+
"""
|
| 39 |
+
idx = sorted({round(i * (n_frames - 1) / (samples - 1)) for i in range(samples)})
|
| 40 |
+
select = "+".join(f"eq(n\\,{f})" for f in idx)
|
| 41 |
+
proc = subprocess.run([FFMPEG, "-hide_banner", "-nostdin", "-loglevel", "error", "-i", str(path),
|
| 42 |
+
"-vf", f"select='{select}',scale={w}:{h},format=gray", "-vsync", "0",
|
| 43 |
+
"-f", "rawvideo", "-"], capture_output=True, timeout=300)
|
| 44 |
+
size = w * h
|
| 45 |
+
frames = [proc.stdout[i:i + size] for i in range(0, len(proc.stdout) - size + 1, size)]
|
| 46 |
+
std = []
|
| 47 |
+
for f in frames:
|
| 48 |
+
mean = sum(f) / size
|
| 49 |
+
std.append(round((sum((p - mean) ** 2 for p in f) / size) ** 0.5, 2))
|
| 50 |
+
diff = [round(sum(abs(a - b) for a, b in zip(f0, f1)) / size, 2) for f0, f1 in zip(frames, frames[1:])]
|
| 51 |
+
return {"frames": [i + 1 for i in idx][: len(frames)], "std": std, "diff": diff}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def contact_sheet(path: str | Path, out: str | Path, frames: list[int], width: int = 480) -> bool:
|
| 55 |
+
"""Tile the given 0-based frame indices into a 2-column grid image."""
|
| 56 |
+
select = "+".join(f"eq(n\\,{f})" for f in frames)
|
| 57 |
+
rows = (len(frames) + 1) // 2
|
| 58 |
+
proc = subprocess.run([FFMPEG, "-hide_banner", "-nostdin", "-loglevel", "error", "-y", "-i", str(path),
|
| 59 |
+
"-vf", f"select='{select}',scale={width}:-2,tile=2x{rows}", "-frames:v", "1", str(out)],
|
| 60 |
+
capture_output=True, text=True, timeout=300)
|
| 61 |
+
return proc.returncode == 0 and Path(out).is_file()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def sample_frames(video: str | Path, n_frames: int, k: int, width: int) -> list[tuple[int, str]]:
|
| 65 |
+
"""(1-based frame number, JPEG data URL) for k evenly spaced frames."""
|
| 66 |
+
idx = sorted({round(i * (n_frames - 1) / (k - 1)) for i in range(k)})
|
| 67 |
+
out = []
|
| 68 |
+
for i in idx:
|
| 69 |
+
proc = subprocess.run([FFMPEG, "-hide_banner", "-nostdin", "-loglevel", "error", "-i", str(video),
|
| 70 |
+
"-vf", f"select='eq(n\\,{i})',scale={width}:-2", "-frames:v", "1",
|
| 71 |
+
"-q:v", "4", "-f", "image2", "-c:v", "mjpeg", "-"], capture_output=True, timeout=120)
|
| 72 |
+
if proc.returncode == 0 and proc.stdout:
|
| 73 |
+
out.append((i + 1, "data:image/jpeg;base64," + base64.b64encode(proc.stdout).decode()))
|
| 74 |
+
return out
|
packages.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
libx11-6
|
| 2 |
+
libxrender1
|
| 3 |
+
libxxf86vm1
|
| 4 |
+
libxfixes3
|
| 5 |
+
libxi6
|
| 6 |
+
libxext6
|
| 7 |
+
libxkbcommon0
|
| 8 |
+
libsm6
|
| 9 |
+
libgl1
|
| 10 |
+
libegl1
|
| 11 |
+
libegl-mesa0
|
| 12 |
+
libgl1-mesa-dri
|
| 13 |
+
libgomp1
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers>=5.17.0
|
| 2 |
+
torchvision
|
| 3 |
+
accelerate
|
| 4 |
+
flash-linear-attention
|
| 5 |
+
imageio-ffmpeg
|
| 6 |
+
uv
|
| 7 |
+
pillow
|
| 8 |
+
pyyaml
|