mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-05 15:20:40 +08:00
backlot phase 2: board UI (library, live board, filmstrip, script modal, drawers)
- 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
This commit is contained in:
17
.claude/launch.json
Normal file
17
.claude/launch.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -94,3 +94,6 @@ remotion-composer/public/demo-props/caption-burn-*
|
||||
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Backlot local cache (thumbnails)
|
||||
.backlot/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<id>/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
|
||||
|
||||
|
||||
|
||||
516
backlot/ui/board.css
Normal file
516
backlot/ui/board.css
Normal file
@@ -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; }
|
||||
15
backlot/ui/board.html
Normal file
15
backlot/ui/board.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Backlot</title>
|
||||
<link rel="stylesheet" href="/ui/board.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="app"></div>
|
||||
<div class="modal-bg" id="modal"></div>
|
||||
<audio id="player"></audio>
|
||||
<script type="module" src="/ui/board.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
531
backlot/ui/board.js
Normal file
531
backlot/ui/board.js
Normal file
@@ -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));
|
||||
}
|
||||
26
backlot/ui/index.html
Normal file
26
backlot/ui/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Backlot — Library</title>
|
||||
<link rel="stylesheet" href="/ui/board.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="slate">
|
||||
<div class="clapper"></div>
|
||||
<div>
|
||||
<span class="wordmark">Backlot</span>
|
||||
<h1>Library</h1>
|
||||
</div>
|
||||
<span class="chip" id="count"></span>
|
||||
<div class="spacer"></div>
|
||||
<span class="live idle" id="liveBadge"><span class="dot"></span><span id="liveText">IDLE</span></span>
|
||||
</header>
|
||||
<div class="lib-grid" id="grid"></div>
|
||||
<p class="hint" id="empty" style="display:none">No projects yet — run a production and it will appear here.</p>
|
||||
</div>
|
||||
<script type="module" src="/ui/library.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
102
backlot/ui/lib.js
Normal file
102
backlot/ui/lib.js
Normal file
@@ -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: "✕",
|
||||
};
|
||||
63
backlot/ui/library.js
Normal file
63
backlot/ui/library.js
Normal file
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user