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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
64
apps/web/src/components/control-room/pst-preview.tsx
Normal file
64
apps/web/src/components/control-room/pst-preview.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"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>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue