mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-24 17:10:27 +08:00
Merge branch 'main' of https://github.com/calesthio/OpenMontage into codex/kling-official-phase-1
This commit is contained in:
121
scripts/atelier_snapshots.py
Normal file
121
scripts/atelier_snapshots.py
Normal 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())
|
||||
358
scripts/backlot_screenshot_stage.py
Normal file
358
scripts/backlot_screenshot_stage.py
Normal 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()
|
||||
161
scripts/backlot_simulate_run.py
Normal file
161
scripts/backlot_simulate_run.py
Normal 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())
|
||||
234
scripts/backlot_visual_eval.py
Normal file
234
scripts/backlot_visual_eval.py
Normal 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())
|
||||
195
scripts/backlot_watch_captures.py
Normal file
195
scripts/backlot_watch_captures.py
Normal 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())
|
||||
Reference in New Issue
Block a user