Playout preview: add hls.js ERROR handler (recover media/network errors,
resume on stall) + live-edge tuning — first transient error no longer halts
<video> to black. Purge stale HLS segments before re-mux (re)start so a
prior/duplicate sidecar session can't corrupt the live playlist.
Growing files: normalize growing_smb_mount (smb://, \host\share, host/share)
to CIFS UNC //host/share in capture-manager — mount no longer fails and
falls back to S3.
Monitors: surface playout channels as monitor tiles (live HLS on-air,
idle placeholder otherwise) in a labeled Playout group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The UXP panel icon-rail redesign (v2.2.2 .ccx + premiere-plugin-uxp src) is
already present in main, identical, via later panel work. The branch's only
remaining delta is a stale screens-admin/editor/data.jsx (~685 lines behind
main's newer admin code, incl. the Add Node wizard). Recorded as merged with
-s ours to avoid reverting newer work; main tree unchanged.
All fixes from this May-17 branch (SRT/RTMP ffmpeg spawn, yuv420p thumbnail
pix_fmt, proxy-from-hires enqueue, recorder field aliases) already exist in
main via later work. Recorded as merged with -s ours; main tree unchanged.
Cluster: AddNodeModal on Admin->Cluster mints a node token via /auth/tokens
and emits a ready-to-paste curl|bash onboarding command. New admin-only
GET /cluster/onboard-info returns apiUrl/scriptUrl/branch. Role->PROFILES
mapping (worker/capture/gpu); gate worker-l4 behind compose profile [gpu].
Home: restore "Let's Create" kicker + one-line "Media Asset Management &
Production Platform" tagline; animated accent pulse behind the dragon logo
(reduced-motion safe); move Settings tile to a centered bottom row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dashboard (screens-home.jsx): rebuild to new design, fully live-wired.
Dropped fabricated figures per "real data" rule (object-store %, uptime,
storage breakdown); repurposed ingest cell to real Assets-24h count.
Fixed undefined refs and double-rendered Resources section.
Playout: as-run writer in scheduler.js writeAsRun() off the health-tick
/status poll; AsRunPanel UI + missing CSS in styles-playout.css.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 720p30 libx264 preview re-encode couldn't sustain real time on the
CPU-only sidecar (running alongside CasparCG's mixer + STREAM consumer):
the UDP input overran and the HLS output stalled, freezing the playlist so
hls.js saw a static live edge (monitor black). Drop the confidence monitor
to 360p / 20fps / ultrafast (-g 40 = 2.0s GOP) — a fraction of the cost,
sustains real time comfortably. NVENC would be ideal but the image's static
ffmpeg has no nvenc encoder; 360p ultrafast is plenty for a preview.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the persistent black preview, fully isolated: ZAMPP1's nginx
serves the live .m3u8 fresh on every request (no-store works there), but
the PUBLIC reverse proxy (159.112.211.103 -> ZAMPP1) caches the static
.m3u8 by path with a multi-second TTL, ignoring both the origin's no-store
and query params. hls.js reloads the playlist ~every second, always landing
inside that TTL, so it sees the live playlist as never advancing
("live playlist MISSED" forever), never establishes the timeline, and never
loads a fragment -> readyState 0 (black). Proven: rapid reads via ZAMPP1
localhost advance (404->405); the same rapid reads via the public URL are
stuck; query-busting doesn't help (proxy caches by path).
Fix: serve the playlist through GET /api/v1/playout/channels/:id/hls/index.m3u8
instead of the static /media/live path. /api/ is not proxy-cached (the live
status poll already updates fine through it), so hls.js always gets 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
correct). ProgramMonitor points hls.js at the /api playlist and sends the
session cookie (xhrSetup withCredentials) since /api is auth-gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Definitive root cause of the black preview, proven in-browser: the live
.m3u8 was served Cache-Control: no-cache, so the browser cached the
playlist and served a STALE copy to hls.js's reloads (cache:'default'
stuck at one MEDIA-SEQUENCE while cache:'reload' advanced). hls.js saw the
live playlist as never advancing -> "live playlist MISSED" forever ->
never established the timeline -> never loaded a fragment -> readyState 0
(black), even though the stream itself is clean and advancing server-side.
Fix: serve live HLS (/live and /media/live) with
"no-store, no-cache, must-revalidate" + Pragma no-cache so the browser
never caches the playlist and every reload fetches the fresh live edge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audio-strip fix made the stream decode cleanly server-side, but the
browser monitor was still black: with -c:v copy the re-mux passed through
CasparCG's erratic real-time keyframes (segments 0.6-2.8s) and irregular
PTS. hls.js can't build a live timeline from that — it logs
"sliding 0.00 / prev-sn na / MISSED" and never loads a fragment (verified
live in-browser: readyState stays 0). A standalone ffmpeg honours
-force_key_frames, so re-encode to libx264 with a forced 2s GOP + CFR
fps=30, scaled to 720p for a light confidence monitor. Every HLS segment
is now a clean, keyframe-aligned 2.0s chunk hls.js can sync to.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Growing-files mode became per-recorder (recorders.growing_enabled); the
global growing_enabled setting was removed. GET /assets/:id/live-path —
which the Premiere plugin calls to mount a still-growing master — still
required growing_enabled==='true', so Mount Live would 409 "Growing-files
mode is disabled" on any deploy where that stale key isn't set. Drop the
global gate: a status='live' asset already proves a growing recorder is
producing the file; only the editor-facing growing_smb_url is required.
Response contract is unchanged, so the plugin needs no update.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements docs/superpowers/specs/2026-05-31-storage-settings-growing-smb-design.md.
1. Storage warning banner at the top of Settings → Storage (set-once /
path-change-corrupts-data warning).
2. Growing-files SMB credentials + system CIFS mount (Approach A):
- settings.js: new global keys growing_smb_mount / growing_smb_username /
growing_smb_vers; growing_smb_password is write-only (GET returns only
growing_smb_password_exists; growing_smb_password_clear:true removes it).
- GrowingSettingsCard: SMB mount/username/password (masked, "saved" state) +
CIFS version fields.
- capture Dockerfile: add cifs-utils + util-linux.
- capture-manager: on growing start, mount //host/share at /growing using a
root-only credentials file (creds never on the command line); unmount on
stop; mount failure falls back to S3 streaming so a recording is never lost.
- recorders.js: pass GROWING_SMB_* env; don't host-bind /growing when a CIFS
mount is configured (an empty mountpoint is required).
3. Per-recorder growing mode (global toggle removed):
- Removed the global "capture writes to local SMB share first" checkbox; the
growing card is now SMB-infrastructure-only.
- recorders.js reads the per-recorder recorders.growing_enabled column
(already present from migration 014) instead of the global setting;
RECORDER_FIELDS += growing_enabled.
- New-recorder modal: "Growing-files mode" toggle.
- storage.js overview: "enabled" now means the SMB landing zone is configured
(mount source set), surfaced as smb_mount; health strip labels updated.
No DB migration required (recorders.growing_enabled exists; new settings are
key/value rows).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CasparCG 2.4.0's config parser aborts at startup (SIGABRT, exit 134) on
nearly every launch when the config contains XML comments / blank lines.
Proven empirically: identical image + comment-free config = 8/8 stable
starts; + commented config = 0/8 (all abort before logging init). Use
the compact comment-free form that the known-good image shipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
apt-get install ffmpeg pulls in ~80 transitive shared libs (libav*,
libx264, libdrm, libva...) that perturb CasparCG 2.4.0's headless
runtime linking and make it abort with SIGABRT (exit 134) on almost
every launch. Replace it with john van sickle's self-contained static
ffmpeg/ffprobe binaries in /usr/local/bin — the standalone CLI the HLS
re-muxer needs, with zero new shared libraries, keeping CasparCG's
environment identical to the known-good image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2.4.x server aborts at startup if its configured log-path isn't
writable/creatable. /opt/casparcg is a read-only-ish symlinked install
dir; the entrypoint already mkdirs /media/casparcg/{log,data}. Point the
config there to match (the working image used these paths).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CasparCG's bundled FFMPEG/HLS consumer muxes a broken audio track
(aac sample_rate=0, time_base 1/0) into the preview, and silently drops
every arg that would remove it (-an, -codec:a, -g, -r all "Unused
option"). That corrupt audio black-screens the browser preview because
neither ffmpeg nor hls.js can decode the playlist.
Re-architect the preview path: CasparCG now STREAMs plain mpegts to a
UDP loopback port, and a Node-spawned STANDALONE ffmpeg (where -an
actually works) re-muxes it to clean, video-only HLS with -c:v copy.
The child process is tracked, auto-respawned while running, and killed
in stopChannel(). The PRIMARY SRT/RTMP/SDI/NDI output (with program
audio) is untouched.
Also fix the Dockerfile to match the working image: ubuntu:22.04 base +
CasparCG 2.4.0 ubuntu22 zip + NodeSource Node 20, and add a standalone
ffmpeg CLI. The old 2.3.3 tarball URL 404s. entrypoint.sh updated for
the 2.4.x bin/casparcg layout + bundled lib/ LD_LIBRARY_PATH.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Definitive root cause of the black preview, found via server-side ffmpeg
decode of the live playlist:
Error while decoding stream #0:1: Invalid data found (x57)
[abuffer] Value inf for parameter 'time_base' ... time_base to value 1/0
Stream #0:1 is the AAC audio. CasparCG's real-time channel feeds the HLS
consumer an audio stream whose muxed time_base is 1/0 (infinity). ffmpeg
itself cannot decode the playlist, and hls.js silently fails to append the
fragment after demux, so the <video> stays at readyState 0 (black) even
though the video PTS is perfectly continuous and segments serve 200.
Fix: drop audio from the HLS confidence monitor (-an). The video track is
clean h264 and plays in hls.js. Program audio still rides the primary
SRT/RTMP/SDI/NDI output, which is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CasparCG's FFMPEG consumer ignores -force_key_frames ("Unused option")
because it routes args to the muxer, not the encoder. Revert to the
frame-based GOP (-g 60 -keyint_min 60) but keep the forced CFR rate
(-r 30000/1001): at 29.97fps a 60-frame GOP is exactly 2.0s, so keyframes
and HLS splits land on clean 2s boundaries. CFR is what was missing
originally — with the channel's irregular feed rate, "60 frames" drifted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the black preview: CasparCG's real-time channel feeds the
HLS consumer frames with irregular timestamps (the "packet with pts X has
duration 0" warnings). With frame-count GOPs (-g 60) the muxer split
points drift, producing erratic segment durations (0.4s-4.2s) that exceed
the declared TARGETDURATION. hls.js parses the resulting live playlist but
can never establish a fragment timeline — it reloads forever
("sliding 0.00 / prev-sn na / MISSED") and never appends a fragment, so
the video element stays at readyState 0 (black). Verified live via the
browser: manifest + segments serve 200, segment is valid h264/aac with a
keyframe start, yet hls.js logs zero FRAG_LOADED.
Fix: force a constant output frame rate (-r 30000/1001, regenerates
uniform PTS) and time-based keyframes every 2s (-force_key_frames
expr:gte(t,n_forced*2)), so every segment is a clean keyframe-aligned 2.0s
chunk. Yields a spec-compliant playlist (TARGETDURATION 2, stable
8-segment/16s window) identical in shape to the capture/VOD HLS the rest
of the app already plays successfully through the same hls.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- nginx.conf: add /media/live/ location serving from the media volume
mount. CasparCG sidecar writes HLS preview to /media/live/<id>/ but
nginx only had /live/ (capture volume). Without this, preview
requests returned the SPA shell instead of the .m3u8 playlist.
- ProgramMonitor: add live elapsed counter (MM:SS, ticks every 500ms)
driven by engine.currentItemStartedAt. Shows alongside clip index.
Adds a ⚠ pip when lastError is set (e.g. NDI SDK missing) without
blocking operation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- startChannel: make primary consumer ADD non-fatal. CasparCG decodes
and routes media without an output consumer, so NDI channels (no SDK)
and misconfigured SRT/RTMP channels still load/play clips and expose
the HLS preview. state.lastError carries the consumer error for UI
visibility without blocking operation.
- loadPlaylist: throw early if state.running=false (channel/start was
never called or failed hard) with a clear error instead of a cryptic
CasparCG AMCP error propagating to the operator.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- spawnChannelSidecar: set last_heartbeat_at = NOW() when flipping
channel to 'running'. Without this, last_heartbeat_at is NULL so
the first scheduler tick sees ageMs = (now - epoch) >> TIMEOUT_MS
and triggers failover before the sidecar has had a single chance
to respond.
- scheduler playoutHealthTick: when last_heartbeat_at is NULL fall
back to updated_at as the baseline (belt-and-suspenders with the
spawnChannelSidecar fix). Also include updated_at in the query.
- POST /channels/:id/play: catch callSidecar errors explicitly and
return 502 Bad Gateway instead of delegating to next(err) which
the error middleware maps to 409 Conflict.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- jobs.js: add playout-stage BullMQ queue to QUEUES; asset_id from
job data is already resolved to a name by attachAssetNames
- screens-jobs.jsx: map type 'playout-stage' -> kind 'Stage' with
monitor icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- playout-stage: skip loudnorm pass 2 when measured_I=-inf (silent or
no-audio clip); fall back to plain AAC transcode so staging completes
instead of erroring out
- screens-home: add Playout tile; replace Premiere panel tile with
Downloads tile opening a combined modal (Premiere panel releases +
Dragon-ISO link to forge.wilddragon.net/WildDragonLLC/dragon-iso)
- screens-playout: add Delete channel button (visible only when stopped);
removes channel from list and selects next on confirm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix event bubbling: e.stopPropagation() in onItemDrop prevents
duplicate POST when dropping on an existing playlist item
- Wrap all drop handlers in try/catch with inline error display
- ProgramMonitor: replace text placeholder with hls.js video player
loading /media/live/<channel_id>/index.m3u8; falls back to native
HLS on Safari; destroys Hls instance on channel stop/unmount
- Playlist: per-item duration (MM:SS), staging progress bar with
animated stripe while staging, now-playing highlight + ▶ indicator
driven by engine.currentIndex from 4s status poll
- Playlist footer: clip count + total duration sum
- Transport: Play button disabled + shows '⏳ N staging' until all
items are media_status=ready, eliminating the staging-not-ready 409
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this, a freshly-spawned channel with NULL last_heartbeat_at was
instantly failover-killed by the playoutHealthTick because `0` was used as
the lastSeen timestamp, making ageMs huge on the very first tick.
Playout failover queries cluster_nodes.last_seen_at to find healthy nodes
for channel re-placement. Column missing from original cluster schema.
Migration 031 adds column + backfills existing nodes to NOW().
Fixes scheduler error: column "last_seen_at" does not exist
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Backend (routes/users.js):
- GET / now returns totp_enabled so the UI can show 2FA status
- GET /:id/access — admin-only effective per-project access (MAX over direct +
group grants), labels via=direct|group:<name>; admins report all/edit
- POST /:id/totp/disable — admin clears a locked-out user's 2FA without their
password (self-service disable still requires it); dev user blocked
- role validated against {admin,editor,viewer} on create + PATCH (was unchecked)
Frontend:
- Users>Policies tab: static prose replaced with interactive per-user matrix —
inline role select, 2FA badge, Reset-2FA action, lazy per-user access expander
- Home "Premiere panel" tile -> "Downloads"; modal renamed, adds Teams ISO row
(disabled "coming soon" until the .exe is supplied); UXP .ccx link unchanged
- data.jsx: window.TEAMS_ISO placeholder ({available:false})
Not runtime-tested in browser yet. Teams ISO .exe still pending from user.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>