File size: 9,725 Bytes
ae36cfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
#!/usr/bin/env python3
"""Record a predeclared evaluation sample with Godot Movie Maker and local ffmpeg.

Linux only. Requires Xvfb, bubblewrap, Mesa software rendering and ffmpeg. This
records the original untouched generation with no input, retries or fixes.
"""
from __future__ import annotations

import argparse
import json
import os
from pathlib import Path
import platform
import shutil
import signal
import subprocess
import sys
import tempfile
import time

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from vibegame.project import parse_project, project_sha256, write_project
from vibegame.validate import ERROR_PATTERN, sandbox_command, with_resource_limits
from generate import file_hash


def record(files: dict, godot: Path, output: Path, seconds: float = 15, fps: int = 30, *, generation_metadata: dict | None = None):
    if platform.system() != "Linux":
        raise RuntimeError("Recording requires the Linux runtime; local macOS Godot is disabled due to sandbox loader crashes")
    if not 1 <= seconds <= 60 or not 1 <= fps <= 60:
        raise ValueError("Recording requires 1–60 seconds and 1–60 fps")
    ffmpeg, xvfb, ffprobe = shutil.which("ffmpeg"), shutil.which("Xvfb"), shutil.which("ffprobe")
    if not ffmpeg or not xvfb or not ffprobe:
        raise RuntimeError("Install ffmpeg, xvfb, libgl1-mesa-dri and bubblewrap first")
    output = output.resolve()
    if output.exists() or output.with_suffix(".recording.json").exists():
        raise FileExistsError("Refusing to overwrite recording or evidence")
    output.parent.mkdir(parents=True, exist_ok=True)
    staging = output.with_suffix(".recording")
    staging.mkdir(exist_ok=False)
    project = write_project(files, staging / "project")
    movie = project / "capture.avi"
    # Private X socket, no TCP listener, no access to the user's desktop.
    display = None
    xprocess = None
    for number in range(101, 151):
        if not Path(f"/tmp/.X11-unix/X{number}").exists():
            display = f":{number}"
            xlog = (staging / "xvfb.log").open("wb")
            xprocess = subprocess.Popen([xvfb, display, "-screen", "0", "1280x720x24", "-nolisten", "tcp", "-ac"], stdout=xlog, stderr=subprocess.STDOUT, start_new_session=True)
            break
    if display is None:
        raise RuntimeError("No private X display available")
    evidence = {"source": generation_metadata, "seconds": seconds, "fps": fps,
                "mode": "fixed-timestep movie; no human or simulated input; unmodified generation",
                "godot_binary_sha256": file_hash(godot), "success": False, "capture_success": False,
                "project_sha256": project_sha256(files), "duration_seconds": seconds, "fixed_timestep": True, "player_input": False, **(generation_metadata or {})}
    try:
        socket = Path(f"/tmp/.X11-unix/X{display[1:]}")
        deadline = time.monotonic() + 10
        while not socket.exists() and xprocess.poll() is None and time.monotonic() < deadline:
            time.sleep(0.1)
        if not socket.exists():
            raise RuntimeError("Private Xvfb display failed to start")
        commands = [
            [str(godot), "--headless", "--editor", "--import", "--path", str(project)],
            [str(godot), "--path", str(project), "--display-driver", "x11", "--rendering-method", "gl_compatibility", "--audio-driver", "Dummy", "--write-movie", str(movie), "--fixed-fps", str(fps), "--quit-after", str(round(seconds * fps)), "--disable-vsync"],
        ]
        for index, command in enumerate(commands):
            wrapped, sandbox = sandbox_command(command, project)
            separator = wrapped.index("--")
            # Share only this private Xvfb socket. Network namespace remains isolated.
            wrapped[separator:separator] = ["--ro-bind", str(socket), str(socket)]
            env = {"PATH": "/usr/bin:/bin", "HOME": str(project / ".sandbox-home"), "DISPLAY": display,
                   "LIBGL_ALWAYS_SOFTWARE": "1", "GODOT_SILENCE_ROOT_WARNING": "1", "LANG": "C.UTF-8"}
            log_path = staging / ("import.log" if index == 0 else "movie.log")
            with log_path.open("wb") as log:
                process = subprocess.Popen(with_resource_limits(wrapped, file_bytes=2 * 1024**3), cwd=project, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
                try:
                    returncode = process.wait(timeout=180 if index == 0 else 600)
                except subprocess.TimeoutExpired:
                    os.killpg(process.pid, signal.SIGKILL)
                    process.wait()
                    raise RuntimeError("Godot movie render timed out")
            text = log_path.read_text(errors="replace")
            if returncode != 0 or ERROR_PATTERN.search(text):
                raise RuntimeError(f"Godot failed; inspect {log_path}")
        subprocess.run([ffmpeg, "-nostdin", "-v", "error", "-i", str(movie), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", "-n", str(output)], check=True, timeout=180)
        probe = subprocess.run([ffprobe, "-v", "error", "-select_streams", "v:0", "-count_frames",
                "-show_entries", "stream=nb_read_frames,avg_frame_rate:format=duration", "-of", "json", str(output)],
                check=True, capture_output=True, text=True, timeout=60)
        video = json.loads(probe.stdout)
        duration = float(video["format"]["duration"])
        frames = int(video["streams"][0]["nb_read_frames"])
        expected_frames = round(seconds * fps)
        if duration < seconds - 2 / fps or abs(frames - expected_frames) > 2:
            raise RuntimeError(f"Recording ended early: expected {expected_frames} frames, found {frames}, duration {duration}s")
        evidence.update({"success": True, "capture_success": True, "output": str(output), "output_sha256": file_hash(output),
                         "duration_seconds": duration, "frames": frames, "requested_duration_seconds": seconds, "sandbox": sandbox})
    except Exception as exc:
        evidence["error"] = f"{type(exc).__name__}: {exc}"
        raise
    finally:
        if xprocess and xprocess.poll() is None:
            os.killpg(xprocess.pid, signal.SIGTERM)
            xprocess.wait(timeout=5)
        if xprocess:
            xlog.close()
        output.with_suffix(".recording.json").write_text(json.dumps(evidence, indent=2) + "\n")
    return evidence


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    sources = parser.add_mutually_exclusive_group(required=True)
    sources.add_argument("--evaluation", type=Path)
    sources.add_argument("--project-json", type=Path, help="Separate smoke/demo project; never counted as an eval sample")
    parser.add_argument("--index", type=int, default=0)
    parser.add_argument("--godot", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--seconds", type=float, default=15)
    parser.add_argument("--fps", type=int, default=30)
    parser.add_argument("--showcase-selection", type=Path, help="Explicit success-only showcase receipt; original fixed-sample mode is unchanged")
    args = parser.parse_args()
    selection = None
    if args.showcase_selection:
        if not args.evaluation:
            parser.error("A success showcase requires --evaluation, never a scaffold project")
        from showcase import load_selection
        selection = load_selection(args.evaluation, args.showcase_selection)
        if args.index not in selection['showcase_indices']:
            parser.error("Only the exact first three clean results may be captured; no substitutions")
        expected_output = args.evaluation / 'showcase' / 'recordings' / f'sample-{args.index:03d}.mp4'
        if args.output.resolve() != expected_output.resolve():
            parser.error("Showcase recordings must use this evaluation's showcase/recordings/sample-NNN.mp4")
        manifest = json.loads((args.evaluation / 'manifest.json').read_text())
        if file_hash(args.godot) != manifest.get('runtime_sha256'):
            parser.error("Showcase capture must use the evaluated Godot binary")
    if args.evaluation:
        if selection is None and args.index not in (0, 200, 400):
            parser.error("Published samples are predeclared indices 0,200,400; do not cherry-pick")
        item = args.evaluation / "items" / f"{args.index:04d}"
        generation = json.loads((item / "generation.json").read_text())
        result = json.loads((item / "result.json").read_text())
        if not result.get("clean_run_60s"):
            parser.error("Predeclared sample failed the 60s check; report that failure instead of substituting a different sample")
        files = parse_project(generation["output"])
        metadata = {"evaluation_manifest_sha256": file_hash(args.evaluation / "manifest.json"), "index": args.index, "sample_index": args.index, "prompt_id": result.get("prompt_id"), "generation_sha256": file_hash(item / "generation.json")}
        if selection is not None:
            metadata.update({'kind': 'model-success-showcase', 'showcase_policy': selection['policy'],
                             'showcase_selection_sha256': file_hash(args.showcase_selection),
                             'selection_disclosure': selection['disclosure']})
    else:
        files = parse_project(args.project_json.read_text())
        metadata = {"kind": "scaffold-preview", "project_json_sha256": file_hash(args.project_json)}
    print(json.dumps(record(files, args.godot.resolve(), args.output, args.seconds, args.fps, generation_metadata=metadata), indent=2))


if __name__ == "__main__":
    main()