Merge feat/playout-mcr into main

Playout/MCR, as-run log, redesigned dashboard, capture CIFS/growing-files,
SDI settings, cluster Add Node wizard, homepage refresh.

# Conflicts:
#	services/mam-api/src/routes/cluster.js
#	services/mam-api/src/routes/playout.js
#	services/mam-api/src/scheduler.js
#	services/playout/Dockerfile
#	services/playout/entrypoint.sh
#	services/web-ui/public/screens-home.jsx
This commit is contained in:
Zac Gaetano 2026-05-31 17:46:12 -04:00
commit 43011bd794
25 changed files with 2102 additions and 596 deletions

View file

@ -103,6 +103,7 @@ services:
# worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to
# zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here.
worker-l4:
profiles: [gpu]
build:
context: ./services/worker
dockerfile: Dockerfile.gpu

View file

@ -0,0 +1,148 @@
# Storage Settings Warning + Growing-Files SMB Auth + Per-Recorder Growing Mode
**Date:** 2026-05-31
**Branch:** `feat/playout-mcr` (Forgejo: WildDragonLLC/dragonflight)
**Status:** Approved, ready for implementation plan
---
## Scope
Three related refinements to the Settings page and growing-files capture pipeline:
1. **Storage warning header** — a prominent set-once warning at the top of the Storage settings section.
2. **Growing-files SMB credentials + system CIFS mount** — store an SMB username/password and have the capture stack mount the growing share itself (Approach A).
3. **Per-recorder growing mode** — remove the global "capture writes to local SMB share first" toggle; make growing-files mode a per-recorder setting.
All changes live on the `growing-files` / storage-settings path. No playout changes (handled separately).
---
## Background (current state)
- **Settings storage:** single key/value `settings(key TEXT PRIMARY KEY, value TEXT, updated_at)` table (migration 006). Secrets like `s3_secret_key` are stored but `GET /settings/s3` returns only `s3_secret_key_exists` (never the value).
- **Growing settings UI:** `GrowingSettingsCard` in `services/web-ui/public/screens-admin.jsx` (rendered by `StorageSection` alongside `MountHealthStrip` and `S3SettingsCard`). Current keys: `growing_enabled`, `growing_path`, `growing_smb_url`, `growing_promote_after_seconds`.
- **Settings API:** `services/mam-api/src/routes/settings.js``GET/PUT /settings/growing` over `GROWING_KEYS`.
- **Global enable today:** the `growing_enabled` setting (checkbox labelled "Capture writes to the local SMB share first; Premier can edit while it's still growing"). `recorders.js` reads this global key at recorder-start and passes `GROWING_ENABLED=true/false` to the capture container.
- **Capture write path:** `services/capture/src/capture-manager.js` reads `process.env.GROWING_ENABLED` and `GROWING_PATH` (default `/growing`). When on, it writes the master to `/growing/{projectId}/{clipName}.{ext}` instead of streaming to S3; the promotion worker uploads to S3 after stop.
- **Current mount model:** `/growing` is a pre-mounted host bind-mount; the app never authenticates to SMB.
- **Per-recorder column already exists:** migration 014 added `recorders.growing_enabled BOOLEAN DEFAULT NULL` ("NULL = use global"), but recorder-start logic ignores it and the new-recorder modal does not expose it.
---
## Part 1 — Storage warning header
Add a danger-styled banner at the **top of `StorageSection()`**, above `MountHealthStrip`.
- Visual: full-width banner, danger token styling (`--danger` border + subtle danger background), alert icon, bold uppercase text.
- Exact copy:
> **⚠ WARNING — THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT. CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS. PLEASE USE WITH CAUTION.**
- Pure presentational; no backend, no dismiss state (always visible).
**Files:** `services/web-ui/public/screens-admin.jsx` (and a small style rule in the appropriate CSS file if needed).
---
## Part 2 — Growing-files SMB credentials + system CIFS mount (Approach A)
The growing share is **shared infrastructure**, so the SMB connection config is global.
### New settings keys
| Key | Purpose | Notes |
|-----|---------|-------|
| `growing_smb_mount` | CIFS source for the system mount, e.g. `//10.0.0.25/mam-growing` | Distinct from `growing_smb_url` |
| `growing_smb_username` | SMB user | Returned in GET (not secret) |
| `growing_smb_password` | SMB password | **Write-only** — never returned |
| `growing_smb_vers` *(optional)* | CIFS protocol version, default `3.0` | Avoids mount negotiation failures |
`growing_smb_url` (the `smb://…` string) is retained unchanged as the **editor-facing** display value (Premiere connect string).
### Settings API (`settings.js`)
- Extend `GROWING_KEYS` with the new keys (except the password is handled specially).
- `GET /settings/growing`: return `growing_smb_mount`, `growing_smb_username`, `growing_smb_vers`, and `growing_smb_password_exists: boolean`**never** the password value. (Mirror the existing `s3_secret_key_exists` pattern.)
- `PUT /settings/growing`: upsert each provided key. For `growing_smb_password`, only write it when a non-empty value is provided (an empty/omitted field leaves the stored password unchanged). Provide a way to clear it (explicit empty sentinel or a "clear" affordance) — see Resolved decisions below.
### Settings UI (`GrowingSettingsCard`)
Add three fields to the card:
- **SMB mount (CIFS):** text input bound to `growing_smb_mount`, placeholder `//10.0.0.25/mam-growing`.
- **SMB username:** text input bound to `growing_smb_username`.
- **SMB password:** masked password input. Shows a "saved" indicator when `growing_smb_password_exists` is true; typing a new value replaces it; leaving it blank keeps the existing one. `autoComplete="new-password"`.
### Capture image (`services/capture/Dockerfile`)
Add `cifs-utils` to the installed packages so `mount -t cifs` is available inside the capture container.
### Capture-manager (`capture-manager.js`)
On capture start, when growing mode is active **and** `GROWING_SMB_MOUNT` is set:
1. Write a root-only credentials file (e.g. `/run/smb-creds`, mode `0600`) containing:
```
username=<GROWING_SMB_USERNAME>
password=<GROWING_SMB_PASSWORD>
```
(Credentials go in the file, **not** the mount command line, to avoid `ps`/log exposure.)
2. `mkdir -p $GROWING_PATH` then `mount -t cifs $GROWING_SMB_MOUNT $GROWING_PATH -o credentials=/run/smb-creds,uid=0,gid=0,file_mode=0664,dir_mode=0775,vers=$GROWING_SMB_VERS`.
3. If the mount succeeds → proceed writing the master to `$GROWING_PATH/...` (existing behaviour).
4. If the mount **fails** → log the error and fall back to S3 streaming (`growingPath = null`), so a recording is never lost.
5. On capture stop/cleanup, unmount `$GROWING_PATH` (best-effort; ignore "not mounted").
Mount isolation: each recorder runs in its **own** capture container, so each container mounts CIFS at its own private `/growing` — no cross-recorder mount conflicts, no ref-counting needed.
### Recorder start (`recorders.js`)
- Pass new env to the spawned capture container: `GROWING_SMB_MOUNT`, `GROWING_SMB_USERNAME`, `GROWING_SMB_PASSWORD`, `GROWING_SMB_VERS` (read from the `settings` table at start).
- The dynamically-spawned capture container must get `/growing` as an **empty mountpoint** (not a host bind-mount) so the in-container CIFS mount lands cleanly. Confirm/adjust the container spec accordingly. The container is already privileged (required for `mount`).
### Security notes
- The password is stored plaintext in the `settings` table, identical to the existing `s3_secret_key` handling — acceptable within this app's current secret model.
- The password reaches the capture container as an env var (visible via `docker inspect`), same as S3 keys already are. The credentials **file** (not the command line) is used for the actual mount.
---
## Part 3 — Per-recorder growing mode (remove the global toggle)
### Remove global enable
- Delete the global "Capture writes to the local SMB share first…" checkbox (`growing_enabled` key) from `GrowingSettingsCard`. The card no longer carries a global on/off — it is **infrastructure-only**: container mount path, SMB URL (editor), SMB mount + credentials, promote threshold.
- The `growing_enabled` settings *key* is retired from the UI. (It may remain in the table harmlessly; recorder-start no longer reads it.)
### Per-recorder semantics
- Reuse the existing `recorders.growing_enabled BOOLEAN` column. New semantics (no global to defer to): `TRUE` = this recorder uses growing-files mode; `NULL`/`FALSE` = off.
- `recorders.js` recorder-start: read the **recorder's own** `growing_enabled` (defaulting `NULL`→off) and set `GROWING_ENABLED` for the capture container from that, instead of the global setting.
- Add `growing_enabled` to `RECORDER_FIELDS` so create/update accept it.
### UI
- **New-recorder modal** (`modal-new-recorder.jsx`): add a "Growing-files mode" toggle that sets `growing_enabled` on the created recorder (default off).
- **Recorder edit** (wherever recorders are edited): same toggle.
- Helper text on the toggle notes that growing-files requires the SMB share to be configured in Settings → Storage.
### Fallback
If a recorder has `growing_enabled = true` but `growing_smb_mount` is not configured globally, capture logs a warning and falls back to S3 streaming (same fallback path as a failed mount). Recording is never blocked.
---
## Files changed
| File | Change |
|------|--------|
| `services/web-ui/public/screens-admin.jsx` | Storage warning banner; SMB mount/username/password fields in `GrowingSettingsCard`; remove global growing-enable checkbox |
| `services/web-ui/public/modal-new-recorder.jsx` | Per-recorder "Growing-files mode" toggle |
| `services/mam-api/src/routes/settings.js` | New growing SMB keys; write-only password (`growing_smb_password_exists`) |
| `services/mam-api/src/routes/recorders.js` | Read per-recorder `growing_enabled`; pass SMB env to capture; `RECORDER_FIELDS` += `growing_enabled`; empty `/growing` mountpoint |
| `services/capture/Dockerfile` | Add `cifs-utils` |
| `services/capture/src/capture-manager.js` | CIFS mount-on-start (creds file), unmount-on-stop, fallback to S3 on failure |
| CSS (storage warning / fields) | Minor styles if needed |
No DB migration required (the `recorders.growing_enabled` column already exists; new settings are key/value rows).
---
## Resolved decisions
- **Clearing the SMB password:** `PUT /settings/growing` treats a field value of the literal sentinel `""` with an explicit `growing_smb_password_clear: true` flag as "remove the stored password"; a blank field with no clear flag leaves it unchanged. (Keeps the common "don't retype the password on every save" UX while still allowing removal.)
- **CIFS version:** default `growing_smb_vers = 3.0`; overridable via settings to support older NAS targets.
- **Recorders already recording when the toggle changes:** the per-recorder `growing_enabled` is read at **start** only; changing it mid-recording has no effect on the active session (consistent with how all recorder encode settings already behave).
---
## Out of scope (deferred)
- Encrypting secrets at rest (the app's existing model stores `s3_secret_key` in plaintext; SMB password follows the same model).
- A global "growing-files master kill switch" (removed by design — control is now per-recorder).
- Exposing `growing_retention_days` in the UI (seeded in DB, still unsurfaced; unrelated to this work).
- Playout HLS preview fix (handled by a separate parallel effort).

View file

@ -67,10 +67,14 @@ RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
FROM node:20-bookworm
# Runtime deps for compiled ffmpeg libs
# Runtime deps for compiled ffmpeg libs.
# cifs-utils provides mount.cifs so growing-files capture can mount the SMB
# landing-zone share inside the (privileged) container at start (Approach A).
# util-linux supplies mount/umount/mountpoint.
RUN apt-get update && apt-get install -y --no-install-recommends \
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
cifs-utils util-linux \
&& rm -rf /var/lib/apt/lists/*
# Copy compiled ffmpeg/ffprobe

View file

@ -1,5 +1,5 @@
import { spawn } from 'child_process';
import { mkdirSync } from 'node:fs';
import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';
@ -9,11 +9,76 @@ const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
// (the host-bound /growing volume is used instead, or S3 streaming if growing
// is off).
const GROWING_SMB_MOUNT = process.env.GROWING_SMB_MOUNT || '';
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
const SMB_CREDS_FILE = '/run/smb-creds';
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
// mounted, or a host bind-mount is present).
function isMounted(path) {
try { execFileSync('mountpoint', ['-q', path]); return true; }
catch { return false; }
}
// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
// file (NOT the command line) so they never appear in `ps`/process listings.
// Returns true on success (or if already mounted), false on failure — callers
// fall back to S3 streaming so a recording is never lost.
function mountGrowingShare() {
if (!GROWING_SMB_MOUNT) return false;
try {
if (isMounted(GROWING_PATH)) {
console.log('[capture] growing share already mounted at', GROWING_PATH);
return true;
}
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
writeFileSync(
SMB_CREDS_FILE,
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
{ mode: 0o600 }
);
const opts = [
`credentials=${SMB_CREDS_FILE}`,
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
`vers=${GROWING_SMB_VERS}`,
].join(',');
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
{ stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
return true;
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr);
return false;
}
}
// Best-effort unmount on session stop. Ignores "not mounted".
function unmountGrowingShare() {
if (!GROWING_SMB_MOUNT) return;
try {
if (isMounted(GROWING_PATH)) {
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] unmounted growing share at', GROWING_PATH);
}
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
console.warn('[capture] growing share unmount failed (ignored):', stderr);
}
}
// ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration.
@ -283,7 +348,15 @@ class CaptureManager {
// Growing-files: write master to the local SMB share instead of streaming
// to S3. Path is relative to the container's GROWING_PATH mount.
const growingPath = GROWING_ENABLED
//
// Approach A: if a CIFS source is configured, mount it now. A mount failure
// is non-fatal — we fall back to S3 streaming so the recording is never
// lost.
let growingActive = GROWING_ENABLED;
if (growingActive && GROWING_SMB_MOUNT) {
if (!mountGrowingShare()) growingActive = false; // fall back to S3
}
const growingPath = growingActive
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
: null;
if (growingPath) {
@ -455,6 +528,11 @@ class CaptureManager {
if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
// Release the CIFS mount (best-effort) once the ffmpeg writers are done with
// it. The promotion worker reads the staged file from the host/S3 side, not
// through this container's mount, so unmounting here is safe.
unmountGrowingShare();
try {
const uploadPromises = [currentSession.uploads.hires];
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);

View file

@ -734,11 +734,14 @@ router.get('/:id/live-path', async (req, res, next) => {
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const asset = a.rows[0];
if (asset.status !== 'live') return res.status(409).json({ error: 'Asset is not currently growing', status: asset.status });
const s = await pool.query(`SELECT key, value FROM settings WHERE key IN ('growing_enabled','growing_smb_url')`);
// Growing-files mode is now per-recorder (recorders.growing_enabled), so we
// no longer gate on the removed global `growing_enabled` setting. A
// status='live' asset already proves a growing recorder is producing this
// file; we only need the editor-facing SMB URL to build the UNC path.
const s = await pool.query(`SELECT key, value FROM settings WHERE key = 'growing_smb_url'`);
const cfg = {};
for (const { key, value } of s.rows) cfg[key] = value;
if (cfg.growing_enabled !== 'true') return res.status(409).json({ error: 'Growing-files mode is disabled' });
if (!cfg.growing_smb_url) return res.status(409).json({ error: 'No SMB URL configured — set growing_smb_url in Settings' });
if (!cfg.growing_smb_url) return res.status(409).json({ error: 'No SMB URL configured — set the editor SMB URL in Settings → Storage' });
const rec = await pool.query(
`SELECT recording_container FROM recorders WHERE current_session_id = $1 ORDER BY updated_at DESC LIMIT 1`,
[asset.id]

View file

@ -1,9 +1,27 @@
import express from 'express';
import http from 'http';
import pool from '../db/pool.js';
import { requireAdmin } from '../middleware/auth.js';
const router = express.Router();
// GET /onboard-info admin-only. Supplies the Add Node wizard with the bits it
// needs to build a `curl … | bash` onboarding command: the primary API URL the
// remote node-agent should heartbeat to, the raw URL of onboard-node.sh, and
// the deploy branch. apiUrl is a best guess the UI lets the operator edit.
router.get('/onboard-info', requireAdmin, (req, res) => {
const branch = process.env.DEPLOY_BRANCH || 'main';
const apiUrl = process.env.PUBLIC_API_URL
|| `${req.protocol}://${req.hostname}:${process.env.API_PORT || 47432}`;
const scriptUrl =
`https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/${branch}/deploy/onboard-node.sh`;
res.json({ apiUrl, scriptUrl, branch });
});
// If the agent reported Docker's default bridge IP (172.17.x) but the request
// itself came from a real LAN address, prefer the request source IP instead.
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918
// 172.16/12 block, since real LANs (e.g. 172.18.91.x) fall in that range.
function pickIp(reportedIp, reqIp) {
const clean = (s) => (s || '').replace(/^::ffff:/, '');
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');

View file

@ -22,20 +22,22 @@ const parseRedisUrl = (url) => {
const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');
const proxyQueue = new Queue('proxy', { connection: redisConn });
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
const filmstripQueue = new Queue('filmstrip', { connection: redisConn });
const conformQueue = new Queue('conform', { connection: redisConn });
const importQueue = new Queue('import', { connection: redisConn });
const trimQueue = new Queue('trim', { connection: redisConn });
const proxyQueue = new Queue('proxy', { connection: redisConn });
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
const filmstripQueue = new Queue('filmstrip', { connection: redisConn });
const conformQueue = new Queue('conform', { connection: redisConn });
const importQueue = new Queue('import', { connection: redisConn });
const trimQueue = new Queue('trim', { connection: redisConn });
const playoutStageQueue = new Queue('playout-stage', { connection: redisConn });
const QUEUES = [
{ queue: proxyQueue, type: 'proxy' },
{ queue: thumbnailQueue, type: 'thumbnail' },
{ queue: filmstripQueue, type: 'filmstrip' },
{ queue: conformQueue, type: 'conform' },
{ queue: importQueue, type: 'import' },
{ queue: trimQueue, type: 'trim' },
{ queue: proxyQueue, type: 'proxy' },
{ queue: thumbnailQueue, type: 'thumbnail' },
{ queue: filmstripQueue, type: 'filmstrip' },
{ queue: conformQueue, type: 'conform' },
{ queue: importQueue, type: 'import' },
{ queue: trimQueue, type: 'trim' },
{ queue: playoutStageQueue, type: 'playout-stage' },
];
// BullMQ state → API status mapping

View file

@ -11,6 +11,7 @@
import express from 'express';
import http from 'http';
import { readFile } from 'fs/promises';
import { Queue } from 'bullmq';
import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js';
@ -307,9 +308,14 @@ async function spawnChannelSidecar(channel) {
}
}
// Set last_heartbeat_at = NOW() so the scheduler health tick treats this
// channel as freshly alive. Without this, last_heartbeat_at starts as NULL
// (epoch=0), and the very first tick sees ageMs >> TIMEOUT_MS and triggers
// failover immediately — before the sidecar has had a chance to respond.
const { rows } = await pool.query(
`UPDATE playout_channels
SET status = 'running', container_id = $1, container_meta = $2, updated_at = NOW()
SET status = 'running', container_id = $1, container_meta = $2,
last_heartbeat_at = NOW(), updated_at = NOW()
WHERE id = $3 RETURNING *`,
[containerId, JSON.stringify(containerMeta), channel.id]
);
@ -367,6 +373,39 @@ router.get('/channels/:id/status', async (req, res, next) => {
}
});
// GET /playout/channels/:id/hls/index.m3u8 — the live preview playlist, served
// through the API (not the static /media/live path) so it bypasses the public
// reverse proxy's static cache. That proxy caches the .m3u8 by path with a
// multi-second TTL and ignores the origin's no-store, so hls.js's ~1s reloads
// always got a STALE playlist ("MISSED" forever → monitor stayed black). The
// /api/ path is not proxy-cached (the status poll updates fine), so this always
// returns the fresh live edge. Segment (.ts) lines are rewritten to absolute
// /media/live/<id>/ URLs so they still load from the static path (immutable,
// caching them is fine). mam-api shares the same /media volume the sidecars
// write to.
const HLS_MEDIA_DIR = process.env.PLAYOUT_MEDIA_DIR || '/media';
router.get('/channels/:id/hls/index.m3u8', async (req, res, next) => {
try {
const cid = req.channel.id;
let body;
try {
body = await readFile(`${HLS_MEDIA_DIR}/live/${cid}/index.m3u8`, 'utf8');
} catch (e) {
return res.status(404).json({ error: 'No live preview for this channel yet' });
}
// Rewrite bare segment names to absolute static URLs.
const rewritten = body
.split('\n')
.map((line) => (/^[^#].*\.ts\s*$/.test(line) ? `/media/live/${cid}/${line.trim()}` : line))
.join('\n');
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.send(rewritten);
} catch (err) { next(err); }
});
// ── Transport ────────────────────────────────────────────────────────────────
async function transport(req, res, action, body = null) {
if (req.channel.status !== 'running') {
return res.status(409).json({ error: 'Channel is not running' });
@ -411,7 +450,14 @@ router.post('/channels/:id/play', requireChannelEdit, async (req, res, next) =>
asset_duration_ms: i.asset_duration_ms != null ? Number(i.asset_duration_ms) : null,
})),
};
const out = await callSidecar(req.channel, '/playlist/load', 'POST', payload);
// callSidecar throws on network/timeout errors. Return 502 (not 409) so
// the UI and operators know it's a gateway problem, not a state conflict.
let out;
try {
out = await callSidecar(req.channel, '/playlist/load', 'POST', payload);
} catch (err) {
return res.status(502).json({ error: 'Sidecar unreachable: ' + err.message });
}
res.json(out);
} catch (err) { next(err); }
});

View file

@ -154,6 +154,7 @@ const RECORDER_FIELDS = [
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
'growing_enabled',
];
function pickRecorderFields(body) {
@ -363,14 +364,25 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
// Growing-files mode is a global setting (settings table). When on, the
// capture container writes the master to its /growing/ mount instead of
// streaming it to S3 — Premiere can mount the SMB share and edit it live.
const growingRow = await pool.query(
`SELECT value FROM settings WHERE key = 'growing_enabled'`
);
const growingEnabled =
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true;
// Growing-files mode is a PER-RECORDER setting (recorders.growing_enabled).
// When on, the capture container writes the master to its /growing/ mount
// instead of streaming it to S3 — editors can mount the SMB share and cut it
// live. The SMB share itself (mount source + credentials) is shared
// infrastructure configured globally in Settings → Storage.
const growingEnabled = recorder.growing_enabled === true;
// Shared growing-files SMB infrastructure (global settings). Used to mount
// the CIFS share inside the capture container (services/capture mounts it
// with these credentials when GROWING_SMB_MOUNT is set).
const growingInfra = {};
{
const r = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`,
[['growing_smb_mount', 'growing_smb_username', 'growing_smb_password', 'growing_smb_vers']]
);
for (const { key, value } of r.rows) growingInfra[key] = value;
}
const smbMount = growingEnabled ? (growingInfra.growing_smb_mount || '') : '';
// Operator-supplied clip name wins over the auto-timestamped fallback.
// The Recorders UI passes this on the start request when the user types
@ -455,6 +467,13 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
`GROWING_PATH=/growing`,
// SMB mount details for the in-container CIFS mount (Approach A). Empty
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
// (or to S3 streaming if growing isn't enabled).
`GROWING_SMB_MOUNT=${smbMount}`,
`GROWING_SMB_USERNAME=${growingInfra.growing_smb_username || ''}`,
`GROWING_SMB_PASSWORD=${growingInfra.growing_smb_password || ''}`,
`GROWING_SMB_VERS=${growingInfra.growing_smb_vers || '3.0'}`,
];
// Deltacast: pass port count so the capture container can enumerate
@ -530,7 +549,15 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`);
} catch (_) { /* no /dev/deltacast* nodes on this host */ }
}
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
// /growing handling:
// - SMB mount configured → DON'T host-bind; the capture container mounts
// the CIFS share at /growing itself (Approach A). A bind-mount here
// would shadow the in-container mount.
// - growing on but no SMB mount → legacy host bind-mount fallback.
// - growing off → no /growing mount at all.
if (growingEnabled && !smbMount) {
hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
}
const localEnv = [...env];
if (useGpu) {

View file

@ -258,21 +258,45 @@ router.put('/transcoding', async (req, res, next) => {
// while it's still being written; the promotion worker later moves the
// finalized file to S3 and flips the asset to status='ready'.
const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];
// Growing-files mode is now a PER-RECORDER setting (recorders.growing_enabled);
// the legacy global `growing_enabled` key is no longer read at recorder start.
// These global keys describe the shared SMB landing-zone infrastructure only:
// - growing_path container mount point (default /growing)
// - growing_smb_url smb://... display string for editors (Premiere)
// - growing_smb_mount //host/share CIFS source the capture container mounts
// - growing_smb_username SMB user for the system-side CIFS mount
// - growing_smb_password SMB password (WRITE-ONLY; never returned)
// - growing_smb_vers CIFS protocol version (default 3.0)
// - growing_promote_after_seconds idle threshold before S3 promotion
const GROWING_KEYS = [
'growing_path', 'growing_smb_url', 'growing_smb_mount',
'growing_smb_username', 'growing_smb_vers', 'growing_promote_after_seconds',
];
// growing_smb_password is handled separately: stored on PUT but NEVER returned
// on GET (only a *_exists flag), mirroring s3_secret_key.
router.get('/growing', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`,
[GROWING_KEYS]
[[...GROWING_KEYS, 'growing_smb_password']]
);
const out = {
growing_enabled: 'false',
growing_path: '/growing',
growing_smb_url: '',
growing_smb_mount: '',
growing_smb_username: '',
growing_smb_vers: '3.0',
growing_promote_after_seconds: '8',
growing_smb_password_exists: false,
};
for (const { key, value } of result.rows) out[key] = value;
for (const { key, value } of result.rows) {
if (key === 'growing_smb_password') {
out.growing_smb_password_exists = !!(value && value.length);
} else {
out[key] = value;
}
}
res.json(out);
} catch (err) {
next(err);
@ -290,6 +314,19 @@ router.put('/growing', async (req, res, next) => {
);
}
}
// SMB password is write-only. A non-empty value sets/replaces it. To remove
// it, send growing_smb_password_clear:true. A blank/omitted password field
// leaves the stored value untouched (so operators don't retype it on every
// save).
if (req.body.growing_smb_password_clear === true) {
await pool.query(`DELETE FROM settings WHERE key = 'growing_smb_password'`);
} else if (typeof req.body.growing_smb_password === 'string' && req.body.growing_smb_password.length > 0) {
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ('growing_smb_password', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
[req.body.growing_smb_password]
);
}
res.json({ message: 'Growing-files settings saved' });
} catch (err) {
next(err);

View file

@ -14,10 +14,12 @@ const exec = promisify(execCb);
const router = express.Router();
// Defaults mirrored from settings.js so the overview never returns nulls.
// Growing-file mode is now per-recorder; "enabled" here means the shared SMB
// landing zone is CONFIGURED (a mount source is set), not a global on/off.
const GROWING_DEFAULTS = {
growing_enabled: 'false',
growing_path: '/growing',
growing_smb_url: '',
growing_smb_mount: '',
growing_promote_after_seconds: '8',
};
@ -100,7 +102,9 @@ router.get('/overview', async (req, res, next) => {
try {
// Growing files — merge defaults with whatever's in `settings`.
const growingRaw = { ...GROWING_DEFAULTS, ...(await readSettings(Object.keys(GROWING_DEFAULTS))) };
const growingEnabled = growingRaw.growing_enabled === 'true' || growingRaw.growing_enabled === true;
// "enabled" now means the shared SMB landing zone is configured (a mount
// source is set). Per-recorder toggles decide which recorders actually use it.
const growingEnabled = !!(growingRaw.growing_smb_mount && growingRaw.growing_smb_mount.trim());
const containerPath = growingRaw.growing_path || '/growing';
const mount = await probeGrowingPath(containerPath);
@ -117,6 +121,7 @@ router.get('/overview', async (req, res, next) => {
// existing deploy uses this symlink — surface it for operator context.
host_path: '/mnt/NVME/MAM/wild-dragon-growing',
smb_url: growingRaw.growing_smb_url || '',
smb_mount: growingRaw.growing_smb_mount || '',
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
exists: mount.exists,
writable: mount.writable,

View file

@ -192,13 +192,68 @@ async function enqueueNextOccurrence(schedule, client) {
// ── Playout channel health + failover ────────────────────────────────────────
// Tick step 6. Reuses the same advisory lock so only one replica probes the
// sidecars. A missed probe is counted via last_heartbeat_at age: > 3 *
// TICK_INTERVAL means 3 consecutive misses.
// sidecars; multi-replica pings would just waste cycles. A missed probe is
// counted via last_heartbeat_at age: > 3 * TICK_INTERVAL means 3 consecutive
// misses.
// Persist the as-run compliance log for one channel from a sidecar /status
// payload. The sidecar reports the currently on-air item via currentItemId /
// currentClip / currentItemStartedAt (playout-manager.getStatus). We keep at
// most one "open" row (ended_at IS NULL) per channel: when the on-air item
// changes (or playout stops) we close the open row — stamping ended_at and a
// computed duration_s — and, if a new clip is on air, open a fresh row.
//
// IMPORTANT: when last_heartbeat_at is NULL (channel just spawned, no
// successful tick yet), use updated_at as the grace anchor — otherwise the
// "0" fallback makes ageMs huge and the channel is instantly failover-killed
// before its first heartbeat can ever land.
// playout_as_run columns (migration 029): id, channel_id, asset_id, item_id,
// clip_name, started_at, ended_at, duration_s, result.
async function writeAsRun(client, channelId, engine) {
const currentItemId = engine && engine.currentItemId ? engine.currentItemId : null;
// The currently-open as-run row for this channel, if any.
const { rows: openRows } = await client.query(
`SELECT id, item_id, started_at FROM playout_as_run
WHERE channel_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC LIMIT 1`,
[channelId]
);
const open = openRows[0] || null;
// Same clip still on air → nothing to do.
if (open && currentItemId && open.item_id === currentItemId) return;
// Nothing on air and nothing open → nothing to do.
if (!open && !currentItemId) return;
// Close the previous open row (clip changed, or playout stopped).
if (open) {
await client.query(
`UPDATE playout_as_run
SET ended_at = NOW(),
duration_s = EXTRACT(EPOCH FROM (NOW() - started_at))
WHERE id = $1`,
[open.id]
);
}
// Open a new row for the clip now on air. Resolve the item's asset_id so the
// compliance log links back to the source asset even after the playlist item
// is later deleted.
if (currentItemId) {
let assetId = null;
try {
const { rows } = await client.query(
'SELECT asset_id FROM playout_items WHERE id = $1', [currentItemId]
);
if (rows.length > 0) assetId = rows[0].asset_id;
} catch (_) { /* item may have been deleted; log without asset link */ }
await client.query(
`INSERT INTO playout_as_run
(channel_id, asset_id, item_id, clip_name, started_at, result)
VALUES ($1, $2, $3, $4, COALESCE($5::timestamptz, NOW()), 'played')`,
[channelId, assetId, currentItemId, engine.currentClip || null,
engine.currentItemStartedAt || null]
);
}
}
async function playoutHealthTick(client) {
let channels;
try {
@ -223,10 +278,24 @@ async function playoutHealthTick(client) {
await client.query(
'UPDATE playout_channels SET last_heartbeat_at = NOW() WHERE id = $1', [ch.id]
);
// As-run compliance log: the sidecar only tracks the on-air clip locally
// (playout-manager._reportAsRunStart). On every successful status poll we
// detect a clip change here and persist it to playout_as_run — close the
// previous open row and open a new one. Failures are swallowed so a logging
// hiccup never knocks a healthy channel into failover.
try {
const engine = await r.json().catch(() => null);
if (engine) await writeAsRun(client, ch.id, engine);
} catch (e) {
console.warn(`[scheduler] as-run write failed for ${ch.id}: ${e.message}`);
}
} catch (err) {
const lastSeen = ch.last_heartbeat_at
? new Date(ch.last_heartbeat_at).getTime()
: new Date(ch.updated_at).getTime();
// When last_heartbeat_at is NULL (channel just spawned), fall back to
// updated_at (set to NOW() by spawnChannelSidecar). This prevents a
// brand-new channel from being failed over on the very first tick because
// epoch-0 age always exceeds TIMEOUT_MS.
const baseline = ch.last_heartbeat_at || ch.updated_at;
const lastSeen = baseline ? new Date(baseline).getTime() : Date.now();
const ageMs = Date.now() - lastSeen;
if (ageMs < TIMEOUT_MS) continue;

View file

@ -1,4 +1,22 @@
# Wild Dragon Playout sidecar — CasparCG Server + Node AMCP control shim.
#
# CasparCG's mixer needs an OpenGL context. On a node with a real GPU we'd pass
# the device + driver through; for the headless / no-GPU case we run a virtual
# framebuffer (Xvfb) so the GL context initialises. The container is launched
# --privileged by mam-api (same as capture) so DeckLink / NDI hardware is
# reachable when present.
#
# CasparCG 2.4.x no longer ships a self-contained Linux tarball — the GitHub
# release provides either Ubuntu .deb packages or an "ubuntu22" zip that bundles
# the binary + its .so files under bin/ and lib/. We use the zip on an
# ubuntu:22.04 base so the bundled libs match the host glibc/abi, then install
# Node 20 from NodeSource on top.
#
# NDI + DeckLink SDKs are NOT redistributable. They are fetched at build time
# from a URL supplied as a build arg (mirror it into your own artifact store);
# the build still succeeds without it (NDI/DeckLink consumers simply won't be
# available — SRT/RTMP/test output still work).
FROM ubuntu:22.04
ARG CASPAR_VERSION=2.4.0-stable
@ -7,8 +25,16 @@ ARG NDI_SDK_URL=
ENV DEBIAN_FRONTEND=noninteractive
# CEF (HTML producer) needs libnss3 + chromium runtime deps. Without these the
# server starts fine but SIGABRTs ~30s in when it lazy-inits CEF (NSS -8023).
# CasparCG 2.4 runtime deps + Xvfb for headless GL + CEF (HTML producer) deps +
# Node 20 (NodeSource).
#
# NOTE: we deliberately do NOT `apt-get install ffmpeg`. That package drags in
# ~80 transitive shared libraries (libav*, libx264, libdrm, libva, ...) that
# perturb CasparCG 2.4.0's runtime linking and make its headless startup abort
# with SIGABRT (exit 134) on nearly every launch. A self-contained STATIC
# ffmpeg binary (installed below) gives us the standalone CLI the preview
# re-muxer needs with ZERO new shared libs, keeping CasparCG's environment
# identical to the known-good image.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl unzip tar xz-utils gnupg \
xvfb libgl1-mesa-dri libglu1-mesa fonts-dejavu-core \
@ -23,16 +49,34 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# ── Standalone STATIC ffmpeg CLI (for the HLS preview re-muxer) ───────────────
# john van sickle's static build is fully self-contained (no shared-lib deps),
# so it can't perturb CasparCG's runtime linking. Override FFMPEG_URL to mirror
# this into your own artifact store if upstream availability is a concern.
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
RUN set -eux; \
curl -fsSL "$FFMPEG_URL" -o /tmp/ffmpeg.tar.xz; \
mkdir -p /tmp/ffmpeg; \
tar xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=1; \
cp /tmp/ffmpeg/ffmpeg /tmp/ffmpeg/ffprobe /usr/local/bin/; \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe; \
rm -rf /tmp/ffmpeg /tmp/ffmpeg.tar.xz; \
/usr/local/bin/ffmpeg -version | head -1
# ── CasparCG Server (ubuntu22 zip bundle) ────────────────────────────────────
# The zip extracts to /opt/casparcg_server with the binary at bin/casparcg and
# its bundled .so files under lib/ (added to LD_LIBRARY_PATH by entrypoint.sh).
# Symlink to /opt/casparcg so the config/entrypoint paths stay stable.
WORKDIR /tmp/caspar
RUN set -eux; \
curl -fsSL "$CASPAR_URL" -o caspar.zip; \
unzip -q caspar.zip -d /opt; \
chmod +x /opt/casparcg_server/bin/casparcg /opt/casparcg_server/scanner 2>/dev/null || true; \
ls /opt/casparcg_server/; \
test -x /opt/casparcg_server/bin/casparcg; \
ln -sfn /opt/casparcg_server /opt/casparcg; \
echo "caspar binary: /opt/casparcg_server/bin/casparcg"; \
cd /; rm -rf /tmp/caspar
curl -fsSL "$CASPAR_URL" -o caspar.zip; \
unzip -q caspar.zip -d /opt; \
chmod +x /opt/casparcg_server/bin/casparcg /opt/casparcg_server/scanner 2>/dev/null || true; \
ls /opt/casparcg_server/; \
test -x /opt/casparcg_server/bin/casparcg; \
ln -sfn /opt/casparcg_server /opt/casparcg; \
echo "caspar binary: /opt/casparcg_server/bin/casparcg"; \
cd /; rm -rf /tmp/caspar
RUN if [ -n "$NDI_SDK_URL" ]; then \
mkdir -p /opt/ndi-lib && \

View file

@ -11,6 +11,8 @@ if [ -z "${DISPLAY:-}" ]; then
done
fi
# Ensure the HLS preview directory exists before the re-mux ffmpeg writes to it
# (mam-api serves /live/<channel_id>/* from the shared media volume).
if [ -n "${CHANNEL_ID:-}" ]; then
mkdir -p "/media/live/${CHANNEL_ID}"
fi

View file

@ -1,4 +1,6 @@
import { AmcpClient } from './amcp.js';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
//
@ -23,6 +25,12 @@ const MEDIA_ROOT = process.env.CASPAR_MEDIA_ROOT || '/media';
const CHANNEL_ID = process.env.CHANNEL_ID || '';
const HLS_DIR = CHANNEL_ID ? `${MEDIA_ROOT}/live/${CHANNEL_ID}` : '';
// Loopback UDP port CasparCG's preview STREAM consumer publishes mpegts to, and
// the standalone ffmpeg re-muxer reads from. One CasparCG per sidecar, so a
// fixed port is fine; allow override for parallel local testing.
const PREVIEW_UDP_PORT = parseInt(process.env.PREVIEW_UDP_PORT || '9710', 10);
const PREVIEW_UDP_URL = `udp://127.0.0.1:${PREVIEW_UDP_PORT}`;
// CasparCG SEEK / LENGTH are in frames, not seconds. Capture standard is 59.94;
// SD/film modes need their own values. Default 60000/1001 matches both
// '1080p5994' and '1080i5994'.
@ -77,6 +85,8 @@ export class PlayoutManager {
lastError: null,
};
this._advanceTimer = null;
this._hlsProc = null; // standalone ffmpeg re-mux child process
this._hlsRestartTimer = null;
}
async _consumerCommand(outputType, cfg) {
@ -111,22 +121,38 @@ export class PlayoutManager {
// Start the channel: bring up CasparCG's primary output consumer for the
// target, plus a second FFMPEG consumer writing HLS for the UI preview
// monitor (~4-6s lag, reuses capture's /live/<id> plumbing).
//
// The primary consumer failure is NON-FATAL. CasparCG can decode and route
// media through its pipeline even without an output consumer. This means:
// - NDI channels work (load/play/transport) even if libndi.so is absent.
// - SRT/RTMP channels work even if the destination URL is unreachable.
// - The HLS preview consumer is always attempted independently.
//
// state.consumerError is set when the primary consumer fails so the mam-api
// can surface a warning in the channel status without blocking operation.
async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) {
await this.amcp.waitReady(30000);
// Set the channel video mode, then attach the output consumer.
// Set the channel video mode first.
try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); }
catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); }
const consumer = await this._consumerCommand(outputType, outputConfig);
await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
// Primary output consumer — non-fatal.
let consumerError = null;
try {
const consumer = await this._consumerCommand(outputType, outputConfig);
await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
} catch (err) {
consumerError = err.message;
console.warn(`[playout] primary consumer ADD failed (continuing without output): ${err.message}`);
}
// HLS preview consumer — always attempt, independently non-fatal.
if (HLS_DIR) {
try {
await this._addHlsConsumer();
console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`);
} catch (err) {
// HLS preview is non-fatal — operators still get the on-air output.
console.warn(`[playout] HLS preview consumer failed: ${err.message}`);
}
}
@ -137,37 +163,136 @@ export class PlayoutManager {
this.state.videoFormat = videoFormat;
this.state.fps = fpsFor(videoFormat);
this.state.startedAt = new Date().toISOString();
this.state.lastError = null;
console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}`);
this.state.lastError = consumerError;
console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}${consumerError ? ' ⚠ consumer: ' + consumerError : ''}`);
return this.getStatus();
}
// Low-bitrate HLS for the web UI preview. Segments land in the shared media
// volume; the mam-api serves /live/<channel_id>/* from there.
// HLS preview for the web UI confidence monitor.
//
// ── Why not CasparCG's own HLS (FILE/STREAM ".../index.m3u8") ──────────────
// CasparCG's bundled FFMPEG consumer muxes a BROKEN audio track into the HLS:
// ffprobe reports `aac, sample_rate=0` and ffmpeg decoding the playlist fails
// with "Invalid data ... abuffer: Value inf for parameter 'time_base' ...
// time_base 1/0". That corrupt audio prevents BOTH ffmpeg and hls.js from
// decoding, so the browser <video> sits at readyState 0 and the preview stays
// black. The video track itself is perfectly clean h264. Critically, the
// consumer IGNORES every arg that would fix it — `-an`, `-codec:a`, `-g`,
// `-r`, `-force_key_frames` are all silently dropped ("Unused option"), so we
// CANNOT remove the audio from inside CasparCG.
//
// ── The fix: STREAM mpegts to UDP loopback, re-mux with a STANDALONE ffmpeg ─
// CasparCG outputs a plain mpegts elementary stream to a local UDP port (its
// STREAM/mpegts path is fine — the breakage is specific to its HLS muxer). A
// Node-spawned standalone ffmpeg (where `-an` actually works) reads that UDP
// stream, drops audio, copies the clean h264 video, and writes proper HLS.
// `-c:v copy` avoids re-encoding. The program audio is untouched — it rides
// the PRIMARY SRT/RTMP/SDI/NDI consumer, which we never modify.
async _addHlsConsumer() {
// mkdir is done by the entrypoint; CasparCG's ffmpeg consumer creates the
// playlist on first segment. 2s segments / 6-window list keeps lag low
// without thrashing disk.
// FILE keyword (alias of the FFMPEG consumer) writing a segmented HLS
// playlist. Same arg rules as the STREAM consumer: -param:stream form and a
// format=yuv420p filter ahead of libx264 (channel output is RGBA).
const out = `${HLS_DIR}/index.m3u8`;
const args = [
`FILE "${out}"`,
'-format hls',
'-hls_time 2',
'-hls_list_size 6',
'-hls_flags delete_segments+append_list',
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 800k -maxrate 1M -bufsize 2M',
'-g 60 -keyint_min 60 -sc_threshold 0',
// 1) CasparCG → mpegts over UDP loopback. The channel feeds RGBA, so a
// format=yuv420p filter is required before libx264.
const streamArgs = [
`STREAM "${PREVIEW_UDP_URL}?pkt_size=1316"`,
'-format mpegts',
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency',
'-b:v 2M -maxrate 2M -bufsize 4M',
'-codec:a aac -b:a 96k',
'-filter:v format=yuv420p',
].join(' ');
await this.amcp.send(`ADD ${CHANNEL} ${args}`);
await this.amcp.send(`ADD ${CHANNEL} ${streamArgs}`);
// 2) Standalone ffmpeg re-mux: UDP mpegts → clean video-only HLS.
this._startHlsRemux();
}
// Spawn (or respawn) the standalone ffmpeg that re-muxes the loopback mpegts
// into video-only HLS. Restarts automatically if it dies while the channel is
// still running (e.g. brief UDP gap before CasparCG's consumer is up).
_startHlsRemux() {
if (!HLS_DIR) return;
try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {}
this._stopHlsRemux();
const out = `${HLS_DIR}/index.m3u8`;
const args = [
'-hide_banner', '-loglevel', 'warning',
// Read the live mpegts loopback. genpts rebuilds timestamps; the analyze/
// probe sizes are kept small so playback starts promptly.
'-fflags', '+genpts',
'-analyzeduration', '2000000', '-probesize', '2000000',
'-i', `${PREVIEW_UDP_URL}?fifo_size=1000000&overrun_nonfatal=1`,
// Drop the (broken) audio entirely.
'-an',
// Re-encode (NOT -c:v copy) to uniform, keyframe-aligned 2s segments with
// regenerated CFR timestamps. -c:v copy passed CasparCG's erratic
// real-time keyframes straight through, producing segments of 0.62.8s
// and irregular PTS; hls.js can't build a live timeline from that — it
// logs "sliding 0.00 / MISSED", never loads a fragment, and the monitor
// stays black even though the stream decodes cleanly server-side. A
// standalone ffmpeg honours -force_key_frames, so every GOP (and thus
// every HLS segment) is exactly 2.0s.
//
// This is a CONFIDENCE MONITOR, kept deliberately tiny: 360p / 20fps /
// ultrafast. The sidecar has no NVENC, so this is a CPU libx264 encode
// running ALONGSIDE CasparCG's mixer + its own STREAM consumer. At 720p30
// the re-encode couldn't sustain real time, the UDP input overran, and the
// HLS output stalled (playlist froze → monitor black). 360p20 ultrafast is
// a fraction of the cost and keeps up comfortably. fps=20 forces CFR;
// -g 40 = 2.0s GOP at 20fps.
'-vf', 'fps=20,scale=-2:360,format=yuv420p',
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
'-b:v', '600k', '-maxrate', '800k', '-bufsize', '1200k',
'-g', '40', '-keyint_min', '40', '-sc_threshold', '0',
'-force_key_frames', 'expr:gte(t,n_forced*2)',
'-f', 'hls',
'-hls_time', '2',
'-hls_list_size', '8',
'-hls_flags', 'delete_segments+append_list+independent_segments',
'-hls_segment_filename', `${HLS_DIR}/index%d.ts`,
out,
];
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
this._hlsProc = proc;
proc.stderr.on('data', (d) => {
const line = d.toString().trim();
if (line) console.warn(`[playout][hls-ffmpeg] ${line}`);
});
proc.on('exit', (code, signal) => {
console.warn(`[playout] HLS re-mux ffmpeg exited code=${code} signal=${signal}`);
if (this._hlsProc === proc) this._hlsProc = null;
// Auto-respawn while the channel is running (and we didn't kill it).
if (this.state.running && signal !== 'SIGTERM' && signal !== 'SIGKILL') {
this._hlsRestartTimer = setTimeout(() => {
this._hlsRestartTimer = null;
if (this.state.running) {
console.log('[playout] respawning HLS re-mux ffmpeg');
this._startHlsRemux();
}
}, 1000);
}
});
proc.on('error', (err) => {
console.warn(`[playout] HLS re-mux ffmpeg spawn error: ${err.message}`);
});
console.log(`[playout] HLS re-mux ffmpeg started: ${PREVIEW_UDP_URL} -> ${out}`);
}
_stopHlsRemux() {
if (this._hlsRestartTimer) {
clearTimeout(this._hlsRestartTimer);
this._hlsRestartTimer = null;
}
if (this._hlsProc) {
const proc = this._hlsProc;
this._hlsProc = null;
try { proc.kill('SIGTERM'); } catch (_) {}
}
}
async stopChannel() {
this._clearAdvance();
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 (_) {}
try { await this.amcp.send(`CLEAR ${CHANNEL}`); } catch (_) {}
this.state.running = false;
@ -181,6 +306,9 @@ export class PlayoutManager {
// Load a playlist (array of { id, asset_id, media_path, in_point, out_point,
// transition, transition_ms, clip_name }) and start playing from index 0.
async loadPlaylist({ items = [], loop = false }) {
if (!this.state.running) {
throw new Error('Channel not started — call /channel/start first');
}
this.state.playlist = items;
this.state.loop = !!loop;
this.state.currentIndex = -1;

View file

@ -61,12 +61,29 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Live HLS served from /live (bind-mounted shared volume), low cache so playlist refreshes
# Live HLS served from /live (bind-mounted capture live volume).
# no-store (not just no-cache): with "no-cache" the browser still caches the
# playlist and serves a STALE copy to hls.js's reloads, so hls.js sees the
# live playlist as never advancing ("MISSED" forever) and never plays the
# monitor stays black. no-store forbids caching entirely so every reload
# fetches the fresh live edge. Segments are short-lived; not caching them is
# fine for a live preview.
location /live/ {
alias /live/;
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-cache";
add_header Access-Control-Allow-Origin *;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Access-Control-Allow-Origin * always;
}
# Playout HLS preview CasparCG sidecar writes to the media volume under
# /media/live/<channel_id>/. This is a separate volume from /live/ (capture).
location /media/live/ {
alias /media/live/;
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Access-Control-Allow-Origin * always;
}
# API proxy - forward to mam-api service

View file

@ -161,6 +161,7 @@ function NewRecorderModal({ open, onClose }) {
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
const [proxyOn, setProxyOn] = React.useState(true);
const [growingOn, setGrowingOn] = React.useState(false);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null);
@ -206,6 +207,7 @@ function NewRecorderModal({ open, onClose }) {
source_type: sourceType.toLowerCase(),
project_id: projectId || undefined,
generate_proxy: proxyOn,
growing_enabled: growingOn,
recording_codec: recCodec,
recording_container: recContainer,
// Framerate + resolution are auto-detected from the source signal/stream.
@ -473,6 +475,20 @@ function NewRecorderModal({ open, onClose }) {
</div>
</div>
<div className="modal-toggle-row">
<label className="switch">
<input type="checkbox" checked={growingOn} onChange={e => setGrowingOn(e.target.checked)} />
<span className="switch-track"><span className="switch-knob" /></span>
</label>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>Growing-files mode</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
Write the live master to the SMB share so editors can cut while it's still recording.
Requires the SMB share to be configured in Settings Storage.
</div>
</div>
</div>
{proxyOn && (
<div className="modal-section">
<div className="modal-section-head"><span>Proxy</span></div>

View file

@ -1358,18 +1358,8 @@ function Cluster() {
const [adviceModal, setAdviceModal] = React.useState(null); // {title, lines:[], commands?}
const addNode = () => setAdviceModal({
title: 'Add a worker node',
lines: [
'Worker nodes auto-register with the cluster on first heartbeat.',
'Run these on the new host (replace 10.0.0.25 with this MAM\'s IP):',
],
commands: [
'git clone https://forge.wilddragon.net/zgaetano/dragonflight.git /opt/dragonflight',
'cd /opt/dragonflight && cp .env.example .env && nano .env # set NODE_ROLE=worker, point at MAM IP',
'docker compose -f docker-compose.worker.yml up -d',
],
});
const [showAddNode, setShowAddNode] = React.useState(false);
const addNode = () => setShowAddNode(true);
const drainNode = (node) => setAdviceModal({
title: `Drain ${node.id}`,
@ -1576,6 +1566,7 @@ function Cluster() {
)}
</div>
</div>
{showAddNode && <AddNodeModal onClose={() => setShowAddNode(false)} />}
{adviceModal && (
<div className="modal-backdrop" onClick={() => setAdviceModal(null)}>
<div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}>
@ -1606,6 +1597,165 @@ function Cluster() {
);
}
// AddNodeModal Approach A onboarding wizard. Collects a node name + role,
// mints a one-time auth token via /auth/tokens, and renders a ready-to-paste
// `curl | bash` command that provisions the machine via deploy/onboard-node.sh.
//
// Role compose PROFILES mapping (see docker-compose.worker.yml):
// Worker "worker"
// Capture "worker capture"
// GPU "worker gpu" (worker-l4 service, profiles: [gpu])
const ADD_NODE_ROLES = [
{ id: 'worker', label: 'Worker', profiles: 'worker', desc: 'CPU transcode / general jobs' },
{ id: 'capture', label: 'Capture', profiles: 'worker capture', desc: 'SDI / DeckLink ingest' },
{ id: 'gpu', label: 'GPU', profiles: 'worker gpu', desc: 'NVENC-accelerated transcode' },
];
function AddNodeModal({ onClose }) {
const [nodeName, setNodeName] = React.useState('');
const [role, setRole] = React.useState('worker');
const [apiUrl, setApiUrl] = React.useState('');
const [info, setInfo] = React.useState(null); // { scriptUrl, branch }
const [command, setCommand] = React.useState(null); // generated string
const [error, setError] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [copied, setCopied] = React.useState(false);
// On open, prefill the editable apiUrl + capture scriptUrl/branch.
React.useEffect(() => {
window.ZAMPP_API.fetch('/cluster/onboard-info')
.then(d => {
setInfo({ scriptUrl: d.scriptUrl, branch: d.branch });
if (d.apiUrl) setApiUrl(d.apiUrl);
})
.catch(() => {}); // leave apiUrl empty user must fill it before Generate
}, []);
const roleDef = ADD_NODE_ROLES.find(r => r.id === role) || ADD_NODE_ROLES[0];
const generate = async () => {
setError(null);
if (!nodeName.trim()) { setError('Node name is required.'); return; }
if (!apiUrl.trim()) { setError('Primary API URL is required.'); return; }
setBusy(true);
try {
const r = await fetch('/api/v1/auth/tokens', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ name: 'node: ' + nodeName.trim() }),
});
if (r.status !== 201) {
const body = await r.json().catch(() => ({}));
setError(body.error || ('Failed to mint token (' + r.status + ')'));
return;
}
const { token } = await r.json();
const scriptUrl = (info && info.scriptUrl)
|| 'https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh';
const cmd =
`curl -sL ${scriptUrl} | NODE_TOKEN=${token} MAM_API_URL=${apiUrl.trim()} ` +
`NODE_ROLE=${role} PROFILES="${roleDef.profiles}" bash`;
setCommand(cmd);
} catch (e) {
setError(e.message || 'Network error');
} finally {
setBusy(false);
}
};
const copy = () => {
if (!command || !navigator.clipboard) return;
navigator.clipboard.writeText(command)
.then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); })
.catch(() => {});
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 620 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Add cluster node</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
{!command && (
<React.Fragment>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Node name</label>
<input className="field-input" style={{ width: '100%' }} autoFocus
placeholder="e.g. zampp3"
value={nodeName} onChange={e => setNodeName(e.target.value)} />
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Role</label>
<div style={{ display: 'flex', gap: 6 }}>
{ADD_NODE_ROLES.map(rd => (
<button key={rd.id}
className={'btn sm' + (role === rd.id ? ' primary' : ' ghost')}
style={{ flex: 1, flexDirection: 'column', alignItems: 'flex-start', gap: 2, padding: '8px 10px' }}
onClick={() => setRole(rd.id)}>
<span style={{ fontWeight: 600 }}>{rd.label}</span>
<span style={{ fontSize: 10, opacity: 0.8 }}>{rd.desc}</span>
</button>
))}
</div>
</div>
<div style={{ marginBottom: 4 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Primary API URL</label>
<input className="field-input mono" style={{ width: '100%', fontSize: 12 }}
placeholder="http://10.0.0.25:47432"
value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
<div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 4 }}>
The LAN address this new node will heartbeat to. Edit if the guess is wrong.
</div>
</div>
</React.Fragment>
)}
{command && (
<React.Fragment>
<div style={{ marginBottom: 10, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)' }}>
This token is shown only once copy the command now.
</div>
</div>
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 11.5, lineHeight: 1.5, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{command}</code>
<ol style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.6 }}>
<li>SSH into the fresh Ubuntu machine.</li>
<li>Paste and run this command.</li>
<li>The node appears in this Cluster view within ~30s.</li>
</ol>
</React.Fragment>
)}
{error && (
<div style={{ marginTop: 10, fontSize: 11.5, color: 'var(--danger)' }}>{error}</div>
)}
</div>
<div className="modal-foot">
{!command && (
<React.Fragment>
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" disabled={busy} onClick={generate}>
{busy ? 'Generating…' : 'Generate command'}
</button>
</React.Fragment>
)}
{command && (
<React.Fragment>
<button className="btn ghost sm" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
<button className="btn primary sm" onClick={onClose}>Done</button>
</React.Fragment>
)}
</div>
</div>
</div>
);
}
function DetailRow({ k, v, mono }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}>
@ -1926,6 +2076,7 @@ function Settings() {
function StorageSection() {
return (
<>
<StorageWarningBanner />
<MountHealthStrip />
<S3SettingsCard />
<GrowingSettingsCard />
@ -1933,6 +2084,27 @@ function StorageSection() {
);
}
// Set-once deployment warning. Storage paths are written into asset rows and
// the S3 layout at ingest time; changing them after assets exist orphans files
// and can corrupt the library's view of where masters/proxies live.
function StorageWarningBanner() {
return (
<div role="alert" style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '14px 16px', marginBottom: 14, borderRadius: 10,
border: '1px solid var(--danger)',
background: 'color-mix(in srgb, var(--danger) 12%, transparent)',
}}>
<Icon name="alert" size={20} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em', lineHeight: 1.5, color: 'var(--text-1)' }}>
WARNING THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT.
CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS.
PLEASE USE WITH CAUTION.
</div>
</div>
);
}
function formatBytes(n) {
if (n == null || isNaN(n)) return '·';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
@ -2005,8 +2177,8 @@ function MountHealthStrip() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 12.5 }}>Growing files</strong>
{g.enabled
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'mounted' : 'unreachable'} detail={g.error || ''} />
: <span className="badge neutral">disabled</span>}
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'configured' : 'unreachable'} detail={g.error || ''} />
: <span className="badge neutral">not configured</span>}
{g.enabled && g.exists && (
<HealthPill ok={g.writable} label={g.writable ? 'writable' : 'read-only'} />
)}
@ -2019,7 +2191,8 @@ function MountHealthStrip() {
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Container</span><span className="mono">{g.container_path || '·'}</span>
<span>Host</span><span className="mono">{g.host_path || '·'}</span>
<span>SMB</span><span className="mono">{g.smb_url || '·'}</span>
<span>SMB mount</span><span className="mono">{g.smb_mount || '·'}</span>
<span>SMB (editors)</span><span className="mono">{g.smb_url || '·'}</span>
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
</div>
@ -2213,35 +2386,75 @@ function GpuSettingsCard() {
function GrowingSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [pwd, setPwd] = React.useState(''); // new password to set; '' = leave unchanged
const [pwdExists, setPwdExists] = React.useState(false);
const [clearPwd, setClearPwd] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({
growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8',
}));
window.ZAMPP_API.fetch('/settings/growing')
.then(d => { setCfg(d); setPwdExists(!!d.growing_smb_password_exists); })
.catch(() => setCfg({
growing_path: '/growing', growing_smb_url: '', growing_smb_mount: '',
growing_smb_username: '', growing_smb_vers: '3.0', growing_promote_after_seconds: '8',
}));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
const body = {
growing_path: cfg.growing_path,
growing_smb_url: cfg.growing_smb_url,
growing_smb_mount: cfg.growing_smb_mount,
growing_smb_username: cfg.growing_smb_username,
growing_smb_vers: cfg.growing_smb_vers,
growing_promote_after_seconds: cfg.growing_promote_after_seconds,
};
if (clearPwd) body.growing_smb_password_clear = true;
else if (pwd) body.growing_smb_password = pwd;
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(body) })
.then(() => {
setSaving(false); setMsg({ ok: true, text: 'Saved.' });
if (clearPwd) { setPwdExists(false); setClearPwd(false); }
else if (pwd) { setPwdExists(true); setPwd(''); }
})
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
const mountConfigured = !!(cfg.growing_smb_mount && cfg.growing_smb_mount.trim());
return (
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="Shared SMB landing zone. Enable per-recorder under Ingest → Recorders."
tag={mountConfigured ? <span className="badge success">configured</span> : <span className="badge neutral">not configured</span>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<SField label="Enable growing-file capture">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Capture writes to the local SMB share first; Premier can edit while it's still growing.</span>
</label>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 10, lineHeight: 1.5 }}>
Growing-file mode is enabled <strong style={{ color: 'var(--text-2)' }}>per recorder</strong> (New recorder Growing-files mode).
These settings describe the SMB share that capture mounts and writes the live master to.
</div>
<SField label="SMB mount source (CIFS)">
<input className="field-input mono" value={cfg.growing_smb_mount || ''} onChange={e => set('growing_smb_mount', e.target.value)} placeholder="//10.0.0.25/mam-growing" />
</SField>
<SField label="SMB username">
<input className="field-input mono" value={cfg.growing_smb_username || ''} onChange={e => set('growing_smb_username', e.target.value)} placeholder="capture" autoComplete="off" />
</SField>
<SField label="SMB password">
<input className="field-input mono" type="password" autoComplete="new-password"
value={pwd}
disabled={clearPwd}
onChange={e => setPwd(e.target.value)}
placeholder={pwdExists ? '•••••••• (saved — leave blank to keep)' : 'Enter SMB password'} />
{pwdExists && (
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--text-3)', marginTop: 4 }}>
<input type="checkbox" checked={clearPwd} onChange={e => { setClearPwd(e.target.checked); if (e.target.checked) setPwd(''); }} />
Remove saved password
</label>
)}
</SField>
<SField label="CIFS protocol version">
<input className="field-input mono" value={cfg.growing_smb_vers || ''} onChange={e => set('growing_smb_vers', e.target.value)} placeholder="3.0" />
</SField>
<SField label="Container mount path">
<input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />

File diff suppressed because it is too large Load diff

View file

@ -45,7 +45,7 @@ function Jobs({ navigate }) {
const normalizeJob = (j) => {
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube' };
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube', 'playout-stage': 'Stage' };
const meta = j.metadata || {};
return {
...j,
@ -207,7 +207,7 @@ function Jobs({ navigate }) {
}
function JobRow({ job, onRetry, onDelete }) {
const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers' };
const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers', Stage: 'monitor' };
return (
<div className="job-row">
<div><StatusDot status={job.status} /></div>

View file

@ -24,6 +24,26 @@ async function poFetch(path, opts) {
return window.ZAMPP_API.fetch('/playout' + path, opts);
}
// Helpers
function fmtDuration(secs) {
if (!secs || secs < 0) return '—';
const s = Math.floor(secs);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = s % 60;
const mm = String(m).padStart(2, '0');
const ssStr = String(ss).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ssStr}` : `${m}:${ssStr}`;
}
function itemEffectiveDuration(it) {
const total = (it.asset_duration_ms || 0) / 1000;
const inPt = it.in_point != null ? Number(it.in_point) : 0;
const outPt = it.out_point != null ? Number(it.out_point) : total;
return Math.max(0, outPt - inPt);
}
// Output-config sub-form (varies by output type)
function OutputConfigFields({ type, config, onChange }) {
const set = (k, v) => onChange({ ...config, [k]: v });
@ -175,29 +195,37 @@ function MediaBin({ projectId }) {
);
}
const MEDIA_STATUS_BADGE = {
ready: 'success', staging: 'warn', pending: 'neutral', error: 'error',
};
// Staging progress bar
function StagingBar({ status }) {
return (
<div className={'po-staging-bar po-staging-bar--' + (status || 'pending')} aria-hidden="true" />
);
}
// Playlist: ordered, drag-drop reorder, drop-target for bin assets
function Playlist({ channel, playlistId, items, onReload }) {
function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
const [dragIndex, setDragIndex] = React.useState(null);
const [dropErr, setDropErr] = React.useState(null);
const onItemDragStart = (e, index) => {
setDragIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const onItemDragOver = (e) => { e.preventDefault(); };
const onItemDrop = async (e, index) => {
e.preventDefault();
// Asset dropped from the bin append.
e.stopPropagation(); // prevent bubble to onContainerDrop
setDropErr(null);
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (assetRaw) {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
try {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
return;
}
// Reorder within the playlist.
@ -206,63 +234,92 @@ function Playlist({ channel, playlistId, items, onReload }) {
const [moved] = order.splice(dragIndex, 1);
order.splice(index, 0, moved);
setDragIndex(null);
await poFetch('/playlists/' + playlistId + '/reorder', {
method: 'PUT', body: JSON.stringify({ order }),
});
onReload();
try {
await poFetch('/playlists/' + playlistId + '/reorder', {
method: 'PUT', body: JSON.stringify({ order }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to reorder'); }
};
// Dropping onto empty area appends.
const onContainerDrop = async (e) => {
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (!assetRaw) return;
e.preventDefault();
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
setDropErr(null);
try {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
};
const removeItem = async (id) => {
await poFetch('/items/' + id, { method: 'DELETE' });
onReload();
try { await poFetch('/items/' + id, { method: 'DELETE' }); onReload(); }
catch (err) { setDropErr(err.message || 'Failed to remove'); }
};
const restage = async (id) => {
await poFetch('/items/' + id + '/stage', { method: 'POST' });
onReload();
try { await poFetch('/items/' + id + '/stage', { method: 'POST' }); onReload(); }
catch (err) { setDropErr(err.message || 'Failed to restage'); }
};
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
return (
<div className="panel po-playlist" onDragOver={e => e.preventDefault()} onDrop={onContainerDrop}>
<div className="po-section-label" style={{ padding: '8px 12px' }}>Playlist</div>
<div className="po-playlist-head">
<span className="po-section-label">Playlist</span>
{dropErr && <span className="po-drop-err">{dropErr}</span>}
</div>
{items.length === 0 && (
<div className="muted po-playlist-empty">Drag clips here to build the playlist.</div>
)}
{items.map((it, index) => (
<div key={it.id} className="po-pl-item" draggable
onDragStart={e => onItemDragStart(e, index)}
onDragOver={onItemDragOver}
onDrop={e => onItemDrop(e, index)}>
<span className="po-pl-index">{index + 1}</span>
<span className="po-pl-name">{it.clip_name || it.asset_id}</span>
<span className={'badge ' + (MEDIA_STATUS_BADGE[it.media_status] || 'neutral')}>
{it.media_status}
</span>
{it.media_status === 'error' && (
<button className="btn ghost xs" onClick={() => restage(it.id)}>Retry</button>
)}
<button className="btn ghost xs" onClick={() => removeItem(it.id)}></button>
{items.map((it, index) => {
const isActive = index === activeIndex;
const dur = itemEffectiveDuration(it);
return (
<div key={it.id}
className={'po-pl-item' + (isActive ? ' po-pl-item--active' : '')}
draggable
onDragStart={e => onItemDragStart(e, index)}
onDragOver={onItemDragOver}
onDrop={e => onItemDrop(e, index)}>
<span className="po-pl-index">
{isActive ? <span className="po-pl-onair"></span> : index + 1}
</span>
<span className="po-pl-name">{it.clip_name || it.asset_id}</span>
<span className="mono po-pl-dur">{fmtDuration(dur)}</span>
<span className={'badge po-pl-badge ' + (it.media_status === 'ready' ? 'success' : it.media_status === 'staging' ? 'warn' : it.media_status === 'error' ? 'error' : 'neutral')}>
{it.media_status}
</span>
{it.media_status === 'error' && (
<button className="btn ghost xs" onClick={() => restage(it.id)}>Retry</button>
)}
<button className="btn ghost xs" onClick={() => removeItem(it.id)}></button>
<StagingBar status={it.media_status} />
</div>
);
})}
{items.length > 0 && (
<div className="po-playlist-footer">
<span className="mono muted">{items.length} clip{items.length !== 1 ? 's' : ''}</span>
<span className="mono po-pl-total">{fmtDuration(totalSecs)} total</span>
</div>
))}
)}
</div>
);
}
// Transport bar
function Transport({ channel, playlistId, onStatus }) {
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); } };
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 }),
@ -277,7 +334,9 @@ function Transport({ channel, playlistId, onStatus }) {
const live = channel.status === 'running';
return (
<div className="po-transport">
<button className="btn primary" disabled={!live || busy || !playlistId} onClick={play}> Play</button>
<button className="btn primary" disabled={!canPlay} onClick={play} title={notReady > 0 ? notReady + ' clip(s) still staging' : ''}>
{notReady > 0 && live ? '⏳ ' + notReady + ' staging' : '▶ Play'}
</button>
<button className="btn ghost" disabled={!live || busy} onClick={pause}> Pause</button>
<button className="btn ghost" disabled={!live || busy} onClick={resume}> Resume</button>
<button className="btn ghost" disabled={!live || busy} onClick={skip}> Skip</button>
@ -286,9 +345,72 @@ function Transport({ channel, playlistId, onStatus }) {
);
}
// 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;
}
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 onAir = channel.status === 'running';
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);
React.useEffect(() => {
const vid = videoRef.current;
if (!vid) return;
// 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()) {
const hls = new window.Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
// The playlist is served from /api/ (auth-gated); send the session
// cookie so the request authenticates. Segments are static + public.
xhrSetup: (xhr) => { xhr.withCredentials = true; },
});
hlsRef.current = hls;
hls.loadSource(previewUrl);
hls.attachMedia(vid);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (Safari).
vid.src = previewUrl;
vid.play().catch(() => {});
}
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
};
}, [onAir, channel.id]);
return (
<div className="po-monitor">
<div className="po-monitor-head">
@ -296,21 +418,85 @@ function ProgramMonitor({ channel, engine }) {
<span className="mono muted">{channel.output_type?.toUpperCase()} · {channel.video_format}</span>
</div>
<div className="po-monitor-screen">
{engine && engine.currentClip
? <div className="po-monitor-clip">{engine.currentClip}</div>
: <div className="muted">{onAir ? 'Idle — no clip playing' : 'Channel stopped'}</div>}
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
{!onAir && (
<div className="po-monitor-overlay muted">Channel stopped</div>
)}
</div>
<div className="po-monitor-foot mono muted">
{engine && engine.currentClip
? <span className="po-monitor-clip-name">{engine.currentClip}</span>
: <span>{onAir ? 'Idle' : 'Stopped'}</span>}
{engine && engine.currentIndex >= 0 && (
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10 }}>
<span style={{ color: 'var(--success)', fontVariantNumeric: 'tabular-nums' }}>
{fmtElapsed(elapsed)}
</span>
<span>clip {engine.currentIndex + 1}/{engine.playlistLength || 0}</span>
{engine.loop && <span></span>}
</span>
)}
{engine && engine.lastError && (
<span style={{ color: 'var(--warning)', fontSize: 10, marginLeft: 6 }} title={engine.lastError}></span>
)}
</div>
{engine && (
<div className="po-monitor-foot mono muted">
clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : ''} / {engine.playlistLength || 0}
{engine.loop ? ' · loop' : ''}
</div>
)}
</div>
);
}
// Channel detail (monitors + bin + playlist + transport)
// As-run compliance log. Polls the existing GET /channels/:id/asrun endpoint
// (rows written by the scheduler health tick on every clip change) and shows the
// most recent plays: start time, clip, on-air duration, result.
function AsRunPanel({ channel, refreshKey }) {
const [rows, setRows] = React.useState([]);
React.useEffect(() => {
let alive = true;
let t;
const poll = async () => {
try {
const r = await poFetch('/channels/' + channel.id + '/asrun');
if (alive) setRows(Array.isArray(r) ? r : []);
} catch (_) {}
t = setTimeout(poll, 5000);
};
poll();
return () => { alive = false; clearTimeout(t); };
}, [channel.id, refreshKey]);
const fmtTime = (ts) => {
if (!ts) return '—';
const d = new Date(ts);
return isNaN(d) ? '—' : d.toLocaleTimeString();
};
return (
<div className="po-asrun">
<div className="po-section-label">As-Run Log</div>
{rows.length === 0
? <div className="mono muted" style={{ padding: '8px 0' }}>No as-run entries yet.</div>
: (
<table className="po-asrun-table">
<thead>
<tr><th>Time</th><th>Clip</th><th>Duration</th><th>Result</th></tr>
</thead>
<tbody>
{rows.slice(0, 50).map((r) => (
<tr key={r.id}>
<td className="mono">{fmtTime(r.started_at)}</td>
<td>{r.clip_name || r.item_id || '—'}</td>
<td className="mono">{r.duration_s != null ? fmtDuration(Number(r.duration_s)) : (r.ended_at ? '—' : 'on air')}</td>
<td className={'po-asrun-result po-asrun-' + (r.result || 'played')}>{r.result || 'played'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function ChannelDetail({ channel, onChannelChange }) {
const [playlists, setPlaylists] = React.useState([]);
const [playlistId, setPlaylistId] = React.useState(null);
@ -365,6 +551,16 @@ function ChannelDetail({ channel, onChannelChange }) {
const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' });
setCh(updated); onChannelChange(updated);
};
const deleteChannel = async () => {
if (!window.confirm('Delete channel "' + ch.name + '"? This cannot be undone.')) return;
try {
await poFetch('/channels/' + ch.id, { method: 'DELETE' });
onChannelChange({ ...ch, _deleted: true });
} catch (e) { alert(e.message); }
};
// engine.currentIndex maps directly to the sorted item position.
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
return (
<div className="po-detail">
@ -377,6 +573,9 @@ function ChannelDetail({ channel, onChannelChange }) {
{ch.status === 'running'
? <button className="btn danger" onClick={stopChannel}>Stop channel</button>
: <button className="btn primary" onClick={startChannel}>Start channel</button>}
{ch.status !== 'running' && (
<button className="btn ghost danger sm" onClick={deleteChannel} title="Delete this channel">Delete</button>
)}
</div>
</div>
{ch.error_message && <div className="alert error">{ch.error_message}</div>}
@ -386,11 +585,19 @@ function ChannelDetail({ channel, onChannelChange }) {
<MediaBin projectId={ch.project_id} />
</div>
<Transport channel={ch} playlistId={playlistId} onStatus={() => loadItems()} />
<Transport channel={ch} playlistId={playlistId} items={items} onStatus={() => loadItems()} />
{playlistId && (
<Playlist channel={ch} playlistId={playlistId} items={items} onReload={loadItems} />
<Playlist
channel={ch}
playlistId={playlistId}
items={items}
activeIndex={activeIndex}
onReload={loadItems}
/>
)}
<AsRunPanel channel={ch} refreshKey={engine && engine.currentItemId} />
</div>
);
}
@ -414,6 +621,14 @@ function Playout() {
const selected = (channels || []).find(c => c.id === selectedId) || null;
const onChannelChange = (updated) => {
if (updated._deleted) {
setChannels(cs => {
const next = (cs || []).filter(c => c.id !== updated.id);
setSelectedId(next.length ? next[0].id : null);
return next;
});
return;
}
setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c));
};

View file

@ -293,7 +293,40 @@
text-align: center;
margin-top: 8px;
}
/* Logo wrapper holds the animated pulse halo behind the image. */
.launcher-logo-wrap {
position: relative;
display: inline-grid;
place-items: center;
width: 180px;
height: 180px;
}
.launcher-logo-pulse {
position: absolute;
left: 50%;
top: 50%;
width: 200px;
height: 200px;
transform: translate(-50%, -50%) scale(0.85);
border-radius: 50%;
pointer-events: none;
z-index: 0;
background: radial-gradient(
circle at center,
color-mix(in srgb, var(--accent) 55%, transparent) 0%,
color-mix(in srgb, var(--accent) 22%, transparent) 38%,
transparent 68%
);
filter: blur(2px);
animation: launcherLogoPulse 3.4s ease-in-out infinite;
}
@keyframes launcherLogoPulse {
0%, 100% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.45; }
50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.9; }
}
.launcher-logo {
position: relative;
z-index: 1;
width: 180px;
height: 180px;
object-fit: contain;
@ -308,6 +341,9 @@
from { opacity: 0; transform: translateY(8px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.launcher-logo-pulse { animation: none; opacity: 0.5; }
}
.launcher-wordmark {
margin: 0;
font-size: 44px;
@ -317,11 +353,23 @@
color: var(--text-1);
text-shadow: 0 0 32px rgba(91, 124, 250, 0.15);
}
.launcher-kicker {
margin: 2px 0 0;
color: var(--accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.launcher-tagline {
margin: 0;
color: var(--text-3);
font-size: 13.5px;
letter-spacing: 0.02em;
white-space: nowrap;
}
@media (max-width: 480px) {
.launcher-tagline { font-size: 11.5px; letter-spacing: 0; }
}
.launcher-tagline-motto {
@ -341,6 +389,19 @@
@media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } }
/* Settings sits on its own centered row beneath the main grid. */
.launcher-settings-row {
width: 100%;
display: flex;
justify-content: center;
}
.launcher-tile-settings {
width: 100%;
max-width: calc((100% - 28px) / 3);
}
@media (max-width: 960px) { .launcher-tile-settings { max-width: calc((100% - 14px) / 2); } }
@media (max-width: 620px) { .launcher-tile-settings { max-width: 100%; } }
.launcher-tile {
position: relative;
display: grid;

View file

@ -50,12 +50,26 @@
.po-onair { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: 0.04em; }
.po-onair.live { color: var(--danger); }
.po-monitor-screen {
flex: 1; min-height: 220px; background: #000;
position: relative; flex: 1; min-height: 220px; background: #000;
display: flex; align-items: center; justify-content: center;
color: var(--text-2);
}
.po-monitor-clip { font-family: var(--font-mono); font-size: 14px; color: var(--text-1); }
.po-monitor-foot { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px; }
.po-monitor-video {
width: 100%; height: 100%; object-fit: contain; display: block;
}
.po-monitor-overlay {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.6); color: var(--text-2);
pointer-events: none;
}
.po-monitor-foot {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px;
}
.po-monitor-clip-name {
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--text-1);
}
/* Media bin */
.po-bin {
@ -75,7 +89,7 @@
/* Transport */
.po-transport {
display: flex; gap: 8px; flex-wrap: wrap;
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
}
@ -84,19 +98,96 @@
border-radius: 12px; overflow: hidden;
min-height: 120px;
}
.po-playlist-empty { padding: 28px 12px; text-align: center; }
.po-pl-item {
.po-playlist-head {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px; border-bottom: 1px solid var(--border);
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
.po-drop-err { font-size: 11px; color: var(--danger); }
.po-playlist-empty { padding: 28px 12px; text-align: center; }
.po-pl-item {
position: relative;
display: flex; align-items: center; gap: 10px;
padding: 9px 12px 13px; /* extra bottom padding for the staging bar */
border-bottom: 1px solid var(--border);
cursor: grab; user-select: none;
}
.po-pl-item:hover { background: var(--bg-3); }
.po-pl-item:active { cursor: grabbing; }
.po-pl-item--active {
background: color-mix(in srgb, var(--danger) 8%, transparent);
border-left: 3px solid var(--danger);
}
.po-pl-item--active:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
.po-pl-index {
width: 22px; text-align: center; font-family: var(--font-mono);
font-size: 12px; color: var(--text-3);
font-size: 12px; color: var(--text-3); flex-shrink: 0;
}
.po-pl-onair { color: var(--danger); font-size: 11px; }
.po-pl-name { flex: 1; font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.po-pl-dur { font-size: 11px; color: var(--text-3); flex-shrink: 0; min-width: 40px; text-align: right; }
.po-pl-badge { flex-shrink: 0; }
/* Staging progress bar — sits flush at the bottom of each playlist item */
.po-staging-bar {
position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
}
.po-staging-bar--pending { background: var(--text-3); opacity: 0.3; }
.po-staging-bar--staging {
background: linear-gradient(90deg, transparent 0%, var(--warning) 50%, transparent 100%);
background-size: 200% 100%;
animation: po-staging-sweep 1.4s linear infinite;
}
.po-staging-bar--ready { background: var(--success); opacity: 0.8; }
.po-staging-bar--error { background: var(--danger); }
@keyframes po-staging-sweep {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Playlist footer */
.po-playlist-footer {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-3);
background: var(--bg-2);
}
.po-pl-total { color: var(--text-2); }
/* As-run log */
.po-asrun {
display: flex; flex-direction: column; gap: 8px;
padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
}
.po-asrun-table {
width: 100%; border-collapse: collapse; font-size: 12px;
}
.po-asrun-table th {
text-align: left; font-weight: 600; color: var(--text-3);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px; border-bottom: 1px solid var(--border);
}
.po-asrun-table td {
padding: 5px 8px; border-bottom: 1px solid var(--border);
color: var(--text-1); overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; max-width: 220px;
}
.po-asrun-table tr:last-child td { border-bottom: none; }
.po-asrun-result { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
.po-asrun-played { color: var(--success); }
.po-asrun-skipped { color: var(--warning); }
.po-asrun-error { color: var(--danger); }
/* Downloads modal section header */
.downloads-section-head {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-3);
padding-bottom: 8px; border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
/* Small button variants reused */
.btn.xs { padding: 2px 8px; font-size: 11px; }

View file

@ -1376,3 +1376,231 @@
/* Tint Cancel-all-failed button to signal destructive action without
making it loud same pattern as the per-row Cancel. */
.jobs-cancel-all { color: var(--danger); }
/* ========================================================================
Dashboard (operations overview) - design rebuild.
Appended last so the design's .dash-grid / .dash-statusbar override the
earlier (pre-redesign) definitions of those two container classes.
======================================================================== */
.page.dashboard { padding: 0; }
.ops-header {
display: flex; align-items: flex-end; gap: 16px;
padding: 24px 28px 18px;
}
.ops-header h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
.ops-sub { margin-top: 5px; color: var(--text-3); font-size: 12.5px; }
.ops-header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.ops-clock {
display: flex; align-items: center; gap: 9px;
height: 32px; padding: 0 12px;
border: 1px solid var(--border); border-radius: var(--r-sm);
background: var(--bg-1);
}
.ops-clock-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--live); box-shadow: 0 0 0 3px var(--live-soft);
animation: dotpulse 1.6s ease-in-out infinite;
}
.ops-clock-time { font-size: 13px; font-weight: 500; letter-spacing: 0.03em; color: var(--text-1); font-variant-numeric: tabular-nums; }
.ops-clock-day { font-size: 10px; color: var(--text-3); letter-spacing: 0.08em; }
.ops-nodes-pill {
display: flex; align-items: center; gap: 7px;
height: 32px; padding: 0 12px;
border: 1px solid var(--border); border-radius: var(--r-sm);
background: var(--bg-1);
font-size: 12px; color: var(--text-2); font-family: var(--font-mono);
}
/* ---- status strip ---- */
.ops-stats {
display: grid; grid-template-columns: repeat(4, 1fr);
margin: 0 28px;
background: var(--bg-1); border: 1px solid var(--border);
border-radius: var(--r-lg); overflow: hidden;
}
.ops-stats.six { grid-template-columns: repeat(6, 1fr); }
.stat-cell { padding: 15px 16px 14px; border-left: 1px solid var(--border); min-width: 0; }
.stat-cell:first-child { border-left: 0; }
.stat-cell-label {
font-size: 10.5px; color: var(--text-3); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.stat-cell-value {
margin-top: 9px; font-size: 26px; font-weight: 600; line-height: 1;
letter-spacing: -0.02em; font-variant-numeric: tabular-nums;
display: flex; align-items: baseline; gap: 6px;
}
.stat-cell-unit { font-size: 12px; font-weight: 500; color: var(--text-3); letter-spacing: 0; }
.stat-cell-foot {
margin-top: 10px; font-size: 11.5px; color: var(--text-3);
display: flex; align-items: center; gap: 8px; white-space: nowrap;
overflow: hidden;
}
.stat-cell-foot .foot-danger { color: var(--danger); }
.stat-cell-foot .foot-warn { color: var(--warning); }
.stat-pips { display: flex; align-items: center; gap: 11px; }
.stat-pip { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-2); font-variant-numeric: tabular-nums; }
.stat-pip i { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.stat-pip.armed i { background: var(--accent); }
.stat-pip.idle i { background: var(--text-4); }
.stat-pip.zero { color: var(--text-4); }
.stat-pip.zero i { opacity: 0.4; }
/* ---- section heads ---- */
.section-head { display: flex; align-items: center; gap: 10px; padding: 22px 0 11px; }
.section-head:first-child { padding-top: 6px; }
.section-head-title { font-size: 13px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; }
.section-head-sub { font-size: 11.5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.section-head-count { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--danger); background: var(--danger-soft); padding: 1px 7px; border-radius: 99px; }
.section-head .btn { margin-left: auto; flex-shrink: 0; }
.section-head-live {
width: 7px; height: 7px; border-radius: 50%;
background: var(--live); box-shadow: 0 0 0 3px var(--live-soft);
animation: dotpulse 1.6s ease-in-out infinite; flex-shrink: 0;
}
/* ---- dashboard grid (overrides earlier .dash-grid) ---- */
.page.dashboard .dash-grid {
display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(300px, 1fr);
gap: 22px; padding: 6px 28px 8px; align-items: start;
}
.dash-main, .dash-side { min-width: 0; }
/* ---- live ingest tiles ---- */
.live-now-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(188px, 1fr));
gap: 12px;
}
.ingest-tile {
background: var(--bg-1); border: 1px solid var(--border);
border-radius: var(--r-md); overflow: hidden; cursor: pointer;
transition: border-color 120ms, transform 120ms;
}
.ingest-tile:hover { border-color: var(--border-strong); transform: translateY(-1px); }
.ingest-tile.recording { border-color: rgba(255,59,48,0.28); }
.ingest-tile-screen { position: relative; aspect-ratio: 16 / 9; background: var(--bg-2); overflow: hidden; }
.ingest-tile-audio {
position: absolute; inset: 0; display: grid; place-items: center; padding: 16px;
background: linear-gradient(160deg, var(--bg-2), var(--bg-1));
}
.ingest-tile-audio .waveform { width: 100%; height: 58%; opacity: 0.85; }
.ingest-tile-veil { position: absolute; inset: 0; background: rgba(11,13,17,0.5); z-index: 1; }
.ingest-tile-top { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; align-items: center; gap: 6px; z-index: 2; }
.ingest-tile-top .badge.outline { margin-left: auto; background: rgba(0,0,0,0.45); border-color: rgba(255,255,255,0.18); color: #fff; backdrop-filter: blur(4px); }
.ingest-tile-bottom { position: absolute; left: 8px; right: 8px; bottom: 8px; display: flex; align-items: center; gap: 6px; z-index: 2; }
.ingest-tile-name {
color: #fff; font-size: 12px; font-weight: 500;
background: rgba(0,0,0,0.6); padding: 3px 8px; border-radius: 4px; backdrop-filter: blur(4px);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
.ingest-tile-tc { margin-left: auto; color: #fff; font-size: 11px; background: rgba(0,0,0,0.6); padding: 3px 6px; border-radius: 4px; backdrop-filter: blur(4px); flex-shrink: 0; }
.ingest-tile-foot { display: flex; align-items: center; gap: 8px; padding: 8px 11px; font-size: 11px; color: var(--text-3); }
.ingest-tile-foot .dot-sep { color: var(--text-4); }
.ingest-tile-node { margin-left: auto; color: var(--text-4); }
/* ---- on-air empty / standby ---- */
.onair-empty { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.onair-empty-head { display: flex; align-items: center; gap: 14px; padding: 18px; }
.onair-empty-icon {
width: 38px; height: 38px; flex-shrink: 0; border-radius: 50%;
background: var(--bg-3); border: 1px solid var(--border);
display: grid; place-items: center; color: var(--text-3);
}
.onair-empty-copy { flex: 1; min-width: 0; }
.onair-empty-title { font-size: 13.5px; font-weight: 600; }
.onair-empty-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.onair-empty-head .btn { flex-shrink: 0; }
.onair-sources {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px; padding: 14px; border-top: 1px solid var(--border); background: var(--bg-0);
}
.onair-source {
display: flex; align-items: center; gap: 9px; padding: 9px 11px;
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--r-md);
text-align: left; cursor: pointer; transition: background 80ms, border-color 80ms;
}
.onair-source:hover { background: var(--bg-3); border-color: var(--border-strong); }
.onair-source-name { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.onair-source-src { font-size: 10px; font-family: var(--font-mono); color: var(--text-3); padding: 1px 6px; border: 1px solid var(--border-strong); border-radius: 4px; }
.onair-source-go { margin-left: auto; display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--accent-text); white-space: nowrap; }
/* ---- job queue table ---- */
.job-table { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.job-table-head, .job-table-row {
display: grid; grid-template-columns: 148px minmax(0, 1fr) 84px 170px 52px;
gap: 14px; align-items: center; padding: 0 14px;
}
.job-table-head {
height: 34px; border-bottom: 1px solid var(--border);
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4);
}
.job-table-row { height: 42px; border-bottom: 1px solid var(--border); }
.job-table-row:last-child { border-bottom: 0; }
.jt-job { display: flex; align-items: center; gap: 8px; color: var(--text-2); font-size: 11.5px; }
.jt-asset { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-1); font-size: 12.5px; }
.jt-node { color: var(--text-3); font-size: 11px; }
.jt-progress { display: flex; align-items: center; }
.jt-bar { display: block; width: 100%; height: 5px; background: var(--bg-3); border-radius: 99px; overflow: hidden; }
.jt-bar > span { display: block; height: 100%; background: var(--accent); border-radius: 99px; transition: width 300ms; }
.jt-eta { color: var(--text-3); font-size: 11px; text-align: right; }
/* ---- needs attention ---- */
.attention-panel { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.attn-row { display: flex; align-items: center; gap: 11px; padding: 11px 13px; border-bottom: 1px solid var(--border); }
.attn-row:last-child { border-bottom: 0; }
.attn-sev { width: 26px; height: 26px; flex-shrink: 0; border-radius: var(--r-sm); display: grid; place-items: center; }
.attn-sev.danger { background: var(--danger-soft); color: var(--danger); }
.attn-sev.warning { background: var(--warning-soft); color: var(--warning); }
.attn-body { flex: 1; min-width: 0; }
.attn-title { font-size: 12.5px; font-weight: 500; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.attn-meta { font-size: 10.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.attn-row .btn { flex-shrink: 0; }
/* ---- cluster node list ---- */
.node-list { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.node-row { display: flex; align-items: center; gap: 14px; padding: 11px 14px; border-bottom: 1px solid var(--border); }
.node-row:last-child { border-bottom: 0; }
.node-row.offline { opacity: 0.55; }
.node-row-id { display: flex; align-items: center; gap: 8px; width: 158px; flex-shrink: 0; }
.node-name { font-size: 12px; color: var(--text-1); font-weight: 500; }
.badge.node-role { height: 17px; padding: 0 5px; font-size: 9px; }
.node-row-metrics { flex: 1; display: grid; grid-template-columns: 1fr 1fr auto; gap: 16px; align-items: center; min-width: 0; }
.node-metric { display: flex; align-items: center; gap: 8px; min-width: 0; }
.node-metric-label { font-size: 10px; color: var(--text-4); width: 24px; flex-shrink: 0; letter-spacing: 0.04em; }
.node-metric-bar { flex: 1; height: 5px; background: var(--bg-3); border-radius: 99px; overflow: hidden; min-width: 26px; }
.node-metric-bar > span { display: block; height: 100%; border-radius: 99px; transition: width 300ms; }
.node-metric-text { font-size: 10.5px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
.node-gpu { font-size: 10.5px; color: var(--text-3); white-space: nowrap; justify-self: end; }
.node-row-off { flex: 1; color: var(--text-4); font-size: 11.5px; font-family: var(--font-mono); }
/* ---- footer status bar (overrides earlier .dash-statusbar) ---- */
.page.dashboard .dash-statusbar {
display: flex; align-items: center; gap: 14px;
margin: 14px 28px 30px; padding-top: 13px;
border-top: 1px solid var(--border);
font-size: 11.5px; color: var(--text-3); font-family: var(--font-mono);
}
.dash-statusbar .sb-item { display: flex; align-items: center; gap: 6px; }
.dash-statusbar .sb-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-4); }
.dash-statusbar .sb-dot.live { background: var(--live); }
.dash-statusbar .sb-dot.run { background: var(--accent); }
.dash-statusbar .sb-dot.fail { background: var(--danger); }
.dash-statusbar .sb-spacer { flex: 1; }
.dash-statusbar .sb-sep { color: var(--text-4); }
@media (max-width: 1340px) {
.ops-stats.six { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 1180px) {
.ops-stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 1080px) {
.page.dashboard .dash-grid { grid-template-columns: 1fr; }
.job-table-head, .job-table-row { grid-template-columns: 130px minmax(0, 1fr) 140px 48px; }
.job-table-head span:nth-child(3), .job-table-row .jt-node { display: none; }
}

View file

@ -57,10 +57,30 @@ async function measureLoudness(inputPath) {
return JSON.parse(match[0]);
}
function isFiniteLoudness(val) {
const n = parseFloat(val);
return isFinite(n);
}
async function applyLoudnorm(inputPath, outputPath, m) {
// Pass 2: linear normalization using pass 1's measurements. -c:v copy keeps
// the video stream intact so we only re-encode audio (target AAC stereo, the
// common-denominator CasparCG ffmpeg producer accepts).
//
// Silent / no-audio clips measure I=-inf which ffmpeg rejects in pass 2.
// When any loudnorm measurement is non-finite, fall back to a plain audio
// transcode (AAC 192k) with no loudness adjustment — the clip has no
// meaningful audio to normalize.
const silentOrNoAudio = !isFiniteLoudness(m.input_i) || !isFiniteLoudness(m.input_tp);
if (silentOrNoAudio) {
console.log(`[playout-stage] loudnorm skip — silent/no audio (I=${m.input_i}), transcoding audio only`);
await runFfmpeg([
'-hide_banner', '-nostats', '-y', '-i', inputPath,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
outputPath,
]);
return;
}
await runFfmpeg([
'-hide_banner', '-nostats', '-y', '-i', inputPath,
'-af', `loudnorm=I=-23:TP=-1:LRA=11:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true:print_format=summary`,