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:
commit
43011bd794
25 changed files with 2102 additions and 596 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 || '');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.6–2.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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue