Files
OpenMontage/backlot/ui/lib.js
calesthio cf3edafff0 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

104 lines
3.1 KiB
JavaScript

// 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) {
const n = Number(seconds);
if (seconds == null || !Number.isFinite(n)) return "";
const s = Math.max(0, Math.round(n));
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, "0")}`;
}
export function fmtMoney(v) {
const n = Number(v);
if (v == null || !Number.isFinite(n)) return "—";
return `$${n.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: "✕",
};