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
2026-07-01 23:47:45 -07:00
|
|
|
// 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.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) {
|
backlot phase 3 review fixes: SSE filtering, thumb race, replay robustness
- ChangeHub subscriptions filtered per project: unrelated-project bursts
can no longer flood a board's queue and starve its own change signal
- thumbnail temp files unique per request (concurrent-miss race on the
shared .tmp path corrupted the cache)
- watcher change-mapping is pure string work (no per-path resolve() in
thousand-file change batches); 'C:'-style project ids rejected
- replay: tz-naive timestamps treated as UTC; final render/script no
longer leak at t0 on storyboard-less projects; single tick chain on
rapid pause/play; drag-safe scrubber (label tracks input, board renders
on release); render-video playback survives SSE re-renders
- state: scene id 0 joins correctly, nested depth>0 events don't corrupt
generating state, out-of-project asset paths honestly unserveable,
negative durations clamped, tolerant manifest stage parse
- UI: decisions alt filter precedence fixed, activity counts parallel
same-tool runs, el() html sink removed, NaN-safe formatters
2026-07-02 00:05:13 -07:00
|
|
|
const n = Number(seconds);
|
|
|
|
|
if (seconds == null || !Number.isFinite(n)) return "";
|
|
|
|
|
const s = Math.max(0, Math.round(n));
|
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
2026-07-01 23:47:45 -07:00
|
|
|
const m = Math.floor(s / 60);
|
|
|
|
|
return `${m}:${String(s % 60).padStart(2, "0")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fmtMoney(v) {
|
backlot phase 3 review fixes: SSE filtering, thumb race, replay robustness
- ChangeHub subscriptions filtered per project: unrelated-project bursts
can no longer flood a board's queue and starve its own change signal
- thumbnail temp files unique per request (concurrent-miss race on the
shared .tmp path corrupted the cache)
- watcher change-mapping is pure string work (no per-path resolve() in
thousand-file change batches); 'C:'-style project ids rejected
- replay: tz-naive timestamps treated as UTC; final render/script no
longer leak at t0 on storyboard-less projects; single tick chain on
rapid pause/play; drag-safe scrubber (label tracks input, board renders
on release); render-video playback survives SSE re-renders
- state: scene id 0 joins correctly, nested depth>0 events don't corrupt
generating state, out-of-project asset paths honestly unserveable,
negative durations clamped, tolerant manifest stage parse
- UI: decisions alt filter precedence fixed, activity counts parallel
same-tool runs, el() html sink removed, NaN-safe formatters
2026-07-02 00:05:13 -07:00
|
|
|
const n = Number(v);
|
|
|
|
|
if (v == null || !Number.isFinite(n)) return "—";
|
|
|
|
|
return `$${n.toFixed(2)}`;
|
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
2026-07-01 23:47:45 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: "✕",
|
|
|
|
|
};
|