Merge branch 'main' of https://github.com/calesthio/OpenMontage into codex/kling-official-phase-1

This commit is contained in:
xucailiang
2026-07-07 15:03:07 +08:00
181 changed files with 11322 additions and 209 deletions

View File

@@ -0,0 +1,121 @@
"""Render one review still per scene for an atelier (bespoke) composition.
The Backlot storyboard can't thumbnail a `.tsx` scene, so a bespoke run
populates the assets-gate filmstrip by writing `projects/<slug>/snapshots/
<scene_id>.png` — one Remotion `still` per scene at a representative frame.
Run this AT THE ASSETS GATE (before any draft/compose render):
python scripts/atelier_snapshots.py <slug>
It reads scene timings from `artifacts/scene_plan.json` and the bespoke render
config from `artifacts/edit_decisions.json` (falling back to conventional
paths: index.tsx / artifacts/props.json / public/). The composition id comes
from edit_decisions.bespoke.composition_id or --composition-id.
See skills/meta/bespoke-composition.md and skills/meta/checkpoint-protocol.md.
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
# On Windows npx is npx.cmd — resolve it so subprocess finds it without a shell.
NPX = shutil.which("npx") or "npx"
REPO_ROOT = Path(__file__).resolve().parent.parent
COMPOSER_DIR = REPO_ROOT / "remotion-composer"
def _load(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("slug", help="project slug under projects/")
ap.add_argument("--composition-id", help="Remotion composition id (else from edit_decisions)")
ap.add_argument("--entry", help="entry .tsx (default projects/<slug>/index.tsx)")
ap.add_argument("--props", help="props JSON (default artifacts/props.json)")
ap.add_argument("--public-dir", help="public dir (default projects/<slug>/public)")
ap.add_argument("--fps", type=int, default=None, help="frames per second (default from props or 30)")
ap.add_argument("--only", nargs="*", help="only these scene ids")
args = ap.parse_args(argv)
proj = REPO_ROOT / "projects" / args.slug
if not proj.is_dir():
print(f"error: no project at {proj}", file=sys.stderr)
return 2
scene_plan = _load(proj / "artifacts" / "scene_plan.json")
scenes = (scene_plan.get("scenes") or []) if isinstance(scene_plan, dict) else []
if not scenes:
print("error: no scenes in artifacts/scene_plan.json", file=sys.stderr)
return 2
edit = _load(proj / "artifacts" / "edit_decisions.json")
bespoke = (edit.get("bespoke") or {}) if isinstance(edit, dict) else {}
props_path = Path(args.props or bespoke.get("props_path") or (proj / "artifacts" / "props.json"))
entry = Path(args.entry or bespoke.get("entry") or (proj / "index.tsx"))
if not entry.is_absolute():
entry = (REPO_ROOT / entry).resolve()
public_dir = Path(args.public_dir or bespoke.get("public_dir") or (proj / "public"))
comp_id = args.composition_id or bespoke.get("composition_id")
if not comp_id:
print("error: composition id unknown (pass --composition-id or set edit_decisions.bespoke)", file=sys.stderr)
return 2
fps = args.fps
if fps is None:
props = _load(props_path)
fps = int(props.get("fps") or 30)
# Stage the project into remotion-composer so webpack resolves node_modules.
sys.path.insert(0, str(REPO_ROOT))
from tools.video.video_compose import VideoCompose # noqa: E402
staged_entry = VideoCompose()._stage_atelier_project(entry, COMPOSER_DIR)
snap_dir = proj / "snapshots"
snap_dir.mkdir(exist_ok=True)
ok, fail = 0, 0
for sc in scenes:
sid = str(sc.get("id") or "").strip()
if not sid:
continue
if args.only and sid not in args.only:
continue
start = sc.get("start_seconds")
end = sc.get("end_seconds")
mid = ((start + end) / 2) if (start is not None and end is not None) else (start or 0)
frame = max(0, round(mid * fps))
out = snap_dir / f"{sid}.png"
cmd = [
NPX, "remotion", "still", str(staged_entry), str(comp_id), str(out.resolve()),
f"--frame={frame}",
f"--props={props_path.resolve()}",
f"--public-dir={public_dir.resolve()}",
]
try:
subprocess.run(cmd, cwd=COMPOSER_DIR, check=True, capture_output=True, text=True, timeout=600)
ok += 1
print(f" {sid}: frame {frame} -> {out.relative_to(REPO_ROOT)}")
except subprocess.CalledProcessError as e:
fail += 1
print(f" {sid}: FAILED — {(e.stderr or e.stdout or '')[-300:]}", file=sys.stderr)
except Exception as e: # noqa: BLE001
fail += 1
print(f" {sid}: FAILED — {e}", file=sys.stderr)
print(f"snapshots: {ok} ok, {fail} failed -> {snap_dir.relative_to(REPO_ROOT)}")
return 0 if fail == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,358 @@
"""Stage demo productions + capture the README screenshots for Backlot.
Builds a handful of fictional projects (generated cinematic placeholder art —
safe for the public repo, no real project content) into a staging projects
dir, serves Backlot against it via OPENMONTAGE_PROJECTS_DIR, and captures
screenshots with Playwright.
python scripts/backlot_screenshot_stage.py # stage + shoot
python scripts/backlot_screenshot_stage.py --stage-only
"""
from __future__ import annotations
import argparse
import json
import math
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
STAGE_DIR = REPO_ROOT / ".backlot" / "screenshot-stage"
SHOTS_DIR = REPO_ROOT / "docs" / "images" / "backlot"
PORT = 4790
os.environ["OPENMONTAGE_PROJECTS_DIR"] = str(STAGE_DIR)
sys.path.insert(0, str(REPO_ROOT))
from PIL import Image, ImageDraw, ImageFilter # noqa: E402
from lib.checkpoint import init_project, write_checkpoint # noqa: E402
from lib.events import emit_event # noqa: E402
from tests.contracts.test_phase0_contracts import sample_artifact # noqa: E402
# ---------------------------------------------------------------------------
# generated cinematic frames
# ---------------------------------------------------------------------------
def cinematic_frame(path: Path, top, bottom, glow, seed: int, label: str = "") -> None:
"""A moody gradient plate: sky gradient, horizon glow, vignette, grain."""
w, h = 960, 540
img = Image.new("RGB", (w, h))
px = img.load()
for y in range(h):
t = y / h
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(w):
px[x, y] = (r, g, b)
# horizon glow + light disc (screen blend so it actually GLOWS)
from PIL import ImageChops
glow_layer = Image.new("RGB", (w, h), (0, 0, 0))
gd = ImageDraw.Draw(glow_layer)
cx, cy = w // 2 + (seed % 200 - 100), int(h * 0.62)
for radius, alpha in ((380, 70), (240, 120), (140, 180), (70, 255)):
gd.ellipse([cx - radius, cy - radius // 2, cx + radius, cy + radius // 2],
fill=tuple(int(c * alpha / 255) for c in glow))
gd.ellipse([cx - 34, cy - 90, cx + 34, cy - 22],
fill=tuple(min(255, int(c * 1.15)) for c in glow))
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(36))
img = ImageChops.screen(img, glow_layer)
d = ImageDraw.Draw(img)
# horizon line + silhouette blocks
d.line([(0, cy + 40), (w, cy + 40)], fill=tuple(int(c * 0.25) for c in glow), width=2)
rnd = seed
for i in range(6):
rnd = (rnd * 16807) % 2147483647
bx = (rnd % w)
bw = 30 + rnd % 90
bh = 20 + rnd % 70
d.rectangle([bx, cy + 40 - bh, bx + bw, cy + 40], fill=(6, 7, 9))
# grain
rnd = seed + 7
for _ in range(2600):
rnd = (rnd * 48271) % 2147483647
x, y = rnd % w, (rnd // w) % h
v = px[x, y]
px[x, y] = tuple(min(255, c + 10) for c in v)
# vignette
vin = Image.new("L", (w, h), 0)
vd = ImageDraw.Draw(vin)
vd.ellipse([-w * 0.25, -h * 0.35, w * 1.25, h * 1.35], fill=255)
vin = vin.filter(ImageFilter.GaussianBlur(120))
img = Image.composite(img, Image.new("RGB", (w, h), (0, 0, 0)), vin)
if label:
d = ImageDraw.Draw(img)
d.text((28, h - 46), label.upper(), fill=(210, 205, 195))
path.parent.mkdir(parents=True, exist_ok=True)
img.save(path)
PALETTES = {
"lighthouse": (((8, 12, 24), (28, 22, 16), (240, 168, 60))),
"static": (((14, 8, 28), (10, 16, 40), (120, 140, 255))),
"orchard": (((6, 18, 14), (20, 30, 18), (140, 220, 140))),
"paper": (((30, 24, 18), (16, 12, 10), (235, 200, 150))),
}
# ---------------------------------------------------------------------------
# project staging
# ---------------------------------------------------------------------------
def script_artifact(title: str, scenes: list) -> dict:
return {
"version": "1.0", "title": title,
"total_duration_seconds": scenes[-1][3],
"sections": [
{"id": f"s{i+1}", "label": desc.split("")[0].strip()[:40], "text": narr,
"start_seconds": s0, "end_seconds": s1}
for i, (sid, desc, s0, s1, narr) in enumerate(scenes)
],
}
def scene_plan_artifact(scenes: list, hero: str) -> dict:
return {
"version": "1.0",
"scenes": [
{"id": sid, "type": "generated", "description": desc,
"start_seconds": s0, "end_seconds": s1, "script_section_id": f"s{i+1}",
"hero_moment": sid == hero,
"shot_language": {"shot_size": ["wide", "medium", "close_up", "extreme_close_up"][i % 4],
"camera_movement": ["static", "dolly_in", "pan_right", "orbital"][i % 4],
"lens_mm": [24, 50, 85, 35][i % 4],
"lighting_key": ["golden_hour", "low_key", "rim_lit", "natural"][i % 4]},
"required_assets": [{"type": "image", "description": desc, "source": "generate"}]}
for i, (sid, desc, s0, s1, _narr) in enumerate(scenes)
],
}
def decision_log(pid: str) -> dict:
return {
"version": "1.0", "project_id": pid,
"decisions": [
{"decision_id": "d-001", "stage": "proposal", "category": "provider_selection",
"subject": "image generation",
"options_considered": [
{"option_id": "flux_image", "label": "FLUX", "score": 0.9,
"reason": "strongest cinematic realism at 16:9"},
{"option_id": "openai_image", "label": "gpt-image-1", "score": 0.7,
"reason": "solid, slightly flatter light",
"rejected_because": "less atmospheric depth for night scenes"}],
"selected": "flux_image",
"reason": "Strongest cinematic realism for night exteriors.",
"user_visible": True, "user_approved": True, "confidence": 0.9},
{"decision_id": "d-002", "stage": "proposal", "category": "render_runtime_selection",
"subject": "compose",
"options_considered": [
{"option_id": "remotion", "label": "Remotion", "score": 0.85,
"reason": "spring typography for the title cards"},
{"option_id": "hyperframes", "label": "HyperFrames", "score": 0.6,
"reason": "GSAP-native motion", "rejected_because": "stock React stack fits better"}],
"selected": "remotion", "reason": "Native title cards with spring physics.",
"user_visible": True, "user_approved": True, "confidence": 0.85},
],
}
def stage_project(pid: str, title: str, palette: str, scenes: list, *,
state: str, hero: str, takes_scene: str | None = None) -> None:
"""state: 'complete' | 'assets_live' | 'script_gate' | 'early'"""
top, bottom, glow = PALETTES[palette]
pdir = STAGE_DIR / pid
init_project(pid, title=title, pipeline_type="cinematic",
pipeline_dir=STAGE_DIR, style_playbook="clean-professional")
art_dir = pdir / "artifacts"
def cp(stage, status, artifacts, **kw):
write_checkpoint(STAGE_DIR, pid, stage, status, artifacts,
pipeline_type="cinematic", **kw)
time.sleep(0.02) # distinct mtimes/timestamps
brief = sample_artifact("research_brief")
brief["topic"] = title
cp("research", "completed", {"research_brief": brief})
script = script_artifact(title, scenes)
plan = scene_plan_artifact(scenes, hero)
(art_dir / "decision_log.json").write_text(json.dumps(decision_log(pid), indent=2))
if state == "early":
cp("script", "in_progress", {})
return
(art_dir / "script.json").write_text(json.dumps(script, indent=2))
if state == "script_gate":
cp("script", "awaiting_human", {"script": script},
review={"round": 1, "decision": "pass", "critical": 0,
"suggestions": 2, "nitpicks": 1,
"summary": "Hook rewritten to a direct claim; s3 tightened."})
return
cp("script", "awaiting_human", {"script": script},
review={"round": 1, "decision": "pass", "critical": 0, "suggestions": 1,
"nitpicks": 0, "summary": "Strong spine; trimmed s2."})
cp("script", "completed", {"script": script}, human_approved=True)
(art_dir / "scene_plan.json").write_text(json.dumps(plan, indent=2))
cp("scene_plan", "awaiting_human", {"scene_plan": plan})
cp("scene_plan", "completed", {"scene_plan": plan}, human_approved=True)
# assets
cp("assets", "in_progress", {})
manifest = {"version": "1.0", "assets": [], "total_cost_usd": 0.0}
n_done = len(scenes) if state == "complete" else max(1, len(scenes) - 2)
for i, (sid, desc, _s0, _s1, _n) in enumerate(scenes[:n_done]):
emit_event(pdir, {"tool": "flux_image", "event": "start", "scene_id": sid})
rel = f"assets/images/{sid}.png"
n_takes = 3 if sid == takes_scene else 1
for take in range(n_takes):
take_rel = rel if take == n_takes - 1 else f"assets/images/{sid}_t{take+1}.png"
cinematic_frame(pdir / take_rel, top, bottom, glow,
seed=i * 97 + take * 31 + 11, label=f"{title} · {sid}")
manifest["assets"].append({
"id": f"img_{sid}_{take+1}", "type": "image", "path": take_rel,
"scene_id": sid, "source_tool": "flux_image", "model": "flux-1.1-pro",
"cost_usd": 0.04, "prompt": desc,
"quality_score": round(0.84 + take * 0.04, 2)})
manifest["total_cost_usd"] = round(manifest["total_cost_usd"] + 0.04, 2)
emit_event(pdir, {"tool": "flux_image", "event": "finish", "scene_id": sid,
"success": True, "cost_usd": 0.04 * n_takes, "duration_s": 18.4,
"output_path": rel})
(art_dir / "asset_manifest.json").write_text(json.dumps(manifest, indent=2))
write_checkpoint(STAGE_DIR, pid, "assets", "in_progress", {},
pipeline_type="cinematic",
metadata={"partial_progress": {
"completed_scene_ids": [s[0] for s in scenes[:i + 1]]}},
cost_snapshot={"total_spent_usd": manifest["total_cost_usd"],
"total_reserved_usd": 0.0,
"budget_remaining_usd": round(4 - manifest["total_cost_usd"], 2)})
if state == "assets_live":
# one scene actively generating right now
gen_sid = scenes[n_done][0]
emit_event(pdir, {"tool": "flux_image", "event": "start", "scene_id": gen_sid})
return
cp("assets", "awaiting_human", {"asset_manifest": manifest},
cost_snapshot={"total_spent_usd": manifest["total_cost_usd"],
"total_reserved_usd": 0.0,
"budget_remaining_usd": round(4 - manifest["total_cost_usd"], 2)})
cp("assets", "completed", {"asset_manifest": manifest}, human_approved=True)
# edit + compose (render via ffmpeg slideshow from the frames)
edit = {"version": "1.0", "cuts": [], "metadata": {"note": "demo"}}
(art_dir / "edit_decisions.json").write_text(json.dumps(edit, indent=2))
renders = pdir / "renders"
renders.mkdir(exist_ok=True)
first_frame = pdir / "assets" / "images" / f"{scenes[0][0]}.png"
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1",
"-i", str(first_frame), "-t", "4", "-vf", "scale=960:540",
"-pix_fmt", "yuv420p", str(renders / "final.mp4")],
check=False, timeout=60)
SCENES_LIGHTHOUSE = [
("sc1", "Opening — a lighthouse at dusk", 0, 4, "The coast holds its breath."),
("sc2", "The beam sweeps the water", 4, 9, "Every night, the same promise."),
("sc3", "A storm builds offshore", 9, 15, "Until the night the light went out."),
("sc4", "The keeper climbs the stairs", 15, 21, "Someone still has to climb."),
("sc5", "The lamp room, hands on glass", 21, 26, "And someone always does."),
]
SCENES_STATIC = [
("sc1", "A radio tower against a violet sky", 0, 5, "The signal arrived at 3:14 a.m."),
("sc2", "Rows of receivers, one glowing", 5, 10, "Nobody was listening. Except her."),
("sc3", "Static resolving into a pattern", 10, 16, "Noise, she realized, was a language."),
("sc4", "The pattern projected on a wall", 16, 22, "And it was asking a question."),
]
SCENES_ORCHARD = [
("sc1", "An orchard in first light", 0, 5, "The trees keep a slower calendar."),
("sc2", "Hands grafting a branch", 5, 11, "A graft is a promise to a future you won't see."),
("sc3", "Seasons blurring over one tree", 11, 18, "Forty springs in a single trunk."),
("sc4", "Fruit in a child's hand", 18, 24, "Somebody planted this for you."),
]
SCENES_PAPER = [
("sc1", "A desk lamp over folded paper", 0, 4, "Every boat starts as a flat sheet."),
("sc2", "Creases becoming a hull", 4, 9, "Twelve folds between idea and vessel."),
("sc3", "The boat on dark water", 9, 15, "It will not survive the river."),
("sc4", "Paper dissolving, ink blooming", 15, 20, "That was never the point."),
]
def build_stage() -> None:
if STAGE_DIR.exists():
shutil.rmtree(STAGE_DIR)
STAGE_DIR.mkdir(parents=True)
stage_project("the-last-lighthouse", "The Last Lighthouse", "lighthouse",
SCENES_LIGHTHOUSE, state="complete", hero="sc3", takes_scene="sc3")
stage_project("signal-in-the-static", "Signal in the Static", "static",
SCENES_STATIC, state="assets_live", hero="sc3")
stage_project("the-slow-orchard", "The Slow Orchard", "orchard",
SCENES_ORCHARD, state="script_gate", hero="sc3")
stage_project("paper-boats", "Paper Boats", "paper",
SCENES_PAPER, state="early", hero="sc3")
print(f"[stage] built 4 demo projects in {STAGE_DIR}")
# ---------------------------------------------------------------------------
# screenshots
# ---------------------------------------------------------------------------
SHOTS = [
("library", "/?static=1", 1560, 500, 4200),
("board-live", "/p/signal-in-the-static?static=1", 1560, 1150, 4200),
("script-gate", "/p/the-slow-orchard?static=1", 1560, 760, 3200),
("storyboard", "/p/the-last-lighthouse?static=1", 1560, 1500, 4200),
]
def shoot() -> None:
env = dict(os.environ)
server = subprocess.Popen(
[sys.executable, "-m", "backlot", "serve", "--port", str(PORT)],
env=env, cwd=REPO_ROOT,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
deadline = time.time() + 20
while time.time() < deadline:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/api/health", timeout=1):
break
except Exception:
time.sleep(0.4)
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
for name, path, w, h, wait_ms in SHOTS:
out = SHOTS_DIR / f"{name}.png"
subprocess.run(
["npx", "playwright", "screenshot",
"--viewport-size", f"{w},{h}",
"--wait-for-timeout", str(wait_ms),
f"http://127.0.0.1:{PORT}{path}", str(out)],
check=True, timeout=120, shell=(os.name == "nt"))
print(f"[shot] {out}")
finally:
server.terminate()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--stage-only", action="store_true")
parser.add_argument("--shoot-only", action="store_true")
args = parser.parse_args()
if not args.shoot_only:
build_stage()
if not args.stage_only:
shoot()

View File

@@ -0,0 +1,161 @@
"""Simulate a pipeline run on disk to exercise the Backlot live board.
Drives a fake production through the REAL contract — init_project,
in_progress checkpoints, gated awaiting_human states, tool events,
progressively-written artifacts — so the board can be watched updating live.
Also useful as a demo driver.
python scripts/backlot_simulate_run.py [--project backlot-demo-run]
[--fast] [--cleanup]
--fast compresses waits to ~0.3s (for automated verification)
--cleanup removes the project directory at the end
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lib.checkpoint import PROJECTS_DIR, init_project, write_checkpoint
from lib.events import emit_event
SCENES = [
("sc1", "Opening — a lighthouse at dusk", 0, 4, "The coast holds its breath."),
("sc2", "The beam sweeps the water", 4, 9, "Every night, the same promise."),
("sc3", "A storm builds offshore", 9, 15, "Until the night the light went out."),
("sc4", "The keeper climbs the stairs", 15, 21, "Someone still has to climb."),
]
def artifacts_for(project_id: str) -> dict:
script = {
"version": "1.0",
"title": "The Last Lighthouse",
"total_duration_seconds": 21,
"sections": [
{"id": f"s{i+1}", "label": desc.split("")[0].strip(), "text": narration,
"start_seconds": s0, "end_seconds": s1}
for i, (sid, desc, s0, s1, narration) in enumerate(SCENES)
],
}
scene_plan = {
"version": "1.0",
"scenes": [
{"id": sid, "type": "generated", "description": desc,
"start_seconds": s0, "end_seconds": s1,
"script_section_id": f"s{i+1}",
"hero_moment": sid == "sc3",
"required_assets": [{"type": "image", "description": desc, "source": "generate"}]}
for i, (sid, desc, s0, s1, _n) in enumerate(SCENES)
],
}
return {"script": script, "scene_plan": scene_plan}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--project", default="backlot-demo-run")
parser.add_argument("--fast", action="store_true")
parser.add_argument("--cleanup", action="store_true")
args = parser.parse_args()
wait = 0.3 if args.fast else 2.5
pid = args.project
pdir = PROJECTS_DIR / pid
if pdir.exists():
shutil.rmtree(pdir)
print(f"[sim] init_project {pid}")
init_project(pid, title="The Last Lighthouse", pipeline_type="cinematic",
style_playbook="clean-professional")
art = artifacts_for(pid)
def save_artifact(name: str, data: dict) -> None:
path = pdir / "artifacts" / f"{name}.json"
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def cp(stage: str, status: str, artifacts: dict, **kw) -> None:
write_checkpoint(PROJECTS_DIR, pid, stage, status, artifacts,
pipeline_type="cinematic", **kw)
print(f"[sim] checkpoint {stage} -> {status}")
time.sleep(wait)
# research auto-proceeds (schema-valid fixture from the contract tests)
cp("research", "in_progress", {})
from tests.contracts.test_phase0_contracts import sample_artifact
brief = sample_artifact("research_brief")
brief["topic"] = "The Last Lighthouse"
cp("research", "completed", {"research_brief": brief})
# script gates: awaiting_human -> approved
cp("script", "in_progress", {})
save_artifact("script", art["script"])
cp("script", "awaiting_human", {"script": art["script"]},
review={"round": 1, "decision": "pass", "critical": 0, "suggestions": 1,
"nitpicks": 0, "summary": "Hook is strong; tightened s3."})
time.sleep(wait) # "user reads the script on the board"
cp("script", "completed", {"script": art["script"]}, human_approved=True)
# scene_plan gates too
cp("scene_plan", "in_progress", {})
save_artifact("scene_plan", art["scene_plan"])
cp("scene_plan", "awaiting_human", {"scene_plan": art["scene_plan"]})
time.sleep(wait)
cp("scene_plan", "completed", {"scene_plan": art["scene_plan"]}, human_approved=True)
# assets: per-scene tool events + growing manifest + partial progress
cp("assets", "in_progress", {})
manifest = {"version": "1.0", "assets": [], "total_cost_usd": 0.0}
done_ids = []
from PIL import Image, ImageDraw
palette = [(24, 32, 48), (40, 30, 60), (60, 24, 24), (20, 48, 40)]
for i, (sid, desc, _s0, _s1, _n) in enumerate(SCENES):
emit_event(pdir, {"tool": "flux_image", "event": "start", "scene_id": sid})
print(f"[sim] generating {sid}")
time.sleep(wait * 1.5)
rel = f"assets/images/{sid}.png"
img = Image.new("RGB", (640, 360), palette[i % 4])
draw = ImageDraw.Draw(img)
draw.text((20, 160), f"{sid}{desc[:40]}", fill=(230, 225, 210))
img.save(pdir / rel)
emit_event(pdir, {"tool": "flux_image", "event": "finish", "scene_id": sid,
"success": True, "cost_usd": 0.05, "duration_s": wait * 1.5,
"output_path": rel})
manifest["assets"].append({
"id": f"img_{sid}", "type": "image", "path": rel, "scene_id": sid,
"source_tool": "flux_image", "model": "flux-sim", "cost_usd": 0.05,
"prompt": desc, "quality_score": 0.88,
})
manifest["total_cost_usd"] = round(manifest["total_cost_usd"] + 0.05, 2)
save_artifact("asset_manifest", manifest)
done_ids.append(sid)
write_checkpoint(PROJECTS_DIR, pid, "assets", "in_progress", {},
pipeline_type="cinematic",
metadata={"partial_progress": {"completed_scene_ids": done_ids}},
cost_snapshot={"total_spent_usd": manifest["total_cost_usd"],
"total_reserved_usd": 0.0,
"budget_remaining_usd": 5 - manifest["total_cost_usd"]})
# assets gate (the storyboard review)
cp("assets", "awaiting_human", {"asset_manifest": manifest},
cost_snapshot={"total_spent_usd": manifest["total_cost_usd"],
"total_reserved_usd": 0.0,
"budget_remaining_usd": 5 - manifest["total_cost_usd"]})
time.sleep(wait)
cp("assets", "completed", {"asset_manifest": manifest}, human_approved=True)
print(f"[sim] done — board at http://127.0.0.1:4750/p/{pid}")
if args.cleanup:
shutil.rmtree(pdir)
print("[sim] cleaned up")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,234 @@
"""Deterministic visual eval for Backlot.
Stages the fictional Backlot projects, captures canonical browser screenshots,
optionally compares them to goldens, and can run a small Playwright interaction
smoke against the staged board.
Examples:
python scripts/backlot_visual_eval.py
python scripts/backlot_visual_eval.py --bless
python scripts/backlot_visual_eval.py --interactions
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Any
from PIL import Image, ImageChops
REPO_ROOT = Path(__file__).resolve().parent.parent
STAGE_DIR = REPO_ROOT / ".backlot" / "screenshot-stage"
GOLDENS_DIR = REPO_ROOT / "internal" / "evals" / "goldens"
CAPTURE_ROOT = REPO_ROOT / "internal" / "evals" / "captures"
PORT = 4791
SHOTS = [
("library", "/?static=1", 1560, 500, 4200, [
(1370, 20, 1510, 62), # live/idle badge
(90, 106, 422, 380), # card border/status animation variance
(440, 106, 772, 380),
(790, 106, 1122, 380),
(1140, 106, 1472, 380),
]),
("board-live", "/p/signal-in-the-static?static=1", 1560, 1150, 4200, []),
("script-gate", "/p/the-slow-orchard?static=1", 1560, 760, 3200, []),
("storyboard", "/p/the-last-lighthouse?static=1", 1560, 1500, 4200, []),
]
def compare_images(
expected_path: Path,
actual_path: Path,
diff_path: Path,
*,
threshold: float = 0.015,
masks: list[tuple[int, int, int, int]] | None = None,
) -> dict[str, Any]:
"""Compare screenshots by changed-pixel ratio and write a red diff image."""
expected = Image.open(expected_path).convert("RGB")
actual = Image.open(actual_path).convert("RGB")
if expected.size != actual.size:
diff_path.parent.mkdir(parents=True, exist_ok=True)
actual.save(diff_path)
return {"passed": False, "changed_ratio": 1.0, "reason": f"size {expected.size} != {actual.size}"}
masks = masks or []
for box in masks:
patch = expected.crop(box)
actual.paste(patch, box)
delta = ImageChops.difference(expected, actual)
changed = 0
pixels = delta.load()
width, height = delta.size
diff = Image.new("RGB", delta.size, (0, 0, 0))
diff_px = diff.load()
for y in range(height):
for x in range(width):
if max(pixels[x, y]) > 8:
changed += 1
diff_px[x, y] = (255, 40, 40)
else:
diff_px[x, y] = actual.getpixel((x, y))
ratio = changed / float(width * height)
diff_path.parent.mkdir(parents=True, exist_ok=True)
diff.save(diff_path)
return {"passed": ratio <= threshold, "changed_ratio": round(ratio, 6), "threshold": threshold}
def run_stage() -> None:
subprocess.run(
[sys.executable, "scripts/backlot_screenshot_stage.py", "--stage-only"],
cwd=REPO_ROOT,
check=True,
timeout=180,
)
def start_server() -> subprocess.Popen:
env = dict(os.environ)
env["OPENMONTAGE_PROJECTS_DIR"] = str(STAGE_DIR)
server = subprocess.Popen(
[sys.executable, "-m", "backlot", "serve", "--port", str(PORT)],
cwd=REPO_ROOT,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.time() + 20
while time.time() < deadline:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/api/health", timeout=1):
return server
except Exception:
time.sleep(0.3)
server.terminate()
raise RuntimeError("Backlot server did not become healthy")
def capture_screenshot(url: str, output: Path, width: int, height: int, wait_ms: int) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"npx",
"playwright",
"screenshot",
"--viewport-size",
f"{width},{height}",
"--wait-for-timeout",
str(wait_ms),
url,
str(output),
],
cwd=REPO_ROOT,
check=True,
timeout=120,
shell=(os.name == "nt"),
)
def capture_shots(capture_dir: Path) -> list[dict[str, Any]]:
results = []
for name, path, width, height, wait_ms, _masks in SHOTS:
out = capture_dir / f"{name}.png"
capture_screenshot(f"http://127.0.0.1:{PORT}{path}", out, width, height, wait_ms)
results.append({"name": name, "path": out})
return results
def compare_or_bless(capture_dir: Path, *, bless: bool, threshold: float) -> list[dict[str, Any]]:
GOLDENS_DIR.mkdir(parents=True, exist_ok=True)
report = []
for name, _path, _width, _height, _wait_ms, masks in SHOTS:
actual = capture_dir / f"{name}.png"
golden = GOLDENS_DIR / f"{name}.png"
if bless or not golden.exists():
shutil.copyfile(actual, golden)
report.append({"name": name, "status": "blessed", "golden": str(golden)})
continue
diff = capture_dir / "diffs" / f"{name}.png"
result = compare_images(golden, actual, diff, threshold=threshold, masks=masks)
result.update({"name": name, "diff": str(diff)})
report.append(result)
return report
def run_interactions(capture_dir: Path) -> dict[str, Any]:
"""Run browser interaction smoke through Python Playwright."""
from playwright.sync_api import sync_playwright
screenshot = capture_dir / "interaction-smoke.png"
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1560, "height": 1000})
page.goto(f"http://127.0.0.1:{PORT}/p/the-last-lighthouse?static=1")
page.wait_for_selector(".stage")
page.locator(".stage").first.click()
page.wait_for_selector(".drawer")
drawer_text = page.locator(".drawer").inner_text()
if "research" not in drawer_text:
raise RuntimeError("stage drawer did not open")
page.locator(".script-card").first.click()
page.wait_for_selector(".modal-bg.open")
page.keyboard.press("Escape")
page.wait_for_function("() => !document.querySelector('.modal-bg')?.classList.contains('open')")
if page.locator(".takes").count() < 1:
raise RuntimeError("takes drawer not present on staged takes scene")
replay_button = page.locator(".rp-btn", has_text="REPLAY RUN")
if replay_button.count():
replay_button.first.click()
page.wait_for_selector('input[type="range"]')
page.locator('input[type="range"]').fill("500")
page.screenshot(path=str(screenshot), full_page=True)
browser.close()
return {"status": "passed", "screenshot": str(capture_dir / "interaction-smoke.png")}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bless", action="store_true", help="Write current captures as goldens")
parser.add_argument("--no-stage", action="store_true", help="Reuse existing .backlot/screenshot-stage")
parser.add_argument("--interactions", action="store_true", help="Run Playwright interaction smoke")
parser.add_argument("--threshold", type=float, default=0.015)
parser.add_argument("--out-dir", type=Path, default=None)
args = parser.parse_args(argv)
if not args.no_stage:
run_stage()
stamp = datetime.now().strftime("visual-%Y%m%d-%H%M%S")
capture_dir = args.out_dir or (CAPTURE_ROOT / stamp)
capture_dir.mkdir(parents=True, exist_ok=True)
server = start_server()
try:
capture_shots(capture_dir)
report = compare_or_bless(capture_dir, bless=args.bless, threshold=args.threshold)
interaction_report = run_interactions(capture_dir) if args.interactions else None
finally:
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
passed = all(item.get("passed", item.get("status") == "blessed") for item in report)
payload = {"capture_dir": str(capture_dir), "shots": report, "interactions": interaction_report}
report_path = capture_dir / "report.json"
report_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(json.dumps(payload, indent=2))
return 0 if passed else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,195 @@
"""Capture Backlot board screenshots whenever watched project state changes.
This is the Half-B dogfood watcher from internal/evals/BACKLOT_EVAL_PLAN.md.
It polls the Backlot API, fingerprints board-relevant state, and captures the
library plus the changed project board through Playwright.
Example:
python scripts/backlot_watch_captures.py --projects why-cities-glow rain-on-glass
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_BASE_URL = "http://127.0.0.1:4750"
DEFAULT_CAPTURE_ROOT = REPO_ROOT / "internal" / "evals" / "captures"
def capture_slug(project_id: str, stage: str | None, status: str | None) -> str:
"""Stable, filesystem-safe screenshot name stem."""
raw = "-".join(part for part in (project_id, stage or "unknown", status or "unknown") if part)
raw = raw.replace("\\", "-").replace("/", "-").replace("..", "")
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-")
slug = re.sub(r"-{2,}", "-", slug)
return slug or "capture"
def state_fingerprint(state: dict[str, Any]) -> str:
"""Hashable representation of board-visible state.
Intentionally ignores mtime-ish noise such as last_activity while keeping
the pieces that should trigger a capture: stage transitions, generating
flags, scene visual changes, costs, renders, and event count/tail.
"""
scenes = []
storyboard = state.get("storyboard") or {}
for card in storyboard.get("scenes") or []:
visual = card.get("visual") or {}
scenes.append({
"id": card.get("id"),
"generating": bool(card.get("generating")),
"generating_tool": card.get("generating_tool"),
"visual": {
"path": visual.get("path"),
"exists": visual.get("exists"),
"type": visual.get("type"),
},
"takes": [take.get("path") for take in (card.get("takes") or [])],
"audio": [asset.get("path") for asset in (card.get("audio") or [])],
})
media = state.get("media") or {}
events = state.get("events") or []
visible = {
"stages": [
{
"name": stage.get("name"),
"status": stage.get("status"),
"gate_skipped": stage.get("gate_skipped"),
"versions": stage.get("versions"),
"partial_progress": stage.get("partial_progress"),
}
for stage in state.get("stages") or []
],
"scenes": scenes,
"cost": state.get("cost"),
"renders": [r.get("path") for r in media.get("renders") or []],
"snapshots": [s.get("path") for s in media.get("snapshots") or []],
"event_count": len(events),
"event_tail": events[-3:],
}
return json.dumps(visible, sort_keys=True, default=str, separators=(",", ":"))
def active_stage(state: dict[str, Any]) -> tuple[str | None, str | None]:
for stage in state.get("stages") or []:
if stage.get("status") in {"in_progress", "awaiting_human", "failed", "blocked"}:
return stage.get("name"), stage.get("status")
for stage in reversed(state.get("stages") or []):
if stage.get("status") == "completed":
return stage.get("name"), stage.get("status")
return None, None
def fetch_json(base_url: str, path: str) -> dict[str, Any] | list[Any]:
with urllib.request.urlopen(f"{base_url.rstrip('/')}{path}", timeout=10) as response:
return json.loads(response.read().decode("utf-8"))
def capture_url(url: str, output: Path, *, width: int = 1560, height: int = 1150, wait_ms: int = 1200) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"npx",
"playwright",
"screenshot",
"--viewport-size",
f"{width},{height}",
"--wait-for-timeout",
str(wait_ms),
url,
str(output),
],
cwd=REPO_ROOT,
check=True,
timeout=120,
shell=(os.name == "nt"),
)
def capture_project(base_url: str, capture_dir: Path, project_id: str, seq: int, state: dict[str, Any]) -> None:
stage, status = active_stage(state)
stem = f"{seq:03d}-{capture_slug(project_id, stage, status)}"
capture_url(f"{base_url.rstrip('/')}/?static=1", capture_dir / "library" / f"{stem}.png", height=620)
capture_url(
f"{base_url.rstrip('/')}/p/{project_id}?static=1",
capture_dir / project_id / f"{stem}.png",
)
def watch(
projects: list[str],
*,
base_url: str,
capture_dir: Path,
interval_s: float,
once: bool = False,
no_screenshots: bool = False,
) -> int:
fingerprints: dict[str, str] = {}
seq = 0
capture_dir.mkdir(parents=True, exist_ok=True)
print(f"[watch] base={base_url} captures={capture_dir}")
while True:
changed = False
for project_id in projects:
try:
state = fetch_json(base_url, f"/api/project/{project_id}/state")
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
print(f"[watch] {project_id}: state fetch failed: {exc}", file=sys.stderr)
continue
fp = state_fingerprint(state)
if fingerprints.get(project_id) == fp:
continue
fingerprints[project_id] = fp
changed = True
seq += 1
stage, status = active_stage(state)
print(f"[watch] change {project_id}: {stage or 'unknown'} -> {status or 'unknown'}")
if not no_screenshots:
capture_project(base_url, capture_dir, project_id, seq, state)
if once:
return 0
if not changed:
time.sleep(interval_s)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--projects", nargs="+", required=True, help="Project ids to watch")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--interval", type=float, default=20.0, help="Polling interval in seconds")
parser.add_argument("--out-dir", type=Path, default=None)
parser.add_argument("--once", action="store_true", help="Poll once and exit")
parser.add_argument("--no-screenshots", action="store_true", help="Exercise polling without Playwright")
args = parser.parse_args(argv)
out_dir = args.out_dir
if out_dir is None:
stamp = datetime.now().strftime("dogfood-%Y%m%d-%H%M%S")
out_dir = DEFAULT_CAPTURE_ROOT / stamp
return watch(
args.projects,
base_url=args.base_url,
capture_dir=out_dir,
interval_s=args.interval,
once=args.once,
no_screenshots=args.no_screenshots,
)
if __name__ == "__main__":
raise SystemExit(main())