T-HUB/apps/web/src/components/control-room/pst-preview.tsx
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

64 lines
4.5 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { controlRequest, signedSameOrigin } from "./client";
import type { Production, Slot } from "./types";
export function slotRect(layout: string, slot: Slot): { x: number; y: number; w: number; h: number } {
if (slot.rect) return { x: slot.rect.x / 10000, y: slot.rect.y / 10000, w: slot.rect.w / 10000, h: slot.rect.h / 10000 };
const cols = layout === "3x3" ? 3 : layout === "4x4" ? 4 : layout === "2x1" || layout === "2x2" ? 2 : 1;
const rows = layout === "2x1" || cols === 1 ? 1 : cols;
return { x: (slot.index % cols) / cols, y: Math.floor(slot.index / cols) / rows, w: 1 / cols, h: 1 / rows };
}
export function PstPreview({ production, channelId, fixture }: { production: Production; channelId: string; fixture: boolean }): React.ReactElement {
const canvas = useRef<HTMLCanvasElement>(null);
const [visible, setVisible] = useState(true);
const scene = production.scenes.find(item => item.id === production.buses.preview);
useEffect(() => {
const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false));
if (canvas.current) observer.observe(canvas.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
const element = canvas.current;
if (!element) return;
const buffer = document.createElement("canvas"); buffer.width = element.width; buffer.height = element.height;
const context = buffer.getContext("2d");
if (!context) return;
const abort = new AbortController();
let busy = false;
const draw = async (): Promise<void> => {
if (document.hidden || !visible || busy) return;
busy = true;
context.fillStyle = "#101010"; context.fillRect(0, 0, element.width, element.height);
try {
for (const slot of [...(scene?.slots ?? [])].sort((a, b) => a.index - b.index)) {
const rect = slotRect(scene?.layout ?? "single", slot);
const x = rect.x * element.width, y = rect.y * element.height, w = rect.w * element.width, h = rect.h * element.height;
context.save(); context.beginPath(); context.rect(x, y, w, h); context.clip();
context.fillStyle = "#222"; context.fillRect(x, y, w, h);
let painted = false;
if (!fixture && slot.sourceId) {
try {
const urls = await controlRequest<{ thumbnail: string }>(`/media-control/channels/${channelId}/preview/${slot.sourceId}`, undefined, abort.signal);
const response = await fetch(signedSameOrigin(urls.thumbnail), { signal: abort.signal });
if (!response.ok) throw new Error("Thumbnail unavailable");
const bitmap = await createImageBitmap(await response.blob());
if (!abort.signal.aborted) {
const scale = slot.fit === "contain" ? Math.min(w / bitmap.width, h / bitmap.height) : Math.max(w / bitmap.width, h / bitmap.height);
context.drawImage(bitmap, x + (w - bitmap.width * scale) / 2, y + (h - bitmap.height * scale) / 2, bitmap.width * scale, bitmap.height * scale);
painted = true;
}
bitmap.close();
} catch { /* Label unavailable taps. */ }
}
if (!painted && !abort.signal.aborted) { context.fillStyle = "#aaa"; context.font = "16px sans-serif"; context.fillText(fixture ? "Fixture · no media" : "Tap unavailable", x + 16, y + 30, Math.max(1, w - 32)); }
context.restore();
}
if (!abort.signal.aborted) element.getContext("2d")?.drawImage(buffer, 0, 0);
} finally { busy = false; }
};
void draw(); const timer = setInterval(() => { void draw(); }, 1000);
return () => { abort.abort(); clearInterval(timer); };
}, [scene, channelId, fixture, visible]);
const overlays = production.overlays.filter(overlay => overlay.enabled && overlay.sceneId === scene?.id);
return <section className="rounded-lg border border-border-subtle bg-surface p-3" aria-label="PST look-ahead"><h2 className="mb-2 text-sm font-semibold">PST · {scene?.label ?? "No scene selected"}</h2><canvas ref={canvas} width={640} height={360} className="aspect-video w-full rounded bg-black" aria-label={`Look-ahead of ${scene?.label ?? "empty preview"}`} /><p className="mt-2 text-xs text-text-faint">PST is a look-ahead; PGM is the truth.</p>{overlays.length > 0 && <p className="mt-2 text-xs text-text-faint">{overlays.length} overlay slots configured. Status provides revision IDs without render pixels; overlay appearance is unavailable in this look-ahead.</p>}</section>;
}