From 4b4d7b1e993cd101d47918e7534f64eb19003e98 Mon Sep 17 00:00:00 2001 From: calesthio Date: Wed, 1 Jul 2026 23:47:45 -0700 Subject: [PATCH] backlot phase 2: board UI (library, live board, filmstrip, script modal, drawers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vanilla ESM frontend on the mockup design system (no build step): library contact-sheet with mini rails + live badges; project board with slate header, cost meter, clickable stage rail, stage drawers (artifact view, review findings, gate-skipped chip, versions), screenplay card + full-script modal, decisions + activity rails, storyboard filmstrip (duration-width cards, shimmer/spec/missing/text-card states, takes, narration + waveform playback), render player with versions, degraded found-media view - /thumb endpoint: cached downscaled JPEGs (Pillow) + ffmpeg poster-frame extraction for videos — the library was loading 73 full-res PNGs - library summaries cached, invalidated by the watcher - asset path resolution tolerates project-relative, repo-relative and absolute manifest paths (real-world variance found in why-do-we-dream) - ?static=1 disables SSE (screenshots/static export) - verified in browser against signal-from-tomorrow, why-do-we-dream, done-beats-perfect and the 73-project library --- .claude/launch.json | 17 ++ .gitignore | 3 + backlot/server.py | 99 +++++++- backlot/state.py | 62 ++++- backlot/ui/board.css | 516 ++++++++++++++++++++++++++++++++++++++++ backlot/ui/board.html | 15 ++ backlot/ui/board.js | 531 ++++++++++++++++++++++++++++++++++++++++++ backlot/ui/index.html | 26 +++ backlot/ui/lib.js | 102 ++++++++ backlot/ui/library.js | 63 +++++ 10 files changed, 1420 insertions(+), 14 deletions(-) create mode 100644 .claude/launch.json create mode 100644 backlot/ui/board.css create mode 100644 backlot/ui/board.html create mode 100644 backlot/ui/board.js create mode 100644 backlot/ui/index.html create mode 100644 backlot/ui/lib.js create mode 100644 backlot/ui/library.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..0bb630c2 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "backlot", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "backlot", "serve", "--port", "4750"], + "port": 4750 + }, + { + "name": "mockups", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "4788", "--bind", "127.0.0.1"], + "port": 4788 + } + ] +} diff --git a/.gitignore b/.gitignore index 7a7c27a2..52051e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ remotion-composer/public/demo-props/caption-burn-* venv/ .venv/ + +# Backlot local cache (thumbnails) +.backlot/ diff --git a/backlot/server.py b/backlot/server.py index e24e5b91..d140cbb7 100644 --- a/backlot/server.py +++ b/backlot/server.py @@ -17,9 +17,11 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles -from backlot.state import PROJECTS_DIR, REPO_ROOT, list_projects, load_board_state +from backlot.state import PROJECTS_DIR, REPO_ROOT, list_projects, load_board_state, summarize_project UI_DIR = Path(__file__).resolve().parent / "ui" +THUMB_CACHE_DIR = REPO_ROOT / ".backlot" / "thumbs" +THUMB_WIDTHS = (320, 640, 960) # Paths inside a project whose changes are pure noise for the board. _IGNORE_PARTS = {"node_modules", ".git", "__pycache__", ".cache"} @@ -51,6 +53,40 @@ class ChangeHub: hub = ChangeHub() +# Library summaries are expensive to derive (full state parse per project); +# cache per project and invalidate from the watcher. +_summary_cache: dict[str, dict] = {} + + +def _invalidate_summary(project_id: str) -> None: + _summary_cache.pop(project_id, None) + + +def _cached_summaries() -> list[dict]: + if not PROJECTS_DIR.is_dir(): + return [] + summaries = [] + for entry in sorted(PROJECTS_DIR.iterdir()): + if not entry.is_dir() or entry.name.startswith(("_", ".")): + continue + cached = _summary_cache.get(entry.name) + if cached is None: + try: + cached = summarize_project(entry) + except Exception: + cached = { + "project_id": entry.name, "title": entry.name, + "pipeline_type": "unknown", "has_pipeline_state": False, + "poster": None, "live": False, "last_activity": 0, + "active_stage": None, "awaiting_human": False, + "stage_states": [], "completed_count": 0, + "render_count": 0, "scene_count": 0, "error": "unreadable", + } + _summary_cache[entry.name] = cached + summaries.append(cached) + summaries.sort(key=lambda s: (not s["live"], -(s["last_activity"] or 0))) + return summaries + def _project_of_change(path_str: str) -> Optional[str]: """Map a changed filesystem path to a project id (None = irrelevant).""" @@ -80,6 +116,7 @@ async def _watch_projects() -> None: if pid: touched.add(pid) for pid in touched: + _invalidate_summary(pid) hub.publish(pid) @@ -104,7 +141,7 @@ def create_app() -> FastAPI: @app.get("/api/projects") async def projects() -> list: - return await asyncio.to_thread(list_projects) + return await asyncio.to_thread(_cached_summaries) @app.get("/api/project/{project_id}/state") async def project_state(project_id: str) -> dict: @@ -171,6 +208,24 @@ def create_app() -> FastAPI: "X-Accel-Buffering": "no", }) + # ---- Thumbnails (downscaled, cached on disk) ------------------------ + + @app.get("/thumb/{project_id}/{file_path:path}") + async def thumb(project_id: str, file_path: str, w: int = 640) -> FileResponse: + project_dir = _safe_project_dir(project_id) + target = (project_dir / file_path).resolve() + try: + target.relative_to(project_dir.resolve()) + except ValueError: + raise HTTPException(status_code=403, detail="path escapes project") + if not target.is_file(): + raise HTTPException(status_code=404, detail="media not found") + width = min(THUMB_WIDTHS, key=lambda x: abs(x - w)) + cached = await asyncio.to_thread(_thumbnail_for, target, width) + if cached is None: + return FileResponse(target) # not an image we can thumb — serve as-is + return FileResponse(cached, media_type="image/jpeg") + # ---- Media (range requests handled by FileResponse) --------------- @app.get("/media/{project_id}/{file_path:path}") @@ -214,4 +269,44 @@ def _sse(payload: dict) -> str: return f"data: {json.dumps(payload)}\n\n" +def _thumbnail_for(source: Path, width: int) -> Optional[Path]: + """Downscale an image (or extract a video poster frame) to a cached JPEG.""" + suffix = source.suffix.lower() + is_image = suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"} + is_video = suffix in {".mp4", ".webm", ".mov"} + if not (is_image or is_video): + return None + try: + import hashlib + stat = source.stat() + key = hashlib.sha1( + f"{source}|{stat.st_mtime_ns}|{stat.st_size}|{width}".encode() + ).hexdigest()[:20] + cached = THUMB_CACHE_DIR / f"{key}.jpg" + if cached.is_file(): + return cached + THUMB_CACHE_DIR.mkdir(parents=True, exist_ok=True) + tmp = cached.with_suffix(".tmp.jpg") + if is_video: + import subprocess + result = subprocess.run( + ["ffmpeg", "-y", "-loglevel", "error", "-ss", "1.5", + "-i", str(source), "-frames:v", "1", + "-vf", f"scale={width}:-2", str(tmp)], + capture_output=True, timeout=30, + ) + if result.returncode != 0 or not tmp.is_file(): + return None + else: + from PIL import Image + with Image.open(source) as img: + img = img.convert("RGB") + img.thumbnail((width, width * 3)) + img.save(tmp, "JPEG", quality=82) + tmp.replace(cached) + return cached + except Exception: + return None + + app = create_app() diff --git a/backlot/state.py b/backlot/state.py index 3c93dcb0..50e765ea 100644 --- a/backlot/state.py +++ b/backlot/state.py @@ -47,8 +47,8 @@ def _read_json(path: Path) -> Optional[dict]: def _rel(project_dir: Path, path: Path) -> str: """Project-relative POSIX path for media URLs.""" try: - return path.relative_to(project_dir).as_posix() - except ValueError: + return path.resolve().relative_to(Path(project_dir).resolve()).as_posix() + except (ValueError, OSError): return path.name @@ -226,13 +226,40 @@ def _collect_artifacts(project_dir: Path, checkpoints: dict[str, dict]) -> dict[ # Storyboard join # --------------------------------------------------------------------------- +def _resolve_asset_path(project_dir: Path, raw_path: str) -> Optional[Path]: + """Manifest paths appear in several real-world flavors — try them all. + + Observed on disk: project-relative ("assets/images/x.png"), + repo-relative ("projects//assets/images/x.png"), and absolute. + """ + if not raw_path: + return None + p = Path(raw_path) + candidates = [] + if p.is_absolute(): + candidates.append(p) + else: + candidates.append(project_dir / raw_path) + candidates.append(REPO_ROOT / raw_path) + # repo-relative with the project prefix repeated + parts = p.parts + if len(parts) > 2 and parts[0] == "projects": + candidates.append(project_dir.parent / Path(*parts[1:])) + for c in candidates: + try: + if c.is_file(): + return c + except OSError: + continue + return None + + def _asset_entry(project_dir: Path, asset: dict) -> dict: """Normalize a manifest asset entry + resolve file existence.""" raw_path = asset.get("path") or "" - file_path = Path(raw_path) - if not file_path.is_absolute(): - file_path = project_dir / raw_path - exists = file_path.is_file() + resolved = _resolve_asset_path(project_dir, raw_path) + file_path = resolved if resolved is not None else (project_dir / raw_path) + exists = resolved is not None kind = asset.get("type") or "" if not kind and file_path.suffix: ext = file_path.suffix.lower() @@ -400,7 +427,8 @@ def _scan_media(project_dir: Path) -> dict[str, list[dict]]: def _find_poster(project_dir: Path, state: dict) -> Optional[str]: - """Best poster image for the library card.""" + """Best poster for the library card (image path, or a video path — + the /thumb endpoint extracts a frame from videos).""" board = state.get("storyboard") or {} for card in board.get("scenes", []): visual = card.get("visual") @@ -408,11 +436,21 @@ def _find_poster(project_dir: Path, state: dict) -> Optional[str]: return visual["path"] for snap in (state.get("media") or {}).get("snapshots", []): return snap["path"] - images_dir = project_dir / "assets" / "images" - if images_dir.is_dir(): - for f in sorted(images_dir.iterdir()): - if f.suffix.lower() in MEDIA_IMAGE_EXT: - return _rel(project_dir, f) + # Common image homes, in order of how representative they usually are. + for rel_dir in ("assets/images", "assets/frames", "exports", "assets", "."): + d = (project_dir / rel_dir) if rel_dir != "." else project_dir + if not d.is_dir(): + continue + try: + for f in sorted(d.iterdir()): + if f.is_file() and f.suffix.lower() in MEDIA_IMAGE_EXT: + return _rel(project_dir, f) + except OSError: + continue + # Last resort: the newest render — /thumb extracts a poster frame. + renders = (state.get("media") or {}).get("renders", []) + if renders: + return renders[0]["path"] return None diff --git a/backlot/ui/board.css b/backlot/ui/board.css new file mode 100644 index 00000000..42c001a6 --- /dev/null +++ b/backlot/ui/board.css @@ -0,0 +1,516 @@ +/* ============================================================ + BACKLOT — Living Storyboard design system (mockup) + Dark-room editorial: near-black matte canvas, artifacts glow. + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Courier+Prime:ital,wght@0,400;0,700;1,400&display=swap'); + +:root { + --bg: #0a0a0c; + --surface: #101013; + --surface-2: #16161a; + --surface-3: #1c1c21; + --border: #232329; + --border-soft: #1a1a1f; + --text: #ececef; + --text-2: #a0a0a9; + --text-3: #5f5f68; + --amber: #f0a83c; + --amber-dim: rgba(240, 168, 60, 0.14); + --green: #4fc283; + --green-dim: rgba(79, 194, 131, 0.12); + --red: #e5544b; + --red-dim: rgba(229, 84, 75, 0.12); + --blue: #6aa1ff; + --cream: #f2e9d5; + --cream-shade: #e5d9be; + --cream-ink: #29231a; + --cream-ink-2: #6b5f4a; + --sans: 'Inter', -apple-system, sans-serif; + --mono: 'JetBrains Mono', ui-monospace, monospace; + --screenplay: 'Courier Prime', 'Courier New', monospace; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { color-scheme: dark; } +::-webkit-scrollbar { width: 10px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #26262e; border-radius: 6px; border: 2px solid var(--bg); } +::-webkit-scrollbar-thumb:hover { background: #34343e; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + /* faint vignette so media pops */ + background-image: radial-gradient(1200px 600px at 50% -100px, #111116 0%, var(--bg) 70%); +} + +.wrap { max-width: 1440px; margin: 0 auto; padding: 0 28px 80px; } + +/* film grain — barely-there, keeps the dark room from feeling flat */ +body::after { + content: ''; position: fixed; inset: -50%; pointer-events: none; z-index: 90; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"); + opacity: .035; animation: grain 1.2s steps(4) infinite; +} +@keyframes grain { + 0%, 100% { transform: translate(0,0); } + 25% { transform: translate(-1.5%, 1%); } + 50% { transform: translate(1%, -1.5%); } + 75% { transform: translate(-1%, -1%); } +} + +/* everything enters like a story unfolding */ +@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } } +.slate { animation: rise .5s cubic-bezier(.2,.7,.3,1) backwards; } +.rail .stage { animation: rise .5s cubic-bezier(.2,.7,.3,1) backwards; } +.rail .stage:nth-child(1) { animation-delay: .06s } .rail .stage:nth-child(2) { animation-delay: .11s } +.rail .stage:nth-child(3) { animation-delay: .16s } .rail .stage:nth-child(4) { animation-delay: .21s } +.rail .stage:nth-child(5) { animation-delay: .26s } .rail .stage:nth-child(6) { animation-delay: .31s } +.rail .stage:nth-child(7) { animation-delay: .36s } .rail .stage:nth-child(8) { animation-delay: .41s } +.script-card, .notice { animation: rise .6s cubic-bezier(.2,.7,.3,1) .25s backwards; } +aside .panel { animation: rise .6s cubic-bezier(.2,.7,.3,1) backwards; } +aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2) { animation-delay: .42s } +.scene-card { animation: rise .65s cubic-bezier(.2,.7,.3,1) backwards; } +.scene-card:nth-child(1) { animation-delay: .35s } .scene-card:nth-child(2) { animation-delay: .43s } +.scene-card:nth-child(3) { animation-delay: .51s } .scene-card:nth-child(4) { animation-delay: .59s } +.scene-card:nth-child(5) { animation-delay: .67s } .scene-card:nth-child(6) { animation-delay: .75s } +.scene-card:nth-child(7) { animation-delay: .83s } .scene-card:nth-child(8) { animation-delay: .91s } +.scene-card:nth-child(9) { animation-delay: .99s } .scene-card:nth-child(10) { animation-delay: 1.07s } +.scene-card:nth-child(11) { animation-delay: 1.15s } .scene-card:nth-child(12) { animation-delay: 1.23s } +.lib-card { animation: rise .6s cubic-bezier(.2,.7,.3,1) backwards; } +.lib-card:nth-child(1) { animation-delay: .08s } .lib-card:nth-child(2) { animation-delay: .15s } +.lib-card:nth-child(3) { animation-delay: .22s } .lib-card:nth-child(4) { animation-delay: .29s } +.lib-card:nth-child(5) { animation-delay: .36s } .lib-card:nth-child(6) { animation-delay: .43s } +.lib-card:nth-child(7) { animation-delay: .50s } .lib-card:nth-child(8) { animation-delay: .57s } + +/* ---------- header slate ---------- */ +.slate { + display: flex; align-items: center; gap: 18px; + padding: 18px 0 16px; + border-bottom: 1px solid var(--border-soft); +} +.clapper { + width: 34px; height: 26px; border-radius: 4px; flex: none; + background: repeating-linear-gradient(-45deg, #2c2c33 0 6px, #101013 6px 12px); + border: 1px solid var(--border); +} +.slate h1 { + font-family: var(--mono); font-size: 17px; font-weight: 600; + letter-spacing: 0.08em; text-transform: uppercase; +} +.slate .wordmark { + font-family: var(--mono); font-size: 11px; letter-spacing: 0.22em; + color: var(--text-3); text-transform: uppercase; margin-right: 2px; +} +.chip { + font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.06em; + padding: 3px 9px; border-radius: 99px; + border: 1px solid var(--border); color: var(--text-2); + white-space: nowrap; +} +.chip.warn { border-color: rgba(240,168,60,.4); color: var(--amber); background: var(--amber-dim); } +.slate .spacer { flex: 1; } + +.live { + display: inline-flex; align-items: center; gap: 7px; + font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; + color: var(--amber); +} +.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); animation: pulse 1.6s ease-in-out infinite; } +.live.idle { color: var(--text-3); } +.live.idle .dot { background: var(--text-3); animation: none; } +@keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(240,168,60,.5); opacity: 1; } + 50% { box-shadow: 0 0 0 7px rgba(240,168,60,0); opacity: .75; } +} + +.cost { text-align: right; } +.cost .nums { font-family: var(--mono); font-size: 13px; } +.cost .nums b { color: var(--text); font-weight: 600; } +.cost .nums span { color: var(--text-3); } +.cost .bar { width: 150px; height: 3px; background: var(--surface-3); border-radius: 3px; margin-top: 5px; overflow: hidden; } +.cost .bar i { display: block; height: 100%; background: var(--green); border-radius: 3px; } +.cost .bar i.warn { background: var(--amber); } +.cost .label { font-size: 10px; color: var(--text-3); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; } + +/* ---------- stage rail ---------- */ +.rail { display: flex; align-items: flex-start; padding: 26px 0 22px; } +.stage { flex: 1; display: flex; flex-direction: column; align-items: center; position: relative; min-width: 0; } +.stage .node { + width: 26px; height: 26px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 12px; z-index: 2; position: relative; + background: var(--surface-2); border: 1.5px solid var(--border); + color: var(--text-3); +} +.stage .line { + position: absolute; top: 13px; left: calc(-50% + 13px); right: calc(50% + 13px); + height: 1.5px; background: var(--border); +} +.stage:first-child .line { display: none; } +.stage .name { + margin-top: 10px; font-family: var(--mono); font-size: 11px; + letter-spacing: 0.05em; color: var(--text-3); +} +.stage .sub { font-size: 10.5px; color: var(--text-3); margin-top: 3px; text-align: center; max-width: 150px; } + +.stage.done .node { background: var(--surface-3); border-color: #3a3a42; color: var(--green); } +.stage.done .line { background: #3a3a42; } +.stage.done .name { color: var(--text-2); } + +.stage.active .node { + border-color: var(--amber); color: var(--amber); background: var(--amber-dim); + animation: ringpulse 1.8s ease-in-out infinite; +} +.stage.active .line { background: linear-gradient(90deg, #3a3a42, rgba(240,168,60,.55)); overflow: hidden; } +.stage.active .line::after { /* energy traveling toward the live stage */ + content: ''; position: absolute; top: 0; bottom: 0; width: 34px; left: -40px; + background: linear-gradient(90deg, transparent, rgba(240,168,60,.95), transparent); + animation: travel 1.7s ease-in-out infinite; +} +@keyframes travel { to { left: calc(100% + 6px); } } +.stage.active .name { color: var(--amber); font-weight: 600; } +.stage.active .sub { color: var(--text-2); } +@keyframes ringpulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(240,168,60,.45); } + 50% { box-shadow: 0 0 0 9px rgba(240,168,60,0); } +} + +.stage.await .node { border-color: var(--amber); color: var(--amber); background: var(--amber-dim); box-shadow: 0 0 18px rgba(240,168,60,.25); } +.stage.await .line { background: linear-gradient(90deg, #3a3a42, var(--amber)); } +.stage.await .name { color: var(--amber); font-weight: 600; } +.stage.await .sub { color: var(--amber); } + +.stage.failed .node { border-color: var(--red); color: var(--red); background: var(--red-dim); } +.stage.failed .name { color: var(--red); } + +/* ---------- layout ---------- */ +.board { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; } +.main-col { min-width: 0; } + +.panel { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; } +.panel + .panel { margin-top: 18px; } +.panel-head { + display: flex; align-items: baseline; gap: 10px; + padding: 13px 16px 11px; border-bottom: 1px solid var(--border-soft); +} +.panel-head h2 { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.18em; color: var(--text-2); text-transform: uppercase; } +.panel-head .meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); margin-left: auto; } +.panel-body { padding: 14px 16px; } + +/* ---------- screenplay card ---------- */ +.script-card { + background: linear-gradient(178deg, var(--cream) 0%, var(--cream-shade) 130%); + color: var(--cream-ink); + border-radius: 6px; + padding: 34px 44px 26px; + max-width: 700px; /* screenplay pages are narrow — paper on a dark desk */ + margin: 0 auto; + font-family: var(--screenplay); + box-shadow: 0 18px 50px -18px rgba(0,0,0,.85), 0 1px 0 rgba(255,255,255,.06) inset; + position: relative; + cursor: pointer; +} +.script-card::after { /* page edge */ + content: ''; position: absolute; right: 7px; top: 7px; bottom: 7px; width: 1px; + background: rgba(0,0,0,.07); +} +.script-card .sp-title { + text-align: center; font-weight: 700; font-size: 16px; + letter-spacing: 0.12em; text-transform: uppercase; + margin-bottom: 4px; +} +.script-card .sp-meta { text-align: center; font-size: 11.5px; color: var(--cream-ink-2); margin-bottom: 26px; } +.script-card .sp-slug { + font-weight: 700; font-size: 12.5px; text-transform: uppercase; + letter-spacing: 0.04em; margin: 18px 0 6px; +} +.script-card .sp-slug .tc { color: var(--cream-ink-2); font-weight: 400; float: right; font-size: 11px; } +.script-card .sp-action { font-size: 13px; line-height: 1.62; } +.script-card .sp-paren { font-size: 11.5px; font-style: italic; color: var(--cream-ink-2); margin: 4px 0 0 42px; } +.script-card .sp-cue { + display: inline-block; font-family: var(--mono); font-size: 9.5px; font-style: normal; + background: rgba(0,0,0,.06); border-radius: 3px; padding: 1px 6px; margin: 6px 0 0; + color: #7d6f52; letter-spacing: .03em; +} +.script-card .sp-fade { text-align: right; font-size: 12px; font-weight: 700; margin-top: 20px; text-transform: uppercase; } +.script-card .sp-expand { + position: absolute; right: 16px; bottom: 12px; + font-family: var(--mono); font-size: 10px; color: var(--cream-ink-2); letter-spacing: .06em; +} +.script-approved { + position: absolute; top: 20px; right: 26px; + font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .14em; + color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px; + padding: 3px 8px; transform: rotate(6deg); opacity: .8; +} + +/* ---------- right rail: decisions & activity ---------- */ +.decision { padding: 11px 0; border-bottom: 1px solid var(--border-soft); } +.decision:last-child { border-bottom: none; } +.decision .d-head { display: flex; gap: 8px; align-items: baseline; } +.decision .d-cat { font-family: var(--mono); font-size: 9.5px; color: var(--text-3); letter-spacing: .1em; text-transform: uppercase; } +.decision .d-pick { font-size: 12.5px; font-weight: 600; margin-top: 3px; } +.decision .d-pick .arrow { color: var(--amber); font-weight: 400; } +.decision .d-why { font-size: 11.5px; color: var(--text-2); margin-top: 3px; line-height: 1.45; } +.decision .d-alt { font-size: 10.5px; color: var(--text-3); margin-top: 4px; } +.decision .d-alt s { opacity: .8; } + +.act-row { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1px solid var(--border-soft); font-family: var(--mono); font-size: 11px; } +.act-row:last-child { border-bottom: none; } +.act-row .t { color: var(--text-3); font-size: 10px; flex: none; } +.act-row .tool { color: var(--text-2); } +.act-row .target { color: var(--text-3); } +.act-row .status { margin-left: auto; flex: none; font-size: 10.5px; } +.act-row .status.ok { color: var(--green); } +.act-row .status.run { color: var(--amber); animation: blink 1.4s ease-in-out infinite; } +.act-row .status.err { color: var(--red); } +@keyframes blink { 50% { opacity: .45; } } + +/* ---------- filmstrip ---------- */ +.strip-outer { position: relative; } +.filmstrip { + display: flex; gap: 12px; overflow-x: auto; padding: 26px 4px; + /* sprocket holes */ + background: + radial-gradient(circle 3.5px, #2e2e36 97%, transparent) 0 6px / 26px 10px repeat-x, + radial-gradient(circle 3.5px, #2e2e36 97%, transparent) 0 calc(100% - 16px) / 26px 10px repeat-x; +} +.filmstrip { scrollbar-width: thin; scrollbar-color: #26262e transparent; } +.scene-card { flex: none; display: flex; flex-direction: column; position: relative; } +.scene-card .sc-slate { + display: flex; align-items: baseline; gap: 8px; + font-family: var(--mono); font-size: 10px; letter-spacing: .05em; + color: var(--text-3); padding: 0 2px 6px; +} +.scene-card .sc-slate .num { color: var(--text-2); font-weight: 600; } +.scene-card .sc-slate .take { color: var(--amber); } +.scene-card .sc-slate .dur { margin-left: auto; } +.scene-card .sc-slate .hero { color: var(--amber); letter-spacing: .1em; } + +.thumb { + border-radius: 7px; overflow: hidden; position: relative; + aspect-ratio: 16 / 9; background: var(--surface-2); + border: 1px solid var(--border); +} +.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.thumb .badge { + position: absolute; left: 7px; bottom: 7px; + font-family: var(--mono); font-size: 9px; letter-spacing: .06em; + background: rgba(8,8,10,.72); color: var(--text-2); + padding: 2px 7px; border-radius: 3px; backdrop-filter: blur(4px); +} +.thumb .play { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + color: rgba(255,255,255,.85); font-size: 26px; text-shadow: 0 2px 12px rgba(0,0,0,.7); + opacity: 0; transition: opacity .18s; +} +.thumb:hover .play { opacity: 1; } +.thumb.approved { border-color: rgba(79,194,131,.35); } + +/* generating shimmer */ +.thumb.generating { border-color: rgba(240,168,60,.45); } +.thumb.generating .shimmer { + position: absolute; inset: 0; + background: linear-gradient(100deg, var(--surface-2) 32%, #24242c 48%, var(--surface-2) 64%); + background-size: 220% 100%; + animation: shimmer 1.5s linear infinite; +} +@keyframes shimmer { to { background-position: -120% 0; } } +.thumb.generating .gen-label { + position: absolute; inset: 0; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 6px; padding: 0 14px; text-align: center; + font-family: var(--mono); font-size: 10px; color: var(--amber); letter-spacing: .08em; +} +.thumb.generating .gen-label .sub { color: var(--text-3); font-size: 9.5px; letter-spacing: .03em; line-height: 1.5; } + +/* pending spec card */ +.thumb.spec { border-style: dashed; border-color: #2c2c34; background: transparent; } +.thumb.spec .spec-in { + position: absolute; inset: 0; padding: 10px 12px; + display: flex; flex-direction: column; justify-content: center; gap: 4px; +} +.thumb.spec .spec-desc { font-size: 10.5px; color: var(--text-3); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.thumb.spec .spec-shot { font-family: var(--mono); font-size: 9px; color: #4a4a54; letter-spacing: .04em; } + +/* missing asset */ +.thumb.missing { border-color: rgba(240,168,60,.55); border-style: dashed; background: var(--amber-dim); } +.thumb.missing .spec-in { align-items: center; text-align: center; } +.thumb.missing .warn-ic { color: var(--amber); font-size: 15px; } +.thumb.missing .spec-desc { color: var(--amber); -webkit-line-clamp: 2; } + +/* text-card scene (typographic placeholder) */ +.thumb.textcard { display: flex; align-items: center; justify-content: center; background: #0d0d10; } +.thumb.textcard .tc-copy { + font-family: var(--mono); font-weight: 500; text-align: center; + letter-spacing: .2em; font-size: 11px; color: #d8d8de; padding: 0 10px; +} + +.narr { + padding: 8px 3px 0; font-size: 11px; color: var(--text-2); line-height: 1.45; + font-style: italic; max-height: 52px; overflow: hidden; +} +.narr.tc-note { color: var(--text-3); } + +.wave { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; padding: 6px 3px 0; } +.wave i { width: 2.5px; background: #3d3d47; border-radius: 1px; } +.wave.played i { background: #565664; } +.wave .wv-time { font-family: var(--mono); font-size: 9px; color: var(--text-3); margin-left: 6px; align-self: center; } + +/* takes drawer */ +.takes { display: flex; gap: 5px; padding: 8px 2px 0; align-items: center; } +.takes .tk { width: 44px; aspect-ratio: 16/9; border-radius: 3px; overflow: hidden; border: 1px solid var(--border); opacity: .55; position: relative; } +.takes .tk img { width: 100%; height: 100%; object-fit: cover; } +.takes .tk.active { opacity: 1; border-color: var(--amber); box-shadow: 0 0 0 1px var(--amber); } +.takes .tk-label { font-family: var(--mono); font-size: 9px; color: var(--text-3); letter-spacing: .05em; } + +/* ---------- empty state ---------- */ +.empty { + border: 1.5px dashed #26262e; border-radius: 10px; padding: 40px; + text-align: center; color: var(--text-3); +} +.empty .big { font-family: var(--mono); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; margin-bottom: 6px; color: #4a4a54; } + +/* ---------- review findings ---------- */ +.findings { display: flex; gap: 8px; align-items: center; font-family: var(--mono); font-size: 10.5px; } +.findings .f { padding: 2px 8px; border-radius: 99px; border: 1px solid var(--border); color: var(--text-3); } +.findings .f.crit { color: var(--red); border-color: rgba(229,84,75,.35); } +.findings .f.sugg { color: var(--amber); border-color: rgba(240,168,60,.3); } + +/* ---------- modal ---------- */ +.modal-bg { + position: fixed; inset: 0; background: rgba(5,5,7,.82); backdrop-filter: blur(6px); + display: none; align-items: flex-start; justify-content: center; overflow-y: auto; + padding: 48px 20px; z-index: 50; +} +.modal-bg.open { display: flex; } +.modal-page { max-width: 640px; width: 100%; } +.modal-close { + position: fixed; top: 18px; right: 26px; font-family: var(--mono); + color: var(--text-2); font-size: 12px; cursor: pointer; letter-spacing: .1em; + background: var(--surface-2); border: 1px solid var(--border); border-radius: 99px; padding: 6px 14px; +} + +/* ---------- library ---------- */ +.lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px; padding-top: 24px; } +.lib-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; overflow: hidden; transition: border-color .15s, transform .15s; } +.lib-card:hover { border-color: #34343e; transform: translateY(-2px); } +.lib-card.live-card { border-color: rgba(240,168,60,.4); } +.lib-poster { aspect-ratio: 16/9; background: var(--surface-2); position: relative; overflow: hidden; } +.lib-poster img { width: 100%; height: 100%; object-fit: cover; display: block; } +.lib-poster .lp-live { + position: absolute; top: 9px; left: 9px; font-family: var(--mono); font-size: 9px; letter-spacing: .12em; + color: var(--amber); background: rgba(8,8,10,.75); border: 1px solid rgba(240,168,60,.45); + padding: 3px 8px; border-radius: 99px; display: flex; gap: 5px; align-items: center; backdrop-filter: blur(4px); +} +.lib-poster .lp-live .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--amber); animation: pulse 1.6s infinite; } +.lib-poster .lp-txt { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--mono); letter-spacing: .16em; font-size: 12px; color: #3f3f4a; } +.lib-body { padding: 13px 15px 14px; } +.lib-body h3 { font-family: var(--mono); font-size: 12.5px; font-weight: 600; letter-spacing: .05em; } +.lib-body .lb-meta { display: flex; gap: 8px; margin-top: 5px; align-items: center; } +.lib-body .lb-meta .chip { font-size: 9.5px; padding: 2px 7px; } +.lib-body .lb-meta .when { font-size: 10.5px; color: var(--text-3); margin-left: auto; } +.mini-rail { display: flex; gap: 4px; margin-top: 11px; align-items: center; } +.mini-rail i { height: 4px; flex: 1; border-radius: 2px; background: var(--surface-3); } +.mini-rail i.d { background: #3d5c4b; } +.mini-rail i.a { background: var(--amber); animation: blink 1.4s infinite; } +.mini-rail i.w { background: var(--amber); } + +/* ---------- misc ---------- */ +.notice { + display: flex; gap: 10px; align-items: center; + border: 1px solid rgba(240,168,60,.3); background: var(--amber-dim); + border-radius: 9px; padding: 11px 15px; font-size: 12.5px; color: var(--text-2); margin: 18px 0 4px; +} +.notice b { color: var(--amber); font-weight: 600; } +.section-title { + font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .18em; + text-transform: uppercase; color: var(--text-2); padding: 26px 0 2px; + display: flex; align-items: baseline; gap: 12px; +} +.section-title .meta { font-size: 10.5px; color: var(--text-3); font-weight: 400; letter-spacing: .05em; margin-left: auto; } +a.backlink { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); text-decoration: none; letter-spacing: .08em; } +a.backlink:hover { color: var(--text-2); } + +/* ============================================================ + Live-board additions (beyond the mockup design system) + ============================================================ */ + +/* stage nodes are interactive on the real board */ +.stage { cursor: pointer; border-radius: 8px; padding: 4px 2px; transition: background .15s; } +.stage:hover { background: rgba(255,255,255,.025); } +.stage.selected .name { text-decoration: underline; text-underline-offset: 4px; } + +/* stage drawer */ +.drawer { + border: 1px solid var(--border-soft); background: var(--surface); + border-radius: 12px; margin: 0 0 20px; overflow: hidden; + animation: rise .35s cubic-bezier(.2,.7,.3,1); +} +.drawer .drawer-head { + display: flex; gap: 10px; align-items: baseline; + padding: 12px 16px; border-bottom: 1px solid var(--border-soft); +} +.drawer .drawer-head h3 { font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; } +.drawer .drawer-head .close { margin-left: auto; cursor: pointer; color: var(--text-3); font-family: var(--mono); font-size: 11px; } +.drawer .drawer-head .close:hover { color: var(--text-2); } +.drawer .drawer-body { padding: 14px 16px; } +.drawer pre { + font-family: var(--mono); font-size: 11px; line-height: 1.55; color: var(--text-2); + background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: 8px; + padding: 12px 14px; overflow: auto; max-height: 420px; white-space: pre-wrap; +} +.gate-chip { + font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; + padding: 2px 8px; border-radius: 99px; border: 1px solid rgba(229,84,75,.45); + color: var(--red); background: var(--red-dim); +} +.ver-chip { + font-family: var(--mono); font-size: 9.5px; letter-spacing: .06em; + padding: 2px 8px; border-radius: 99px; border: 1px solid var(--border); color: var(--text-3); +} + +/* render section */ +.render-hero { position: relative; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); background: #000; } +.render-hero video { width: 100%; display: block; max-height: 560px; } +.render-meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); padding: 8px 2px; display: flex; gap: 14px; flex-wrap: wrap; } +.render-meta .v { color: var(--text-2); cursor: pointer; } +.render-meta .v.active { color: var(--amber); } + +/* audio playback affordance */ +.narr-audio { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--text-3); } +.narr-audio:hover { color: var(--amber); } + +/* found-media grids (degraded view) */ +.found-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; } +.found-grid .thumb { aspect-ratio: 16/9; } + +/* replay bar (phase 3) */ +.replay-bar { + display: flex; align-items: center; gap: 14px; + border: 1px solid var(--border-soft); background: var(--surface); + border-radius: 10px; padding: 10px 16px; margin: 14px 0; +} +.replay-bar input[type=range] { flex: 1; accent-color: var(--amber); } +.replay-bar .rp-btn { + font-family: var(--mono); font-size: 11px; letter-spacing: .08em; cursor: pointer; + border: 1px solid var(--border); border-radius: 99px; padding: 4px 12px; color: var(--text-2); + background: var(--surface-2); +} +.replay-bar .rp-btn:hover { color: var(--amber); border-color: rgba(240,168,60,.4); } +.replay-bar .rp-time { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); min-width: 130px; text-align: right; } +body.replaying .live .dot { background: var(--blue); animation: none; } + +/* filmstrip thumbs at fixed height (duration drives width) */ +.filmstrip .thumb { height: 118px; aspect-ratio: auto; } + +/* empty board hints */ +.hint { font-size: 12px; color: var(--text-3); padding: 10px 2px; } + +a { color: inherit; } diff --git a/backlot/ui/board.html b/backlot/ui/board.html new file mode 100644 index 00000000..9b9a2488 --- /dev/null +++ b/backlot/ui/board.html @@ -0,0 +1,15 @@ + + + + + +Backlot + + + +
+ + + + + diff --git a/backlot/ui/board.js b/backlot/ui/board.js new file mode 100644 index 00000000..07cba410 --- /dev/null +++ b/backlot/ui/board.js @@ -0,0 +1,531 @@ +// Backlot project board — renders BoardState and stays live via SSE. + +import { + STAGE_ICONS, el, fmtAgo, fmtClock, fmtDuration, fmtMoney, + getJSON, mediaURL, subscribe, thumbURL, waveBars, +} from "/ui/lib.js"; + +const projectId = decodeURIComponent(location.pathname.split("/p/")[1] || ""); +const app = document.getElementById("app"); +const modal = document.getElementById("modal"); +const player = document.getElementById("player"); + +let state = null; +let selectedStage = null; // stage drawer open for this stage name +let activeRender = 0; + +// --------------------------------------------------------------------------- +// header slate +// --------------------------------------------------------------------------- + +function renderSlate(s) { + const board = s.storyboard; + const chips = [ + el("span", { class: "chip" }, `${s.pipeline.pipeline_type} pipeline`), + board && board.total_duration_seconds + ? el("span", { class: "chip" }, `${board.scenes.length} scenes · ${fmtDuration(board.total_duration_seconds)}`) + : null, + s.style_playbook ? el("span", { class: "chip" }, s.style_playbook) : null, + ]; + + const awaiting = s.stages.find((x) => x.status === "awaiting_human"); + const inProgress = s.stages.find((x) => x.status === "in_progress"); + let liveEl; + if (awaiting) { + liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "◈ AWAITING YOU"); + } else if (s.live || inProgress) { + liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "LIVE"); + } else { + liveEl = el("span", { class: "live idle" }, el("span", { class: "dot" }), + `IDLE${s.last_activity ? " · " + fmtAgo(s.last_activity).toUpperCase() : ""}`); + } + + const cost = el("div", { class: "cost" }); + if (s.cost) { + const spent = s.cost.total_spent_usd ?? 0; + const budget = spent + (s.cost.budget_remaining_usd ?? 0); + const hasBudget = s.cost.budget_remaining_usd != null; + const pct = hasBudget && budget > 0 ? Math.min(100, (spent / budget) * 100) : 0; + cost.append( + el("div", { class: "nums" }, el("b", {}, fmtMoney(spent)), + hasBudget ? el("span", {}, ` / ${fmtMoney(budget)}`) : null), + hasBudget + ? el("div", { class: "bar" }, el("i", { + class: pct > 75 ? "warn" : "", style: `width:${pct}%`, + })) + : null, + el("div", { class: "label" }, "generation spend"), + ); + } + + return el("header", { class: "slate" }, + el("div", { class: "clapper" }), + el("div", {}, + el("a", { class: "wordmark", href: "/", style: "text-decoration:none" }, "Backlot"), + el("h1", {}, s.title), + ), + ...chips, + el("div", { class: "spacer" }), + liveEl, + cost, + ); +} + +// --------------------------------------------------------------------------- +// stage rail +// --------------------------------------------------------------------------- + +function stageSub(st) { + if (st.status === "awaiting_human") return "awaiting your approval\nreply in chat to continue"; + if (st.status === "in_progress" && st.partial_progress) { + const done = st.partial_progress.completed_scene_ids; + if (Array.isArray(done)) return `${done.length} scene${done.length === 1 ? "" : "s"} done`; + return "in progress"; + } + if (st.status === "in_progress") return "in progress"; + if (st.status === "failed") return st.error ? String(st.error).slice(0, 60) : "failed"; + if (st.timestamp) { + const approved = st.gated && st.human_approved ? " · approved" : ""; + return fmtClock(st.timestamp) + approved; + } + return ""; +} + +function renderRail(s) { + const rail = el("nav", { class: "rail" }); + let pendingIndex = 1; + for (const st of s.stages) { + const cls = st.status === "completed" ? "done" + : st.status === "in_progress" ? "active" + : st.status === "awaiting_human" ? "await" + : st.status === "failed" ? "failed" : ""; + 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" : ""}`, + 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)), + ); + rail.append(node); + } + return rail; +} + +function toggleDrawer(stageName) { + selectedStage = selectedStage === stageName ? null : stageName; + render(); +} + +const STAGE_ARTIFACTS = { + research: ["research_brief"], + proposal: ["proposal_packet"], + idea: ["brief"], + script: ["script"], + scene_plan: ["scene_plan"], + assets: ["asset_manifest"], + edit: ["edit_decisions"], + compose: ["render_report", "final_review"], + publish: ["publish_log"], +}; + +function renderDrawer(s) { + if (!selectedStage) return null; + const st = s.stages.find((x) => x.name === selectedStage); + if (!st) return null; + + const body = el("div", { class: "drawer-body" }); + + if (st.review) { + body.append(el("div", { class: "findings", style: "margin-bottom:12px" }, + el("span", { class: `f ${st.review.critical ? "crit" : ""}` }, `${st.review.critical ?? 0} critical`), + el("span", { class: `f ${st.review.suggestions ? "sugg" : ""}` }, `${st.review.suggestions ?? 0} suggestions`), + el("span", { class: "f" }, `${st.review.nitpicks ?? 0} nitpicks`), + typeof st.review.summary === "string" ? el("span", { style: "font-size:11.5px;color:var(--text-2);margin-left:8px" }, st.review.summary) : null, + )); + } + + const names = STAGE_ARTIFACTS[st.name] || []; + let shown = false; + for (const name of names) { + const artifact = s.artifacts[name]; + if (!artifact) continue; + shown = true; + body.append( + el("div", { class: "d-cat", style: "font-family:var(--mono);font-size:9.5px;color:var(--text-3);letter-spacing:.1em;text-transform:uppercase;margin:6px 0 4px" }, name), + el("pre", {}, JSON.stringify(artifact, null, 2)), + ); + } + if (!shown) { + body.append(el("div", { class: "hint" }, + st.status === "pending" ? "This stage hasn't run yet." : "No canonical artifact found on disk for this stage.")); + } + + return el("div", { class: "drawer" }, + el("div", { class: "drawer-head" }, + el("h3", {}, `${st.name} — ${st.status}`), + st.gate_skipped ? el("span", { class: "gate-chip" }, "⚑ GATE SKIPPED") : null, + st.versions > 1 ? el("span", { class: "ver-chip" }, `v${st.versions}`) : null, + st.timestamp ? el("span", { class: "meta", style: "font-family:var(--mono);font-size:10.5px;color:var(--text-3)" }, st.timestamp) : null, + el("span", { class: "close", onclick: () => toggleDrawer(st.name) }, "CLOSE ✕"), + ), + body, + ); +} + +// --------------------------------------------------------------------------- +// script card +// --------------------------------------------------------------------------- + +function scriptSections(script, limit) { + const sections = script.sections || []; + const shown = limit ? sections.slice(0, limit) : sections; + const nodes = []; + for (const sec of shown) { + nodes.push(el("div", { class: "sp-slug" }, + `${(sec.id || "").toUpperCase()} — ${sec.label || "Section"} `, + el("span", { class: "tc" }, `${fmtDuration(sec.start_seconds)} – ${fmtDuration(sec.end_seconds)}`))); + if (sec.text) nodes.push(el("div", { class: "sp-action" }, sec.text)); + if (sec.speaker_directions) nodes.push(el("div", { class: "sp-paren" }, `(${sec.speaker_directions})`)); + const cues = sec.enhancement_cues || []; + if (cues.length) { + nodes.push(el("div", { style: "margin-left:42px" }, + cues.map((c) => el("span", { class: "sp-cue" }, `▸ ${c.type} · ${String(c.description || "").slice(0, 60)}`)))); + } + } + if (limit && sections.length > limit) { + nodes.push(el("div", { class: "sp-fade" }, `… ${sections.length - limit} more sections`)); + } + return nodes; +} + +function renderScriptCard(s) { + const script = s.artifacts.script; + if (!script) return null; + const scriptStage = s.stages.find((x) => x.name === "script"); + const approved = scriptStage && scriptStage.status === "completed"; + + const card = el("div", { class: "script-card", title: "Click to expand full script", onclick: openScriptModal }, + approved ? el("span", { class: "script-approved" }, "APPROVED") : null, + el("div", { class: "sp-title" }, script.title || s.title), + el("div", { class: "sp-meta" }, + `script · ${fmtDuration(script.total_duration_seconds)} · ${(script.sections || []).length} sections`), + ...scriptSections(script, 4), + el("span", { class: "sp-expand" }, "⤢ EXPAND SCRIPT"), + ); + return card; +} + +function openScriptModal() { + const script = state && state.artifacts.script; + if (!script) return; + modal.innerHTML = ""; + modal.append( + el("span", { class: "modal-close", onclick: closeModal }, "ESC · CLOSE"), + el("div", { class: "modal-page" }, + el("div", { class: "script-card", style: "cursor:default" }, + el("div", { class: "sp-title" }, script.title || state.title), + el("div", { class: "sp-meta" }, + `script · ${fmtDuration(script.total_duration_seconds)} · ${(script.sections || []).length} sections`), + ...scriptSections(script, 0), + el("div", { class: "sp-fade" }, "END"), + )), + ); + modal.classList.add("open"); +} + +function closeModal() { modal.classList.remove("open"); } +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); }); +modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); }); + +// --------------------------------------------------------------------------- +// right rail: decisions, activity +// --------------------------------------------------------------------------- + +function renderDecisions(s) { + const log = s.artifacts.decision_log; + const decisions = (log && log.decisions) || []; + if (!decisions.length) return null; + const body = el("div", { class: "panel-body" }); + for (const d of decisions.slice(-8).reverse()) { + const alts = (d.options_considered || []) + .filter((o) => (o.option_id || o.label) !== d.selected && o.rejected_because !== undefined || (o.option_id !== d.selected && (o.option_id || o.label))) + .filter((o) => o.option_id !== d.selected); + body.append(el("div", { class: "decision" }, + el("div", { class: "d-cat" }, `${d.category || "decision"}${d.confidence ? ` · ${d.confidence}` : ""}`), + el("div", { class: "d-pick" }, `${d.subject || ""} `, el("span", { class: "arrow" }, "→"), ` ${d.selected || ""}`), + d.reason ? el("div", { class: "d-why" }, d.reason) : null, + alts.length ? el("div", { class: "d-alt" }, "also considered: ", + alts.slice(0, 3).map((o, i) => [i ? " · " : "", el("s", {}, o.label || o.option_id)]).flat()) : null, + )); + } + return el("div", { class: "panel" }, + el("div", { class: "panel-head" }, el("h2", {}, "Decisions"), el("span", { class: "meta" }, "decision_log.json")), + body); +} + +function renderActivity(s) { + const events = s.events || []; + if (!events.length) return null; + const body = el("div", { class: "panel-body" }); + const started = new Map(); + for (const ev of events) { + if (ev.event === "start") started.set(`${ev.tool}:${ev.scene_id || ""}`, ev); + } + for (const ev of events.slice(-10).reverse()) { + let statusEl; + if (ev.event === "finish") { + statusEl = el("span", { class: "status ok" }, + `✓${ev.duration_s != null ? ` ${ev.duration_s}s` : ""}`); + } else if (ev.event === "error") { + statusEl = el("span", { class: "status err" }, "✕"); + } else { + statusEl = el("span", { class: "status run" }, "● running"); + } + body.append(el("div", { class: "act-row" }, + el("span", { class: "t" }, fmtClock(ev.ts)), + el("span", { class: "tool" }, ev.tool || ""), + el("span", { class: "target" }, ev.scene_id || ""), + statusEl, + )); + } + return el("div", { class: "panel" }, + el("div", { class: "panel-head" }, el("h2", {}, "Activity"), el("span", { class: "meta" }, "events.jsonl")), + body); +} + +// --------------------------------------------------------------------------- +// storyboard filmstrip +// --------------------------------------------------------------------------- + +function sceneCard(s, card) { + const dur = card.duration_seconds; + const width = Math.max(132, Math.min(300, 70 + (dur || 3) * 26)); + const wrap = el("div", { class: "scene-card", style: `width:${width}px` }); + + const slate = el("div", { class: "sc-slate" }, + el("span", { class: "num" }, `SC ${String(card.id).replace(/^sc/i, "").padStart(2, "0")}`), + card.takes.length > 1 ? el("span", { class: "take" }, `T${card.takes.length}`) : null, + card.hero_moment ? el("span", { class: "hero" }, "★ HERO") : null, + el("span", { class: "dur" }, fmtDuration(dur)), + ); + wrap.append(slate); + + // visual slot + let thumb; + if (card.generating) { + thumb = el("div", { class: "thumb generating" }, + el("div", { class: "shimmer" }), + el("div", { class: "gen-label" }, + el("span", {}, "◉ GENERATING"), + el("span", { class: "sub" }, card.generating_tool || ""))); + } else if (card.visual && card.visual.exists) { + const v = card.visual; + const badge = [v.model || v.source_tool, v.cost_usd != null ? fmtMoney(v.cost_usd) : null, + v.quality_score != null ? `q ${v.quality_score}` : null].filter(Boolean).join(" · "); + if (v.type === "video") { + thumb = el("div", { class: "thumb approved" }, + el("video", { src: mediaURL(s.project_id, v.path), muted: "", preload: "metadata", playsinline: "" }), + el("span", { class: "play" }, "▶"), + badge ? el("span", { class: "badge" }, badge) : null); + thumb.onclick = () => { + const vid = thumb.querySelector("video"); + if (vid.paused) vid.play(); else vid.pause(); + }; + } else { + thumb = el("div", { class: "thumb approved" }, + el("img", { src: thumbURL(s.project_id, v.path, 640), loading: "lazy", alt: "" }), + badge ? el("span", { class: "badge" }, badge) : null); + } + } else if (card.visual && !card.visual.exists) { + thumb = el("div", { class: "thumb missing" }, + el("div", { class: "spec-in" }, + el("span", { class: "warn-ic" }, "⚑"), + el("div", { class: "spec-desc" }, "asset in manifest, file missing"), + el("div", { class: "spec-shot" }, card.visual.path || ""))); + } else if (card.type === "text_card") { + thumb = el("div", { class: "thumb textcard" }, + el("div", { class: "tc-copy" }, (card.narration || card.description || "").slice(0, 48))); + } else if (card.required_assets.length) { + thumb = el("div", { class: "thumb missing" }, + el("div", { class: "spec-in" }, + el("span", { class: "warn-ic" }, "⚑"), + el("div", { class: "spec-desc" }, "no asset yet"), + el("div", { class: "spec-shot" }, (card.required_assets[0].description || "").slice(0, 60)))); + } else { + thumb = el("div", { class: "thumb spec" }, + el("div", { class: "spec-in" }, + el("div", { class: "spec-desc" }, card.description || ""), + el("div", { class: "spec-shot" }, [card.framing, card.movement].filter(Boolean).join(" · ").slice(0, 70)))); + } + wrap.append(thumb); + + // shot language chips + const sl = card.shot_language; + if (sl) { + wrap.append(el("div", { class: "shotchips", style: "display:flex;flex-wrap:wrap;gap:4px;padding:7px 2px 0" }, + [sl.shot_size, sl.camera_movement, sl.lens_mm ? `${sl.lens_mm}mm` : null, sl.lighting_key] + .filter(Boolean) + .map((t) => el("span", { style: "font-family:var(--mono);font-size:8.5px;letter-spacing:.04em;color:#62626c;border:1px solid #212129;border-radius:3px;padding:1px 5px" }, String(t).replaceAll("_", " "))))); + } + + // takes drawer + if (card.takes.length > 1) { + const takes = el("div", { class: "takes" }); + card.takes.forEach((t, i) => { + const isActive = t === card.visual; + const tk = el("span", { class: `tk${isActive ? " active" : ""}`, title: `take ${i + 1}` }); + if (t.exists && t.type === "image") tk.append(el("img", { src: thumbURL(s.project_id, t.path, 320), loading: "lazy", alt: "" })); + takes.append(tk); + }); + takes.append(el("span", { class: "tk-label" }, `${card.takes.length} TAKES`)); + wrap.append(takes); + } + + // narration + audio + if (card.narration) { + wrap.append(el("div", { class: "narr" }, card.narration)); + } else if (card.shot_intent || card.description) { + wrap.append(el("div", { class: "narr tc-note" }, (card.shot_intent || card.description || "").slice(0, 110))); + } + const narrAudio = card.audio.find((a) => a.exists && (a.type === "narration" || a.type === "audio")); + if (narrAudio) { + const wave = el("div", { class: "wave", style: "cursor:pointer", title: "Play narration" }); + waveBars(wave, card.id + narrAudio.path); + wave.append(el("span", { class: "wv-time" }, narrAudio.duration_seconds ? fmtDuration(narrAudio.duration_seconds) : "♪")); + wave.onclick = () => { + player.src = mediaURL(s.project_id, narrAudio.path); + player.play(); + }; + wrap.append(wave); + } + return wrap; +} + +function renderStoryboard(s) { + const board = s.storyboard; + if (!board) return null; + const strip = el("div", { class: "filmstrip" }); + for (const card of board.scenes) strip.append(sceneCard(s, card)); + return el("div", {}, + el("div", { class: "section-title" }, "Storyboard", + el("span", { class: "meta" }, + `${board.scenes.length} scenes${board.total_duration_seconds ? ` · ${fmtDuration(board.total_duration_seconds)}` : ""} · card width ∝ duration`)), + el("div", { class: "strip-outer" }, strip)); +} + +// --------------------------------------------------------------------------- +// renders + degraded media +// --------------------------------------------------------------------------- + +function renderRenders(s) { + const renders = s.media.renders; + if (!renders.length) return null; + if (activeRender >= renders.length) activeRender = 0; + const current = renders[activeRender]; + const video = el("video", { src: mediaURL(s.project_id, current.path), controls: "", preload: "none" }); + const versions = el("div", { class: "render-meta" }, + renders.map((r, i) => el("span", { + class: `v${i === activeRender ? " active" : ""}`, + onclick: () => { activeRender = i; render(); }, + }, `${r.path.split("/").pop()}${r.at_root ? " · root" : ""}`)), + el("span", { style: "margin-left:auto" }, `${(current.size / 1048576).toFixed(1)} MB`), + ); + return el("div", {}, + el("div", { class: "section-title" }, "Renders", + el("span", { class: "meta" }, `${renders.length} version${renders.length === 1 ? "" : "s"}`)), + el("div", { class: "render-hero" }, video), + versions); +} + +function renderFoundMedia(s) { + // Degraded view: show discovered snapshots when there's no storyboard. + if (s.storyboard || !s.media.snapshots.length) return null; + const grid = el("div", { class: "found-grid" }); + for (const snap of s.media.snapshots.slice(0, 12)) { + grid.append(el("div", { class: "thumb" }, + el("img", { src: thumbURL(s.project_id, snap.path, 640), loading: "lazy", alt: "" }))); + } + return el("div", {}, + el("div", { class: "section-title" }, "What the watcher found", + el("span", { class: "meta" }, "snapshots / verification frames")), + grid); +} + +function renderNoState(s) { + if (s.has_pipeline_state) return null; + return el("div", { class: "notice", style: "border-color:#2b2b33;background:var(--surface-2);color:var(--text-3)" }, + el("span", { style: "font-size:15px" }, "◌"), + el("span", {}, + el("b", { style: "color:var(--text-2)" }, "No pipeline state. "), + "This project has no checkpoints — Backlot is showing what it found on disk. ", + "Runs that follow the checkpoint protocol get the full board.")); +} + +function renderAwaitingNotice(s) { + const awaiting = s.stages.find((x) => x.status === "awaiting_human"); + if (!awaiting) return null; + return el("div", { class: "notice" }, + el("span", { style: "font-size:16px" }, "◈"), + el("span", {}, + el("b", {}, `The ${awaiting.name} stage is waiting for your review. `), + "The agent is paused at this gate — reply ", el("b", {}, "in chat"), " to approve or request changes.")); +} + +// --------------------------------------------------------------------------- +// page assembly +// --------------------------------------------------------------------------- + +function render() { + if (!state) return; + const s = state; + document.title = `Backlot — ${s.title}`; + app.innerHTML = ""; + app.append(renderSlate(s)); + app.append(renderRail(s)); + const drawer = renderDrawer(s); + if (drawer) app.append(drawer); + const awaitingNotice = renderAwaitingNotice(s); + if (awaitingNotice) app.append(awaitingNotice); + const noState = renderNoState(s); + if (noState) app.append(noState); + + const main = el("div", { class: "main-col" }); + const script = renderScriptCard(s); + if (script) main.append(script); + const aside = el("aside", {}); + const decisions = renderDecisions(s); + const activity = renderActivity(s); + if (decisions) aside.append(decisions); + if (activity) aside.append(activity); + + if (script || decisions || activity) { + app.append(el("div", { class: "board" }, main, aside)); + } + + const storyboard = renderStoryboard(s); + if (storyboard) app.append(storyboard); + const found = renderFoundMedia(s); + if (found) app.append(found); + const renders = renderRenders(s); + if (renders) app.append(renders); +} + +async function refresh() { + state = await getJSON(`/api/project/${encodeURIComponent(projectId)}/state`); + render(); +} + +refresh().catch((err) => { + app.innerHTML = ""; + app.append(el("div", { class: "empty", style: "margin-top:80px" }, + el("div", { class: "big" }, "PROJECT NOT FOUND"), + el("div", {}, String(err)))); +}); +// ?static=1 disables the live feed (screenshots, static exports). +if (!new URLSearchParams(location.search).has("static")) { + subscribe(`/api/project/${encodeURIComponent(projectId)}/events`, () => refresh().catch(console.error)); +} diff --git a/backlot/ui/index.html b/backlot/ui/index.html new file mode 100644 index 00000000..b79219c9 --- /dev/null +++ b/backlot/ui/index.html @@ -0,0 +1,26 @@ + + + + + +Backlot — Library + + + +
+
+
+
+ Backlot +

