From 984a73e8ec1e9727a3c2d8fa273f078d3d37792f Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 19:58:02 -0400 Subject: [PATCH] feat(playout): redesigned MCR screen + SCTE-35 end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop in the redesigned timeline-centric Playout (PGM monitor, transport, SCTE-35 card, as-run drawer) from the on-node redesign, fully wired to the real playout API (channels/transport/HLS preview w/ error-recovery/as-run); no mock data. In-page ConfirmModal for destructive actions. SCTE-35: new playout_scte_breaks table (migration 033), endpoints to schedule/trigger/list/cancel breaks (POST/GET/DELETE /channels/:id/scte[/trigger]), scheduler due-break sweep, engine triggerScte + auto-return + as-run 'scte' rows + on-air SCTE-BREAK state and timeline AD markers. In-stream SCTE-35 cue injection is a documented stub (CasparCG FFMPEG consumer exposes no scte35 muxer) — scheduling/triggering/countdown/as-run are functional. Co-Authored-By: Claude Opus 4.8 --- .../src/db/migrations/033-playout-scte.sql | 52 ++ services/mam-api/src/routes/playout.js | 111 +++ services/mam-api/src/scheduler.js | 28 +- services/playout/src/index.js | 14 + services/playout/src/playout-manager.js | 93 ++ services/web-ui/public/screens-playout.jsx | 851 ++++++++++++++---- services/web-ui/public/styles-playout.css | 537 +++++++++-- 7 files changed, 1399 insertions(+), 287 deletions(-) create mode 100644 services/mam-api/src/db/migrations/033-playout-scte.sql diff --git a/services/mam-api/src/db/migrations/033-playout-scte.sql b/services/mam-api/src/db/migrations/033-playout-scte.sql new file mode 100644 index 0000000..9d3e56d --- /dev/null +++ b/services/mam-api/src/db/migrations/033-playout-scte.sql @@ -0,0 +1,52 @@ +-- Migration 033 — SCTE-35 ad-break markers for playout. +-- +-- Adds the missing SCTE-35 splice feature to the playout (MCR) subsystem. An +-- operator can either schedule an ad break on a channel's timeline (relative to +-- the active playlist position, or at a wall-clock time) or fire one immediately +-- ("splice now"). Each break is recorded here and, when fired, also written to +-- the append-only as-run log so it shows in the compliance record alongside the +-- clips that aired. +-- +-- type: +-- splice_insert — a scheduled break (out → return), duration_s seconds long +-- immediate — fire-now splice (operator pressed "Trigger ad break now") +-- splice_out — open-ended avail out (provider break start) +-- splice_in — return-to-network (provider break end) +-- +-- status: pending → fired (when the engine acts on it) → done (when the break +-- window has elapsed). cancelled is set if the operator removes a pending break. +-- +-- The engine (services/playout) acts on a break by logging the cue, marking the +-- as-run row, and — where the output path supports it — injecting a real +-- SCTE-35 cue (see playout-manager.triggerScte for the injection point/TODO). + +CREATE TABLE IF NOT EXISTS playout_scte_breaks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE, + -- Position on the active playlist this break should fire after (0-based item + -- index). NULL for immediate/wall-clock breaks. + playlist_pos INTEGER, + -- Wall-clock fire time for scheduled breaks. NULL for immediate breaks. + scheduled_at TIMESTAMPTZ, + duration_s INTEGER NOT NULL DEFAULT 30, + -- SCTE-35 event id (the splice_event_id carried in the cue). Auto-assigned. + event_id INTEGER NOT NULL DEFAULT 1, + type TEXT NOT NULL DEFAULT 'splice_insert', + status TEXT NOT NULL DEFAULT 'pending', + fired_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (type IN ('splice_insert','immediate','splice_out','splice_in')), + CHECK (status IN ('pending','fired','done','cancelled')) +); + +CREATE INDEX IF NOT EXISTS idx_playout_scte_channel ON playout_scte_breaks (channel_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_playout_scte_status ON playout_scte_breaks (status); + +-- As-run gains a 'scte' result so fired breaks land in the compliance log next to +-- the clips. The original migration constrained result to played/skipped/error; +-- widen it. +ALTER TABLE playout_as_run DROP CONSTRAINT IF EXISTS playout_as_run_result_check; +ALTER TABLE playout_as_run ADD CONSTRAINT playout_as_run_result_check + CHECK (result IN ('played','skipped','error','scte')); diff --git a/services/mam-api/src/routes/playout.js b/services/mam-api/src/routes/playout.js index f002c9b..ca66cc2 100644 --- a/services/mam-api/src/routes/playout.js +++ b/services/mam-api/src/routes/playout.js @@ -476,6 +476,117 @@ router.get('/channels/:id/asrun', async (req, res, next) => { } catch (err) { next(err); } }); +// ── SCTE-35 ad-break splices ─────────────────────────────────────────────── +// Schedule, trigger, and list SCTE-35 ad breaks on a channel. A break can be +// scheduled (after a playlist position, or at a wall-clock time) or fired +// immediately. Firing tells the sidecar to splice the live output, marks the +// break 'fired', and stamps a row in the as-run compliance log. +const SCTE_TYPES = new Set(['splice_insert', 'immediate', 'splice_out', 'splice_in']); + +// Fire a break row on the sidecar + record it. Shared by the immediate-trigger +// route and the scheduler's due-break sweep. Best-effort: a sidecar failure +// marks the break 'error' via error_message but never throws to the caller's +// HTTP path beyond what's handled here. +export async function fireScteBreak(channel, brk) { + const out = await callSidecar(channel, '/scte/trigger', 'POST', { + eventId: brk.event_id, + type: brk.type === 'immediate' ? 'splice_insert' : brk.type, + durationS: brk.duration_s, + }); + await pool.query( + `UPDATE playout_scte_breaks SET status = 'fired', fired_at = NOW(), updated_at = NOW() WHERE id = $1`, + [brk.id] + ); + // Stamp the compliance log. ended_at/duration are known up front for a + // fixed-duration break, so the row is written closed. + await pool.query( + `INSERT INTO playout_as_run + (channel_id, item_id, clip_name, started_at, ended_at, duration_s, result) + VALUES ($1, $2, $3, NOW(), + CASE WHEN $4 > 0 THEN NOW() + ($4 || ' seconds')::interval ELSE NULL END, + $4, 'scte')`, + [channel.id, brk.id, `SCTE-35 ${brk.type} (${brk.duration_s}s)`, brk.duration_s] + ); + return out; +} + +router.get('/channels/:id/scte', async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT * FROM playout_scte_breaks WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 200`, + [req.channel.id]); + res.json(rows); + } catch (err) { next(err); } +}); + +// Schedule a break. Body: { type, duration_s, playlist_pos?, scheduled_at? }. +// A pending break with a playlist_pos / scheduled_at is fired later by the +// scheduler; one with neither is fired immediately for convenience. +router.post('/channels/:id/scte', requireChannelEdit, async (req, res, next) => { + try { + const { type = 'splice_insert', duration_s = 30, + playlist_pos = null, scheduled_at = null } = req.body || {}; + if (!SCTE_TYPES.has(type)) { + return res.status(400).json({ error: `type must be one of: ${[...SCTE_TYPES].join(', ')}` }); + } + const dur = Math.max(0, parseInt(duration_s, 10) || 0); + // Auto-assign a monotonically increasing splice_event_id per channel. + const ev = await pool.query( + `SELECT COALESCE(MAX(event_id), 0) + 1 AS next FROM playout_scte_breaks WHERE channel_id = $1`, + [req.channel.id]); + const eventId = ev.rows[0].next; + const { rows } = await pool.query( + `INSERT INTO playout_scte_breaks + (channel_id, playlist_pos, scheduled_at, duration_s, event_id, type, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [req.channel.id, playlist_pos, scheduled_at, dur, eventId, type, req.user?.id || null]); + res.status(201).json(rows[0]); + } catch (err) { next(err); } +}); + +// Fire an ad break immediately ("splice now"). Body: { type?, duration_s? }. +// Creates the break row and triggers the splice on the live output in one shot. +router.post('/channels/:id/scte/trigger', requireChannelEdit, async (req, res, next) => { + try { + if (req.channel.status !== 'running') { + return res.status(409).json({ error: 'Channel is not running' }); + } + const { type = 'immediate', duration_s = 30 } = req.body || {}; + if (!SCTE_TYPES.has(type)) { + return res.status(400).json({ error: `type must be one of: ${[...SCTE_TYPES].join(', ')}` }); + } + const dur = Math.max(0, parseInt(duration_s, 10) || 0); + const ev = await pool.query( + `SELECT COALESCE(MAX(event_id), 0) + 1 AS next FROM playout_scte_breaks WHERE channel_id = $1`, + [req.channel.id]); + const eventId = ev.rows[0].next; + const { rows } = await pool.query( + `INSERT INTO playout_scte_breaks (channel_id, duration_s, event_id, type, status, created_by) + VALUES ($1,$2,$3,$4,'pending',$5) RETURNING *`, + [req.channel.id, dur, eventId, type, req.user?.id || null]); + try { + const out = await fireScteBreak(req.channel, rows[0]); + const updated = await pool.query('SELECT * FROM playout_scte_breaks WHERE id = $1', [rows[0].id]); + res.json({ break: updated.rows[0], engine: out }); + } catch (err) { + await pool.query(`UPDATE playout_scte_breaks SET status = 'cancelled', updated_at = NOW() WHERE id = $1`, + [rows[0].id]).catch(() => {}); + return res.status(502).json({ error: 'Sidecar unreachable: ' + err.message }); + } + } catch (err) { next(err); } +}); + +router.delete('/channels/:id/scte/:scteId', requireChannelEdit, async (req, res, next) => { + try { + const { rows } = await pool.query( + `UPDATE playout_scte_breaks SET status = 'cancelled', updated_at = NOW() + WHERE id = $1 AND channel_id = $2 AND status = 'pending' RETURNING id`, + [req.params.scteId, req.channel.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Pending break not found' }); + res.json({ cancelled: true }); + } catch (err) { next(err); } +}); + async function loadChannelForBody(req, res, next) { const channelId = req.body.channel_id || req.query.channel_id; if (!channelId) return res.status(400).json({ error: 'channel_id is required' }); diff --git a/services/mam-api/src/scheduler.js b/services/mam-api/src/scheduler.js index 143297e..a929bc1 100644 --- a/services/mam-api/src/scheduler.js +++ b/services/mam-api/src/scheduler.js @@ -9,7 +9,7 @@ import pool from './db/pool.js'; import { syncToAmpp } from './routes/upload.js'; -import { restartChannel } from './routes/playout.js'; +import { restartChannel, fireScteBreak } from './routes/playout.js'; import { INTERNAL_TOKEN } from './middleware/auth.js'; const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10); @@ -289,6 +289,32 @@ async function playoutHealthTick(client) { } catch (e) { console.warn(`[scheduler] as-run write failed for ${ch.id}: ${e.message}`); } + + // SCTE-35: fire any pending scheduled breaks now due. Position-based + // breaks (playlist_pos) fire when the engine reaches that item; wall-clock + // breaks fire at scheduled_at. Failures mark the break cancelled so a bad + // break never wedges the sweep. + try { + const { rows: due } = await client.query( + `SELECT * FROM playout_scte_breaks + WHERE channel_id = $1 AND status = 'pending' + AND ( (scheduled_at IS NOT NULL AND scheduled_at <= NOW()) + OR (playlist_pos IS NOT NULL AND playlist_pos <= $2) ) + ORDER BY created_at ASC`, + [ch.id, (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1] + ); + for (const brk of due) { + try { await fireScteBreak(ch, brk); } + catch (e2) { + console.warn(`[scheduler] scte fire failed for break ${brk.id}: ${e2.message}`); + await client.query( + `UPDATE playout_scte_breaks SET status = 'cancelled', updated_at = NOW() WHERE id = $1`, + [brk.id]).catch(() => {}); + } + } + } catch (e) { + console.warn(`[scheduler] scte sweep failed for ${ch.id}: ${e.message}`); + } } catch (err) { // When last_heartbeat_at is NULL (channel just spawned), fall back to // updated_at (set to NOW() by spawnChannelSidecar). This prevents a diff --git a/services/playout/src/index.js b/services/playout/src/index.js index accbd2d..646f18e 100644 --- a/services/playout/src/index.js +++ b/services/playout/src/index.js @@ -44,6 +44,20 @@ app.post('/transport/skip', async (req, res) => { try { res.json(await playout app.post('/transport/pause', async (req, res) => { try { res.json(await playoutManager.pause()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/transport/resume', async (req, res) => { try { res.json(await playoutManager.resume()); } catch (e) { res.status(500).json({ error: e.message }); } }); +// Fire an SCTE-35 ad-break splice on the live output. Body: +// { eventId, type: 'splice_insert'|'immediate'|'splice_out'|'splice_in', durationS } +// Returns the active-break descriptor (or the splice_in ack) so the mam-api can +// stamp the as-run log. +app.post('/scte/trigger', (req, res) => { + try { + const { eventId = 1, type = 'splice_insert', durationS = 30 } = req.body || {}; + res.json(playoutManager.triggerScte({ eventId, type, durationS })); + } catch (err) { + console.error('[playout] /scte/trigger error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + app.get('/status', (req, res) => res.json(playoutManager.getStatus())); // Auto-start: when the sidecar is spawned by mam-api with channel env, bring up diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js index abb3ba9..9c63512 100644 --- a/services/playout/src/playout-manager.js +++ b/services/playout/src/playout-manager.js @@ -83,8 +83,13 @@ export class PlayoutManager { currentClip: null, startedAt: null, lastError: null, + // SCTE-35: the currently-active ad break, if any. Set by triggerScte and + // cleared by a timer when the break window elapses. Surfaced in getStatus + // so the UI can render an "in break" state + countdown. + scteActive: null, // { eventId, type, durationS, firedAt(iso), endsAt(iso) } }; this._advanceTimer = null; + this._scteTimer = null; this._hlsProc = null; // standalone ffmpeg re-mux child process this._hlsRestartTimer = null; } @@ -305,6 +310,7 @@ export class PlayoutManager { async stopChannel() { this._clearAdvance(); + this._clearScte(); this.state.running = false; // set first so the ffmpeg exit handler won't respawn this._stopHlsRemux(); try { await this.amcp.send(`STOP ${CHANNEL}-${FG_LAYER}`); } catch (_) {} @@ -430,6 +436,92 @@ export class PlayoutManager { return this.getStatus(); } + // ── SCTE-35 ad-break splice ────────────────────────────────────────────── + // Act on an ad-break cue. The mam-api owns scheduling + persistence; the + // sidecar performs the actual splice on the live output and tracks the active + // break locally so /status can report a countdown. + // + // What this does today, end to end: + // 1. Records the break as the active break (UI reads it from /status for the + // "SCTE BREAK" on-air state + countdown). A timer clears it after + // durationS so the UI returns to normal automatically. + // 2. Emits an operator-visible log line at the splice point. + // 3. Returns the cue descriptor so the mam-api can stamp the as-run log. + // + // ── Real in-stream SCTE-35 injection (the injection point) ───────────────── + // True SCTE-35 requires inserting a splice_info_section into the OUTPUT + // transport stream on a dedicated SCTE-35 PID, time-aligned to the splice + // point (pts_time). CasparCG 2.3's FFMPEG consumer does NOT expose an SCTE-35 + // muxer option, so we cannot ask CasparCG to carry the cue. The two viable + // production paths, neither of which the current single-process CasparCG + // output supports out of the box, are: + // + // (a) ffmpeg-based output: when the primary consumer is replaced by a + // Node-spawned ffmpeg (as the HLS preview re-mux already is), mux an + // SCTE-35 data stream. ffmpeg can pass through a -map'd scte35 PID, and + // for HLS can emit #EXT-X-CUE-OUT/#EXT-X-CUE-IN (or DATERANGE) tags. The + // hook would build the splice_insert binary section here and feed it to + // that ffmpeg via a data input / sidecar packetizer. + // (b) A downstream SCTE-35 inserter (e.g. an OTT packager / encoder that + // accepts cue triggers over its own API). The hook would POST the cue + // to that device's API at the splice instant. + // + // Until one of those output paths is wired, the splice is faithfully + // scheduled, triggered, countdown-tracked, and as-run-logged — but the cue is + // NOT yet embedded in the SRT/RTMP/SDI/NDI elementary stream. Replace the body + // of _injectScteCue below to enable real injection. + triggerScte({ eventId = 1, type = 'splice_insert', durationS = 30 } = {}) { + const firedAt = new Date(); + const endsAt = new Date(firedAt.getTime() + (durationS > 0 ? durationS * 1000 : 0)); + + // Build + emit the cue on the output (TODO injection point — see above). + this._injectScteCue({ eventId, type, durationS }); + + // A splice_in / return-to-network ends any active break immediately. + if (type === 'splice_in') { + this._clearScte(); + console.log(`[playout][scte] splice_in event=${eventId} — return to network`); + return { eventId, type, durationS: 0, firedAt: firedAt.toISOString(), endsAt: firedAt.toISOString() }; + } + + this.state.scteActive = { + eventId, type, durationS, + firedAt: firedAt.toISOString(), + endsAt: endsAt.toISOString(), + }; + console.log(`[playout][scte] ${type} event=${eventId} duration=${durationS}s — splice OUT at ${firedAt.toISOString()}`); + + // Auto-clear the active break when its window elapses (splice_out is + // open-ended, so it stays until an explicit splice_in). + if (this._scteTimer) { clearTimeout(this._scteTimer); this._scteTimer = null; } + if (durationS > 0 && type !== 'splice_out') { + this._scteTimer = setTimeout(() => { + this._scteTimer = null; + console.log(`[playout][scte] break event=${eventId} ended — return to network`); + this._clearScte(); + }, durationS * 1000); + } + return this.state.scteActive; + } + + // The SCTE-35 cue packetizer / injection hook. See the long comment on + // triggerScte for why this is a stub on the current CasparCG output path and + // what to put here to enable real in-stream injection. + _injectScteCue({ eventId, type, durationS }) { + // TODO(scte-injection): build the splice_info_section (splice_insert with + // splice_event_id=eventId, out_of_network_indicator per type, + // break_duration=durationS*90000 ticks) and emit it on the output's SCTE-35 + // PID via an ffmpeg-based output, or POST it to a downstream inserter's API. + // No-op until the output path supports it; the scheduling/trigger/as-run + // path above is fully functional regardless. + return null; + } + + _clearScte() { + if (this._scteTimer) { clearTimeout(this._scteTimer); this._scteTimer = null; } + this.state.scteActive = null; + } + _reportAsRunStart(item) { // The mam-api owns the as-run table; the sidecar just logs locally. The API // polls /status and writes as-run rows on clip change. Keeping the DB write @@ -451,6 +543,7 @@ export class PlayoutManager { loop: this.state.loop, startedAt: this.state.startedAt, lastError: this.state.lastError, + scteActive: this.state.scteActive || null, }; } } diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index 01011a5..a5535fd 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -1,15 +1,25 @@ // screens-playout.jsx — Master Control (MCR) playout page. // -// Operator workflow (Phase A — playlist player): -// 1. Create / pick a channel (output target: SRT / RTMP / NDI / DeckLink). -// 2. Start the channel → spawns the CasparCG sidecar, brings up the output. -// 3. Drag assets from the media bin into the playlist; reorder by dragging. -// Each item stages from S3 to the CasparCG /media volume in the background. -// 4. Hit PLAY → the engine walks the playlist gaplessly. PAUSE / SKIP / STOP -// transport. As-run log records what aired. +// Redesigned (timeline-centric): styled PGM monitor with audio meters + +// timecode, transport bar, SCTE-35 break panel, now-playing + up-next, a +// horizontal timeline, and a slide-in as-run drawer — all wired to the real +// /api/v1/playout backend (no mock data). // -// Talks to /api/v1/playout via window.ZAMPP_API.fetch. Native HTML5 drag-drop, -// no extra library. Components are plain globals (esbuild bundle:false). +// API wiring summary: +// - Channel CRUD: GET/POST/DELETE /playout/channels +// - Channel lifecycle: POST /channels/:id/start|stop +// - Engine status: GET /channels/:id/status (polls 4s; carries scteActive) +// - HLS preview: GET /channels/:id/hls/index.m3u8 (hls.js + error recovery) +// - Playlist CRUD: GET/POST /playlists, GET/POST/PUT /playlists/:id/items +// - Transport: POST /channels/:id/play|pause|resume|skip|stop-playback +// - As-run log: GET /channels/:id/asrun (polls 5s, drawer) +// - Staging: POST /items/:id/stage (retry) +// - SCTE-35: GET/POST /channels/:id/scte, POST /channels/:id/scte/trigger, +// DELETE /channels/:id/scte/:id +// +// esbuild bundle:false + jsx:transform — no import statements. +// Globals: React, Icon, window.ZAMPP_API, window.ZAMPP_DATA, window.Hls, +// window.useConfirm/ConfirmModal. const PO_OUTPUTS = [ { value: 'srt', label: 'SRT' }, @@ -24,7 +34,7 @@ async function poFetch(path, opts) { return window.ZAMPP_API.fetch('/playout' + path, opts); } -// ── Helpers ────────────────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── function fmtDuration(secs) { if (!secs || secs < 0) return '—'; @@ -37,6 +47,16 @@ function fmtDuration(secs) { return h > 0 ? `${h}:${mm}:${ssStr}` : `${m}:${ssStr}`; } +function fmtTimecode(secs) { + // HH:MM:SS:FF at 29.97 (rounded) + const s = Math.floor(secs); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + const ff = Math.floor(((secs - s) * 29.97)); + return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}:${String(ff).padStart(2,'0')}`; +} + function itemEffectiveDuration(it) { const total = (it.asset_duration_ms || 0) / 1000; const inPt = it.in_point != null ? Number(it.in_point) : 0; @@ -44,7 +64,34 @@ function itemEffectiveDuration(it) { return Math.max(0, outPt - inPt); } -// ── Output-config sub-form (varies by output type) ─────────────────────────── +// ── Clock ───────────────────────────────────────────────────────────────────── +function LiveClock() { + const [time, setTime] = React.useState(new Date()); + React.useEffect(() => { + const id = setInterval(() => setTime(new Date()), 500); + return () => clearInterval(id); + }, []); + const pad = n => String(n).padStart(2, '0'); + return ( + {pad(time.getHours())}:{pad(time.getMinutes())}:{pad(time.getSeconds())} + ); +} + +// ── Elapsed timer ───────────────────────────────────────────────────────────── +function useElapsed(startedAt) { + const [elapsed, setElapsed] = React.useState(0); + React.useEffect(() => { + if (!startedAt) { setElapsed(0); return; } + const base = new Date(startedAt).getTime(); + const tick = () => setElapsed(Math.max(0, (Date.now() - base) / 1000)); + tick(); + const id = setInterval(tick, 100); + return () => clearInterval(id); + }, [startedAt]); + return elapsed; +} + +// ── Output-config sub-form ──────────────────────────────────────────────────── function OutputConfigFields({ type, config, onChange }) { const set = (k, v) => onChange({ ...config, [k]: v }); if (type === 'decklink') { @@ -65,7 +112,6 @@ function OutputConfigFields({ type, config, onChange }) { ); } - // srt / rtmp return (
@@ -92,7 +138,7 @@ function OutputConfigFields({ type, config, onChange }) { ); } -// ── Channel create modal ───────────────────────────────────────────────────── +// ── Channel create modal ────────────────────────────────────────────────────── function ChannelCreate({ onClose, onCreated }) { const PROJECTS = window.ZAMPP_DATA?.PROJECTS || []; const [name, setName] = React.useState(''); @@ -162,7 +208,7 @@ function ChannelCreate({ onClose, onCreated }) { ); } -// ── Media bin: assets draggable into the playlist ──────────────────────────── +// ── Media bin ───────────────────────────────────────────────────────────────── function MediaBin({ projectId }) { const ASSETS = (window.ZAMPP_DATA?.ASSETS || []).filter(a => !projectId || a.project_id === projectId); @@ -195,14 +241,14 @@ function MediaBin({ projectId }) { ); } -// ── Staging progress bar ────────────────────────────────────────────────────── +// ── Staging bar ─────────────────────────────────────────────────────────────── function StagingBar({ status }) { return ( ); } -// ── Transport bar ──────────────────────────────────────────────────────────── -function Transport({ channel, playlistId, items, onStatus }) { - const [busy, setBusy] = React.useState(false); - const act = async (fn) => { setBusy(true); try { await fn(); } catch (e) { alert(e.message); } finally { setBusy(false); } }; +// ── Audio meter ─────────────────────────────────────────────────────────────── +// Simulated VU meter — real values would require a WebAudio analyzer on the +// HLS stream. For now, animate a plausible signal when on-air. (Named PoAudioMeter +// to avoid colliding with the global AudioMeter from visuals.jsx.) +function PoAudioMeter({ onAir }) { + const canvasRef = React.useRef(null); + const rafRef = React.useRef(null); - const notReady = items.filter(i => i.media_status !== 'ready').length; - const canPlay = channel.status === 'running' && !busy && !!playlistId && notReady === 0; - - const play = () => act(async () => { - const r = await poFetch('/channels/' + channel.id + '/play', { - method: 'POST', body: JSON.stringify({ playlist_id: playlistId }), - }); - onStatus && onStatus(r); - }); - const pause = () => act(() => poFetch('/channels/' + channel.id + '/pause', { method: 'POST' })); - const resume = () => act(() => poFetch('/channels/' + channel.id + '/resume', { method: 'POST' })); - const skip = () => act(() => poFetch('/channels/' + channel.id + '/skip', { method: 'POST' })); - const stopPb = () => act(() => poFetch('/channels/' + channel.id + '/stop-playback', { method: 'POST' })); - - const live = channel.status === 'running'; - return ( -
- - - - - -
- ); -} - -// ── Elapsed timer ───────────────────────────────────────────────────────────── -function useElapsed(startedAt) { - const [elapsed, setElapsed] = React.useState(0); React.useEffect(() => { - if (!startedAt) { setElapsed(0); return; } - const base = new Date(startedAt).getTime(); - const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - base) / 1000))); - tick(); - const id = setInterval(tick, 500); - return () => clearInterval(id); - }, [startedAt]); - return elapsed; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let levelL = 0, levelR = 0; + let peakL = 0, peakR = 0; + let peakHoldL = 0, peakHoldR = 0; + let frame = 0; + + const draw = () => { + frame++; + const w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + + if (onAir) { + const target = 0.55 + Math.sin(frame * 0.07) * 0.25 + (Math.random() - 0.5) * 0.15; + levelL += (target - levelL) * 0.25; + levelR += ((target + (Math.random() - 0.5) * 0.08) - levelR) * 0.25; + levelL = Math.max(0, Math.min(1, levelL)); + levelR = Math.max(0, Math.min(1, levelR)); + peakL = Math.max(peakL * 0.995, levelL); + peakR = Math.max(peakR * 0.995, levelR); + if (levelL >= peakHoldL) { peakHoldL = levelL; } else { peakHoldL *= 0.992; } + if (levelR >= peakHoldR) { peakHoldR = levelR; } else { peakHoldR *= 0.992; } + } else { + levelL *= 0.9; levelR *= 0.9; + peakHoldL *= 0.9; peakHoldR *= 0.9; + } + + const barW = Math.floor((w - 6) / 2); + const drawBar = (x, level, peakHold) => { + const fillH = Math.floor(level * h); + ctx.fillStyle = 'rgba(255,255,255,0.05)'; + ctx.fillRect(x, 0, barW, h); + const greenH = Math.min(fillH, Math.floor(h * 0.7)); + ctx.fillStyle = '#22c55e'; + ctx.fillRect(x, h - greenH, barW, greenH); + if (fillH > Math.floor(h * 0.7)) { + const yw = Math.min(fillH - Math.floor(h * 0.7), Math.floor(h * 0.2)); + ctx.fillStyle = '#eab308'; + ctx.fillRect(x, h - Math.floor(h * 0.7) - yw, barW, yw); + } + if (fillH > Math.floor(h * 0.9)) { + const rw = fillH - Math.floor(h * 0.9); + ctx.fillStyle = '#ef4444'; + ctx.fillRect(x, h - Math.floor(h * 0.9) - rw, barW, rw); + } + const peakY = Math.floor((1 - peakHold) * h); + ctx.fillStyle = peakHold > 0.9 ? '#ef4444' : peakHold > 0.7 ? '#eab308' : '#22c55e'; + ctx.fillRect(x, peakY, barW, 2); + }; + drawBar(0, levelL, peakHoldL); + drawBar(barW + 6, levelR, peakHoldR); + + rafRef.current = requestAnimationFrame(draw); + }; + + rafRef.current = requestAnimationFrame(draw); + return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; + }, [onAir]); + + return ( + + ); } -function fmtElapsed(secs) { - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - const s = secs % 60; - return (h > 0 ? String(h).padStart(2,'0') + ':' : '') + - String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0'); -} - -// ── Program monitor ────────────────────────────────────────────────────────── -function ProgramMonitor({ channel, engine }) { - const videoRef = React.useRef(null); - const hlsRef = React.useRef(null); - const onAir = channel.status === 'running'; +// ── PGM monitor ─────────────────────────────────────────────────────────────── +function ProgramMonitor({ channel, engine, elapsed }) { + const videoRef = React.useRef(null); + const hlsRef = React.useRef(null); + const onAir = channel.status === 'running'; // Load the playlist through the API (not the static /media/live path): the // public reverse proxy caches the static .m3u8 with a multi-second TTL and // ignores no-store, which starved hls.js's reloads of the live edge and kept // the monitor black. /api/ isn't proxy-cached, so this always returns fresh. const previewUrl = `/api/v1/playout/channels/${channel.id}/hls/index.m3u8`; - const elapsed = useElapsed(engine && engine.currentItemStartedAt); + const scte = engine && engine.scteActive; React.useEffect(() => { const vid = videoRef.current; @@ -385,7 +450,6 @@ function ProgramMonitor({ channel, engine }) { // Tear down any previous HLS instance before re-evaluating. if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } - if (!onAir) { vid.src = ''; return; } if (window.Hls && window.Hls.isSupported()) { @@ -413,14 +477,12 @@ function ProgramMonitor({ channel, engine }) { // Resilient recovery. Without this, the FIRST fatal hls.js error (a // buffer stall on the live edge, a media/decode error, or a transient // fragment/playlist load error against the rewinding live playlist) - // permanently halts playback and the monitor goes black — which is - // exactly the "flashes a frame then stays black" symptom: hls.js renders - // a fragment or two, hits an unrecovered error, and never resumes. We - // distinguish error types and recover in place rather than tearing down. + // permanently halts playback and the monitor goes black — exactly the + // "flashes a frame then stays black" symptom. We distinguish error types + // and recover in place rather than tearing down. let recoverCount = 0; hls.on(window.Hls.Events.ERROR, (_evt, data) => { if (!data.fatal) { - // Non-fatal buffer stalls: nudge hls.js back to the live edge. if (data.details === window.Hls.ErrorDetails.BUFFER_STALLED_ERROR) { try { hls.startLoad(); } catch (_) {} } @@ -428,34 +490,27 @@ function ProgramMonitor({ channel, engine }) { } switch (data.type) { case window.Hls.ErrorTypes.NETWORK_ERROR: - // Playlist/fragment load errors against the live edge are usually - // transient (segment rotated or mid-write). Re-arm the loader. try { hls.startLoad(); } catch (_) {} break; case window.Hls.ErrorTypes.MEDIA_ERROR: - // Decode/buffer-append failures: flush + rebuild the buffer. recoverCount += 1; if (recoverCount <= 3) { try { hls.recoverMediaError(); } catch (_) {} } else { - // Repeated media errors: full reload of the source from scratch. recoverCount = 0; try { hls.destroy(); } catch (_) {} if (hlsRef.current === hls) hlsRef.current = null; } break; default: - // Unrecoverable: drop the instance so a re-render can re-init. try { hls.destroy(); } catch (_) {} if (hlsRef.current === hls) hlsRef.current = null; } }); - // A stalled