mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-26 01:52:30 +08:00
backlot: README showcase + screenshot pipeline, unlisted-stage placement fix
- README 'Watch It Happen' section with four staged screenshots (fictional demo productions, generated placeholder art — no private project content) + governance section notes enforced approval gates - scripts/backlot_screenshot_stage.py: stages 4 demo projects in 4 pipeline states against OPENMONTAGE_PROJECTS_DIR and captures the shots with Playwright — reproducible README imagery - lib/paths.py: OPENMONTAGE_PROJECTS_DIR env override (checkpoints, events, and the board all follow one root); backlot/state.py now imports it (was still defining its own copy — the exact drift the review warned of) - undeclared-stage checkpoints (e.g. a legacy 'idea' run under a cinematic manifest) now slot into their canonical rail position instead of dangling after publish, drawn dashed + italic with an 'unlisted' note
This commit is contained in:
31
README.md
31
README.md
@@ -111,6 +111,36 @@ Works with **Claude Code, Cursor, Copilot, Windsurf, Codex** — any AI coding a
|
||||
|
||||
---
|
||||
|
||||
## Watch It Happen — The Backlot Living Storyboard
|
||||
|
||||
Chat tells you what the agent *said*. **Backlot shows you what the production is actually doing** — a local board that fills itself in as the pipeline runs. Stages light up, the script lands as a screenplay page, scene cards shimmer while assets generate, and every provider decision and dollar spent is on the wall.
|
||||
|
||||
When a production starts, the agent opens it for you automatically. No setup, no reporting — the board derives everything from the project files the pipeline already writes.
|
||||
|
||||
<p align="center"><img src="docs/images/backlot/board-live.png" alt="Backlot live board — assets generating" width="920"></p>
|
||||
|
||||
**The storyboard is now a real approval gate.** Asset generation pauses on a scene-by-scene contact sheet — takes, prompts, per-asset cost, quality scores — so you approve the visuals *before* the render, not after it's too late:
|
||||
|
||||
<p align="center"><img src="docs/images/backlot/storyboard.png" alt="Backlot storyboard — filmstrip with takes and renders" width="920"></p>
|
||||
|
||||
Creative gates hold until you answer. The board shows what's waiting and why; you reply in chat:
|
||||
|
||||
<p align="center"><img src="docs/images/backlot/script-gate.png" alt="Backlot script gate — awaiting approval" width="920"></p>
|
||||
|
||||
Every production on your machine, live-first, in the library:
|
||||
|
||||
<p align="center"><img src="docs/images/backlot/library.png" alt="Backlot library" width="920"></p>
|
||||
|
||||
```bash
|
||||
python -m backlot open # the library — every project on disk
|
||||
python -m backlot open <project-id> # one production's live board
|
||||
python scripts/backlot_simulate_run.py # no production yet? watch a simulated one live
|
||||
```
|
||||
|
||||
And when a run is done, hit **▶ REPLAY RUN** — the whole production replays from its timestamps, scrubbable end to end. See [`backlot/README.md`](backlot/README.md) for how it works.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
@@ -568,6 +598,7 @@ OpenMontage treats video production like real engineering — with quality gates
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- **Human approval gates are enforced, not suggested** — proposal, script, scene plan, generated assets, and publish all pause for your sign-off. The checkpoint writer rejects a "completed" gated stage without recorded approval, and every superseded checkpoint is archived so the audit trail (including gate transitions) survives revisions. Review happens visually on the [Backlot board](#watch-it-happen--the-backlot-living-storyboard).
|
||||
- **Pre-compose validation** — blocks render if the delivery promise is violated (e.g. "motion-led" video with 80% still images), slideshow risk score is critical, or renderer family is missing. Catches broken plans before wasting GPU time.
|
||||
- **Post-render self-review** — after every render, the runtime runs ffprobe validation, extracts frames at 4 positions to check for black frames and broken overlays, analyzes audio levels for silence and clipping, verifies the delivery promise was honored, and checks subtitle presence. If the review fails, the video is not presented.
|
||||
- **Slideshow risk scoring** — 6-dimension analysis (repetition, decorative visuals, weak motion, shot intent, typography overreliance, unsupported cinematic claims) prevents "animated PowerPoint" outputs.
|
||||
|
||||
@@ -13,9 +13,7 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.events import read_events
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PROJECTS_DIR = REPO_ROOT / "projects"
|
||||
from lib.paths import PROJECTS_DIR, REPO_ROOT # single source of truth (env-overridable)
|
||||
|
||||
MEDIA_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
MEDIA_VIDEO_EXT = {".mp4", ".webm", ".mov"}
|
||||
@@ -167,22 +165,36 @@ def _build_stage_rail(
|
||||
rail.append(entry)
|
||||
|
||||
# Checkpoints for stages the manifest doesn't declare (legacy runs,
|
||||
# pipeline mismatch) still deserve a slot at the end.
|
||||
# pipeline mismatch) still deserve a slot — at their canonical position
|
||||
# in the pipeline, not dangling after publish ("idea" belongs up front).
|
||||
canon = {name: i for i, name in enumerate(FALLBACK_STAGES)}
|
||||
for name, cp in checkpoints.items():
|
||||
if name not in manifest_stage_names:
|
||||
rail.append({
|
||||
"name": name,
|
||||
"gated": False,
|
||||
"status": cp.get("status") or "unknown",
|
||||
"timestamp": cp.get("timestamp"),
|
||||
"review": cp.get("review"),
|
||||
"cost_snapshot": cp.get("cost_snapshot"),
|
||||
"error": cp.get("error"),
|
||||
"human_approved": cp.get("human_approved"),
|
||||
"partial_progress": None,
|
||||
"versions": 1 + len(history.get(name, [])),
|
||||
"undeclared": True,
|
||||
})
|
||||
if name in manifest_stage_names:
|
||||
continue
|
||||
entry = {
|
||||
"name": name,
|
||||
"gated": False,
|
||||
"status": cp.get("status") or "unknown",
|
||||
"timestamp": cp.get("timestamp"),
|
||||
"review": cp.get("review"),
|
||||
"cost_snapshot": cp.get("cost_snapshot"),
|
||||
"error": cp.get("error"),
|
||||
"human_approved": cp.get("human_approved"),
|
||||
"partial_progress": None,
|
||||
"versions": 1 + len(history.get(name, [])),
|
||||
"undeclared": True,
|
||||
}
|
||||
pos = canon.get(name)
|
||||
if pos is None:
|
||||
rail.append(entry) # truly unknown name — end of rail
|
||||
continue
|
||||
insert_at = len(rail)
|
||||
for i, existing in enumerate(rail):
|
||||
existing_pos = canon.get(existing["name"])
|
||||
if existing_pos is not None and existing_pos > pos:
|
||||
insert_at = i
|
||||
break
|
||||
rail.insert(insert_at, entry)
|
||||
return rail
|
||||
|
||||
|
||||
|
||||
@@ -520,3 +520,7 @@ body:not(.first) .slate, body:not(.first) .rail .stage,
|
||||
body:not(.first) .script-card, body:not(.first) .notice,
|
||||
body:not(.first) aside .panel, body:not(.first) .scene-card,
|
||||
body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
|
||||
|
||||
/* stages that ran but aren't declared by the pipeline manifest */
|
||||
.stage.undeclared .node { border-style: dashed; opacity: .85; }
|
||||
.stage.undeclared .name { font-style: italic; }
|
||||
|
||||
@@ -102,13 +102,15 @@ function renderRail(s) {
|
||||
const icon = STAGE_ICONS[st.status] || String(pendingIndex);
|
||||
if (!STAGE_ICONS[st.status]) pendingIndex += 1;
|
||||
const node = el("div", {
|
||||
class: `stage ${cls}${selectedStage === st.name ? " selected" : ""}`,
|
||||
class: `stage ${cls}${selectedStage === st.name ? " selected" : ""}${st.undeclared ? " undeclared" : ""}`,
|
||||
title: st.undeclared ? `"${st.name}" ran but isn't declared by this pipeline's manifest` : null,
|
||||
onclick: () => toggleDrawer(st.name),
|
||||
},
|
||||
el("span", { class: "line" }),
|
||||
el("span", { class: "node" }, icon),
|
||||
el("span", { class: "name" }, st.name),
|
||||
el("span", { class: "sub", style: "white-space:pre-line" }, stageSub(st)),
|
||||
el("span", { class: "sub", style: "white-space:pre-line" },
|
||||
st.undeclared ? `${stageSub(st)}\nunlisted`.trim() : stageSub(st)),
|
||||
);
|
||||
rail.append(node);
|
||||
}
|
||||
|
||||
BIN
docs/images/backlot/board-live.png
Normal file
BIN
docs/images/backlot/board-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 787 KiB |
BIN
docs/images/backlot/library.png
Normal file
BIN
docs/images/backlot/library.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
BIN
docs/images/backlot/script-gate.png
Normal file
BIN
docs/images/backlot/script-gate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
BIN
docs/images/backlot/storyboard.png
Normal file
BIN
docs/images/backlot/storyboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1020 KiB |
@@ -7,7 +7,11 @@ board watches it. Define it once.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PROJECTS_DIR = REPO_ROOT / "projects"
|
||||
|
||||
# Overridable for staging/screenshots/tests. Everything — checkpoint writes,
|
||||
# event attribution, the Backlot board — follows the same root.
|
||||
PROJECTS_DIR = Path(os.environ.get("OPENMONTAGE_PROJECTS_DIR") or (REPO_ROOT / "projects"))
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user