Library

+
+ +
+ IDLE +
+
+ +
+ + + diff --git a/backlot/ui/lib.js b/backlot/ui/lib.js new file mode 100644 index 00000000..d99649b3 --- /dev/null +++ b/backlot/ui/lib.js @@ -0,0 +1,102 @@ +// Shared helpers for the Backlot UI. + +export async function getJSON(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status} ${url}`); + return res.json(); +} + +export function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (v == null) continue; + if (k === "class") node.className = v; + else if (k === "html") node.innerHTML = v; + else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v); + } + for (const child of children.flat()) { + if (child == null) continue; + node.append(child.nodeType ? child : document.createTextNode(String(child))); + } + return node; +} + +export function fmtDuration(seconds) { + if (seconds == null) return ""; + const s = Math.round(seconds); + const m = Math.floor(s / 60); + return `${m}:${String(s % 60).padStart(2, "0")}`; +} + +export function fmtMoney(v) { + if (v == null) return "—"; + return `$${Number(v).toFixed(2)}`; +} + +export function fmtAgo(epochSeconds) { + if (!epochSeconds) return ""; + const diff = Date.now() / 1000 - epochSeconds; + if (diff < 90) return "just now"; + if (diff < 3600) return `${Math.round(diff / 60)}m ago`; + if (diff < 86400) return `${Math.round(diff / 3600)}h ago`; + return `${Math.round(diff / 86400)}d ago`; +} + +export function fmtClock(iso) { + if (!iso) return ""; + try { + return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + } catch { + return ""; + } +} + +export function mediaURL(projectId, relPath) { + return `/media/${encodeURIComponent(projectId)}/${relPath.split("/").map(encodeURIComponent).join("/")}`; +} + +// Downscaled cached JPEG for images (full media only in players/lightbox). +export function thumbURL(projectId, relPath, w = 640) { + return `/thumb/${encodeURIComponent(projectId)}/${relPath.split("/").map(encodeURIComponent).join("/")}?w=${w}`; +} + +// Subscribe to a server-sent change feed; call onChange (debounced) per burst. +export function subscribe(url, onChange) { + let timer = null; + const source = new EventSource(url); + source.onmessage = (msg) => { + try { + const data = JSON.parse(msg.data); + if (data.type !== "change") return; + } catch { + return; + } + clearTimeout(timer); + timer = setTimeout(onChange, 250); + }; + source.onerror = () => { /* EventSource auto-reconnects */ }; + return source; +} + +// Deterministic pseudo-waveform bars (seeded by a string). +export function waveBars(container, seedStr, count = 26, maxH = 14) { + let seed = 0; + for (const c of seedStr || "wave") seed = (seed * 31 + c.charCodeAt(0)) % 2147483647; + seed = seed || 7; + container.innerHTML = ""; + for (let i = 0; i < count; i++) { + seed = (seed * 16807) % 2147483647; + const h = 3 + ((seed % 100) / 100) * maxH * (0.55 + 0.45 * Math.sin(i / 5)); + const bar = document.createElement("i"); + bar.style.height = `${Math.max(3, h)}px`; + container.append(bar); + } +} + +export const STAGE_ICONS = { + completed: "✓", + in_progress: "◉", + awaiting_human: "◈", + failed: "✕", +}; diff --git a/backlot/ui/library.js b/backlot/ui/library.js new file mode 100644 index 00000000..07222432 --- /dev/null +++ b/backlot/ui/library.js @@ -0,0 +1,63 @@ +import { el, fmtAgo, getJSON, subscribe, thumbURL } from "/ui/lib.js"; + +const grid = document.getElementById("grid"); + +function miniRail(states) { + const rail = el("div", { class: "mini-rail" }); + for (const s of states) { + const cls = s.status === "completed" ? "d" + : s.status === "in_progress" ? "a" + : s.status === "awaiting_human" ? "w" : ""; + rail.append(el("i", { class: cls, title: `${s.name}: ${s.status}` })); + } + return rail; +} + +function card(p) { + const poster = el("div", { class: "lib-poster" }); + if (p.poster) { + poster.append(el("img", { src: thumbURL(p.project_id, p.poster, 640), loading: "lazy", alt: "" })); + } else { + poster.append(el("span", { class: "lp-txt" }, "NO MEDIA YET")); + } + if (p.live && p.active_stage) { + poster.append(el("span", { class: "lp-live" }, + el("span", { class: "dot" }), + p.awaiting_human ? "◈ AWAITING YOU" : `LIVE · ${p.active_stage.toUpperCase()}`)); + } else if (p.awaiting_human) { + poster.append(el("span", { class: "lp-live" }, "◈ AWAITING YOU")); + } + + const meta = el("div", { class: "lb-meta" }, + el("span", { class: "chip" }, p.pipeline_type || "unknown"), + p.scene_count ? el("span", { class: "chip" }, `${p.scene_count} scenes`) : null, + p.render_count ? el("span", { class: "chip" }, `${p.render_count} renders`) : null, + el("span", { class: "when" }, fmtAgo(p.last_activity)), + ); + + return el("a", { class: `lib-card${p.live ? " live-card" : ""}`, href: `/p/${p.project_id}`, style: "text-decoration:none;color:inherit" }, + poster, + el("div", { class: "lib-body" }, + el("h3", {}, (p.title || p.project_id).toUpperCase()), + meta, + p.stage_states.length ? miniRail(p.stage_states) : null, + ), + ); +} + +async function render() { + const projects = await getJSON("/api/projects"); + document.getElementById("count").textContent = `${projects.length} projects`; + const liveCount = projects.filter((p) => p.live).length; + const badge = document.getElementById("liveBadge"); + badge.classList.toggle("idle", liveCount === 0); + document.getElementById("liveText").textContent = liveCount ? `${liveCount} LIVE` : "IDLE"; + grid.innerHTML = ""; + document.getElementById("empty").style.display = projects.length ? "none" : "block"; + for (const p of projects) grid.append(card(p)); +} + +render().catch(console.error); +if (!new URLSearchParams(location.search).has("static")) { + subscribe("/api/library/events", () => render().catch(console.error)); +}