The Deltacast picker's selected index is the capture channel on the single
board. Write it into source_config.port (in addition to device_index) so the
capture sidecar maps "pick channel N" to the bridge's --port N. device_index is
retained for backward-compatible display/fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Added useEffect to parse location.hash and update route state.
Fixes deep links like /#/library not rendering correct screen.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Cap monitor column at 960px width so full GUI fits 1920x1080 without scroll.
Preview now ~960×540px (16:9), leaves room for 300px rail + margins.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Growing root cause (4th attempt): Premiere doesn't import H.264-in-.ts
("unsupported compression type"); its growing-file support is MXF OP1a.
Prior MXF/DNxHR failed because DNxHR is VBR and never flushes the incremental
index — XDCAM HD422 (mpeg2video, CBR) DOES write index segments into body
partitions mid-record (proven live via SIGKILL: 5 index segments, readable,
no footer). Growing master is now MXF OP1a / XDCAM HD422 4:2:2 CBR + PCM s16le,
operator bitrate as CBR (default 50M). live-path returns .mxf to match.
GUI: bitrate input is now always editable in growing mode (was hidden for
ProRes-selected codecs); codec menu shown disabled-with-explanation under
growing (it had only looked "missing" due to a stale served bundle).
Requires Premiere prefs: Media > "Automatically refresh growing files" ON,
and disable the two XMP-write-on-import options.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
screens-playout.jsx declared a top-level function fmtDuration(secs) that, in
the shared global script scope, overwrote data.jsx's fmtDuration(ms). After
the playout redesign loaded, normalizeAsset(duration_ms) hit the seconds-based
version, rendering every asset duration x1000 (15000ms shown as 4:10:00).
Rename the playout-local helpers to playoutFmtDur/playoutFmtTC.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Growing master was H.264 High 4:2:2 Intra (high422/yuv422p) — ffprobe/VLC
open it but Adobe Premiere's H.264 importer only accepts 8-bit 4:2:0, so it
refused ("won't import"). Switch growing video to -profile:v high
-pix_fmt yuv420p (still -g 1 all-intra). Also the growing branch ignored the
operator's bitrate; now applies -b:v/-maxrate/-bufsize. Modal notes that
growing mode fixes codec/container (bitrate still applies).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add v2.2.3 to downloads (streaming write fix for large imports)
- Fix duration bug: worker now overwrites with ffprobe result instead of preserving capture wall-clock estimate
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Drop in the redesigned timeline-centric Playout (PGM monitor, transport,
SCTE-35 card, as-run drawer) from the on-node redesign, fully wired to the
real playout API (channels/transport/HLS preview w/ error-recovery/as-run);
no mock data. In-page ConfirmModal for destructive actions.
SCTE-35: new playout_scte_breaks table (migration 033), endpoints to
schedule/trigger/list/cancel breaks (POST/GET/DELETE /channels/:id/scte[/trigger]),
scheduler due-break sweep, engine triggerScte + auto-return + as-run 'scte'
rows + on-air SCTE-BREAK state and timeline AD markers. In-stream SCTE-35
cue injection is a documented stub (CasparCG FFMPEG consumer exposes no
scte35 muxer) — scheduling/triggering/countdown/as-run are functional.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#162 local-spawn stop now uses /stop?t=180 + waits for asset to leave 'live'
before removing the container (no more SIGKILL-corrupted masters / stuck-live).
#163 validateRecorderConfig guard (PCM!=MP4, HEVC!=MXF, NVENC needs GPU) on
create+PATCH; codec presets in new-recorder modal.
#159 container list reads Docker /stats memory (N/A when null) + UI render.
#160 primary node self-populates version + uptime on the Cluster screen.
#145 asset-detail Download original gated by dismissable size warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace 17 native window.confirm() destructive prompts with an in-page
ConfirmModal/useConfirm (added to visuals.jsx) across jobs/asset/editor/
ingest/projects/admin/playout/library. Add "Created by Wild Dragon LLC"
footer to the home launcher.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-node "Capture Drivers / SDKs" panel installs Blackmagic / AJA / Deltacast
/ NDI drivers without SSH. node-agent gains NODE_TOKEN-gated /driver/install
+ /driver/status (spawns a one-shot privileged ubuntu container that bind-
mounts host kernel paths + the repo and runs deploy/install-driver.sh);
mam-api adds admin-gated /cluster/:id/install-driver + /driver-status.
Driver files live in-repo under sdk/<vendor>/ (private repo); binaries are
admin-supplied per each sdk/<vendor>/README.md. Vendor allowlist throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
onboard-node.sh auto-detects GPU (nvidia-smi/lspci) and SDI capture cards
(blackmagic/deltacast) and computes PROFILES (worker [+gpu] [+capture])
automatically; explicit NODE_ROLE/PROFILES still override. Add Node wizard
drops the role picker — node self-configures from hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
- 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>
- 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>
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>
Fetches /playout/channels separately and degrades silently when the
endpoint or schema is absent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
screens-playout.jsx + styles-playout.css: program monitor (HLS preview from
the sidecar), media bin, drag-drop playlist editor, transport controls. Plain
HTML5 drag-drop, no extra library. Talks to /api/v1/playout via
ZAMPP_API.fetch.
Wired into the shell: "Playout" under Operations, breadcrumb mapping, route
case in app.jsx, stylesheet + dist/screens-playout.js script in index.html.
Format dropdown defaults to 1080p5994 (matches the new channel default).
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The HLS-VOD work made GET /assets/:id/stream return the HLS playlist URL as
`url` whenever hls_s3_key was set. The Premiere plugin's "Import Proxy"
downloads `url` to a file and imports it — so it was saving an .m3u8 playlist
as .mp4, and Premiere rejected it ("unsupported compression type"). This hit
every YouTube asset (all get HLS generated), regardless of codec.
/stream now returns the directly-downloadable MP4 proxy as `url` (type mp4)
and the HLS playlist as a separate `hls_url`. The web player prefers `hls_url`
(so in-browser HLS playback is unchanged), while the already-installed plugin
gets a real MP4 again — no plugin reinstall needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The redesigned UXP panel (left icon rail, compact list-view toggle, hover
tooltips, single Export menu) was committed only to redesign/panel-icon-rail
and never merged, so main + the website kept serving the old blocky-button
build under the same version number (2.2.2). That branch had diverged off an
old main and is missing recent worker/HLS/NVENC/import work, so it can't be
merged wholesale — cherry-pick just the plugin instead.
- services/premiere-plugin-uxp: replace source with the redesigned panel
(adds src/tooltip.js; reworks index.html + styles.css + src/*). Verified
byte-identical to the build installed on BMG-PC-Edit.
- web-ui/public/downloads/dragonflight-mam-2.2.2.ccx: swap the served
artifact to the redesigned 34708-byte build (download link unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Reorder the Workspace nav group so Projects sits above Library.
- Add a lucide-style `dollar` icon and use it for the Billing nav item
(was borrowing the `token` key icon).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The import queue row's progress bar read only `r.progress`, which pollRow
never updated numerically — it tracked asset.status (ingesting/processing/
ready) but not a percentage, so the bar sat at 0 until the asset flipped to
ready, then snapped to 100 ("blank until it finishes").
pollRow now also fetches GET /jobs/<jobId> and feeds the BullMQ job's numeric
progress (worker emits 2..100 across the yt-dlp download + S3 upload) into the
bar, so it fills during the download. Falls back to status when the job is
evicted post-completion. Also reaffirm 'downloading' label while ingesting and
poll a bit faster (2s) since short clips finish quickly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stuck-live fix: capture sidecar now finalises the pre-created live asset by id (new POST /assets/:id/finalize) instead of POSTing a new asset (409 collision); node-agent gives the sidecar a 180s stop grace so the S3 upload + callback complete; node-agent logs sidecar start/stop for diagnostics.
Live SDI monitor: HLS preview is now a 2nd output of the hires ffmpeg (single DeckLink read, split to ProRes/S3 + H.264/HLS); node-agent serves /live over HTTP; mam-api proxies GET /recorders/:id/live/* to the recorder node; web-ui HlsPreview loads from the proxied URL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- recorders.js: when isRemote=true, replace MAM_API_URL in sidecar env with
http://<NODE_IP>:<PORT_MAM_API> so capture containers on worker host network
can reach mam-api (fixes assets stuck in live status after recorder stop)
- cluster.js: add GET /api/v1/cluster/metrics endpoint returning per-node
cpu/ram/gpu utilization; update heartbeat handler to persist metrics JSONB
- web-ui: add Resources panel to dashboard with live CPU/RAM/GPU bars per node,
polling /api/v1/cluster/metrics every 5s