Commit graph

770 commits

Author SHA1 Message Date
f21bc490e8 feat(web-ui): redesigned Dashboard + playout as-run log
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>
2026-05-31 17:15:32 -04:00
7451d7c703 fix(playout): lighter 360p/20fps preview encode so the CPU re-mux keeps up
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>
2026-05-31 16:15:16 -04:00
d3ad2397fb fix(playout): serve preview m3u8 via /api to bypass the proxy's static cache
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>
2026-05-31 15:57:41 -04:00
24d10fda5d fix(playout/web-ui): no-store on live HLS m3u8 so hls.js sees the live edge
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>
2026-05-31 15:45:28 -04:00
59551f28a5 fix(playout): re-encode HLS preview to uniform 2s segments so hls.js syncs
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>
2026-05-31 15:22:21 -04:00
e0e0b83810 fix(assets): live-path no longer gates on removed global growing_enabled
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>
2026-05-31 15:05:06 -04:00
5968d4f681 feat(settings/growing): storage warning, SMB auth + CIFS mount, per-recorder growing
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>
2026-05-31 14:50:36 -04:00
3b2f9fe6a0 fix(playout): strip comments from casparcg.config (parser aborts on them)
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>
2026-05-31 14:32:08 -04:00
869ae1aa83 fix(playout): use static ffmpeg, not apt, to avoid CasparCG SIGABRT
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>
2026-05-31 14:18:56 -04:00
abfbd034ab fix(playout): point CasparCG log/data paths at writable /media volume
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>
2026-05-31 14:11:00 -04:00
739d08d4b5 fix(web-ui): restore home screen icon fixes on feat/playout-mcr
playout tile: monitor -> signal, dashboard tile: home -> layout.
All feature-branch content (Dragon-ISO, tagline, etc.) preserved.
2026-05-31 13:56:19 -04:00
27a868aa5c fix(playout): clean video-only HLS preview via standalone ffmpeg re-mux
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>
2026-05-31 13:55:18 -04:00
1db0e81efb fix(web-ui): restore nav icon fixes on feat/playout-mcr
youtube: import, schedule: clock, playout: signal.
Keeps getting wiped by other agents writing full file replacements.
2026-05-31 13:54:16 -04:00
40e987b4a2 fix(web-ui): restore icon audit fixes on feat/playout-mcr
jobs: bulleted list (not hamburger), import: added, grid: rx=1,
hdd: cylinder, proxy: sliders. Keeps getting wiped by other agents.
2026-05-31 13:52:37 -04:00
426273129d fix(playout): video-only HLS preview (broken audio time_base was the black-screen cause)
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>
2026-05-31 13:44:45 -04:00
87d988810f fix(playout): use CFR rate + frame GOP for uniform HLS segments
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>
2026-05-31 13:38:21 -04:00
f28799317d fix(playout): clean CFR HLS preview so hls.js can sync
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>
2026-05-31 13:35:45 -04:00
d778aa4cdb fix(playout): HLS preview path + live elapsed counter
- 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>
2026-05-31 13:16:57 -04:00
00b04aa4a8 fix(playout): non-fatal consumer + loadPlaylist guard
- 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>
2026-05-31 13:01:21 -04:00
e8f91cf4b4 fix(playout): immediate failover on new channels + play 502 vs 409
- 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>
2026-05-31 12:34:41 -04:00
e51cf1aa9c feat(jobs): surface playout-stage queue in Jobs screen
- 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>
2026-05-31 12:06:08 -04:00
f7cf56ae0d fix(playout): silent-audio staging crash, home tiles, channel delete
- 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>
2026-05-31 12:03:20 -04:00
12115a053a feat(playout): fix 409 drag bug, add HLS preview, advanced playlist
- 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>
2026-05-31 11:45:25 -04:00
Zac
ca71e47035 fix(playout): repair failover, authenticate scheduler self-calls, fix playlist walk + CasparCG consumer syntax
Post-review fixes for the 8-commit playout-mcr drop:

- Scheduler self-calls (callSelf -> /recorders, /playout) carried no auth, so
  under AUTH_ENABLED=true requireUiHeader 403'd every mutating POST. This broke
  playout failover AND scheduled recordings. Add a per-boot in-process service
  token (x-internal-token) the scheduler attaches; requireAuth/requireUiHeader
  treat it as the seeded admin. No env/compose config needed.

- Failover deadlocked: restartChannel set status='starting' then the scheduler
  called the guarded /start route, which 409s on 'starting'. Extract the spawn
  body into spawnChannelSidecar() shared by /start and restartChannel; failover
  now spawns directly with no self-call.

- Phase A playlist stalled after 2 clips: _scheduleAdvance cued the next clip
  via LOADBG AUTO but never advanced the pointer. Pass asset_duration_ms in the
  /play payload and arm a duration-based timer that advances currentIndex and
  cues subsequent clips, keeping as-run in sync for arbitrary-length playlists.

- CasparCG consumer syntax was invalid: "ADD <ch> FFMPEG" is the producer name,
  not a consumer keyword, and old -vcodec/-acodec short args are rejected. Use
  STREAM/FILE with -codec:v / -codec:a / -preset:v / -tune:v and a format=yuv420p
  filter ahead of libx264 (channel output is RGBA).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:51:35 +00:00
Zac
793011b78b feat(web-ui): MCR page — channels, playlist, transport, preview
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).
2026-05-30 14:02:25 +00:00
Zac
5538683d78 feat(mam-api): /playout control plane + auto-failover
Routes: channel + playlist CRUD, start/stop/play/pause/skip transport, as-run
log. RBAC via assertProjectAccess on channel.project_id; null project ⇒
admin-only (recorder convention).

Sidecar orchestration mirrors recorders.js: Docker socket for local node,
node-agent /sidecar/start for remote. Channel start passes CHANNEL_ID env so
the sidecar can write HLS preview to /media/live/<id>.

DeckLink port-contention guard: blocks starting a decklink channel when a
recorder or another channel on the same node+device_index is active.

restartChannel(id) helper picks another healthy cluster node and re-places
non-decklink channels; decklink is alert-only. Exposed for the scheduler.

Scheduler tick adds step 6: poll each running channel's sidecar /status,
update last_heartbeat_at, and after ~3 misses trigger restartChannel +
self-call /start. Reuses the existing PG advisory lock so multi-replica
deploys don't double-fire failovers.
2026-05-30 14:02:25 +00:00
Zac
d62af34e98 feat(playout): CasparCG sidecar image + Node AMCP shim
One container per channel. Built like capture/build-with-decklink: NDI +
DeckLink SDKs fetched at build, runs --privileged with Xvfb for the GL
context where no real display is present.

Components:
- entrypoint.sh: Xvfb + CasparCG launch, creates /media/live/<CHANNEL_ID>
- src/amcp.js: TCP AMCP client
- src/playout-manager.js: channel lifecycle, playlist walk via LOADBG AUTO
  for gapless transitions; primary consumer (decklink/ndi/srt/rtmp) plus a
  second FFMPEG HLS consumer (~600 kbps, 2s segments) for the UI preview
- src/index.js: HTTP shim — /channel/start, /playlist/load, transport
- frame-rate helper picks fps from video_format (59.94 → 60000/1001) so
  SEEK / LENGTH frame math is correct
2026-05-30 14:02:25 +00:00
Zac
209f9fda52 feat(worker): playout-stage job — S3 → /media + EBU R128 loudnorm
Stages playlist items from S3 to the shared CasparCG media volume. Pass 1
measures, pass 2 applies linear loudnorm (I=-23 LUFS, TP=-1 dBTP, LRA=11);
output is AAC 192k @ 48 kHz, video stream copied. Atomic rename on success
so CasparCG never sees a partial file. Per-item audio_normalized flag means
re-stages of the same asset skip the loudnorm pass.

Wired into worker/src/index.js behind WORKER_QUEUES=playout-stage so
capability-routed deploys can pin it to nodes that already have ffmpeg +
the media mount.
2026-05-30 14:02:25 +00:00
Zac
29187a90df feat(mam-api): migration 029 — playout schema
Six tables: channels, playlists, items, sidecars (sidecar registry for
health-check), schedule (Phase B), as-run log.

- video_format default 1080p5994 (house standard, capture cadence)
- restart_count / last_restart_at / last_heartbeat_at on channels for
  auto-failover bookkeeping
- audio_normalized flag on items so re-stages skip the loudnorm pass
- unique partial index on (channel_id) for running sidecars
2026-05-30 14:02:25 +00:00
Zac
72fc608d8a fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.

1. TOTP brute-force gate (the big one). /login was calling
   ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
   *before* the second factor was proven. That cleared the per-IP failure
   counter, so each /login retry let an attacker with a known password
   hammer the 6-digit /login/totp space (10^6) at full speed.
   Now recordSuccess fires only inside establishSession() — i.e. after
   every required factor has actually passed (password [+TOTP] or
   OAuth [+TOTP]).

2. MFA ticket binding. Tickets issued by /login (and the Google callback)
   were unbound — a stolen ticket replayed from a different origin still
   worked. Tickets now carry SHA-256 hashes of the issuing request's IP
   and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
   even on mismatch so a wrong-binding probe can't be retried.

3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
   accepted the same code as many times as you submitted it. Now
   verifyToken returns the matched counter, and /login/totp does a CAS
   UPDATE on users.totp_last_counter — codes at counters <= the last
   accepted value are rejected. New migration 030 adds totp_last_counter,
   seeded on /totp/enable so the enrollment code itself can't be reused
   at first login, and zeroed on /totp/disable.

4. Google OAuth domain check no longer falls back to the email suffix
   when the hd (hosted-domain) claim is missing. Email-suffix matching
   let consumer (non-Workspace) Google accounts whose email happens to
   end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
   the operator means "only this Workspace", so accounts without a
   verified hd must be rejected.

Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:52:53 +00:00
Zac
3fe7d6bba2 fix(mam-api): close cross-project authz gaps in assets/bins/jobs/upload
Review of the v2 auth landing found four places where the per-project RBAC
helpers weren't applied to destination/source projects, letting a scoped
editor write into projects they don't have access to:

- assets PATCH /🆔 bin_id moved with no check, so an editor in project A
  could stuff their asset into a bin in project B. Now validates the bin's
  project_id matches the asset's own project (assets don't change project).
- assets POST /:id/copy: body's projectId/binId never checked, so any
  reachable asset could be cloned into an arbitrary project. Now asserts
  edit on the destination project and validates binId belongs there.
- bins POST /:id/assets: requireBinEdit checks edit on the bin's project but
  not on the source asset's project, so an asset from project B could be
  pulled into A's bin tree (and surfaced in A's views). Now the asset must
  belong to the bin's own project.
- jobs POST /conform: project_id from body never gated, so any logged-in
  user could enqueue conform jobs against any project. Now asserts edit.
- upload POST /init, POST /simple: projectId/binId from body never gated,
  same class of bug. Now asserts edit on projectId and validates binId.
- upload GET /: returned every in-progress upload globally, leaking
  filenames across projects. Now scoped via accessibleProjectIds.

These are the same pattern as the holes 2615143 closed on recorders/
sequences/imports/comments — these routes existed before the RBAC commit
landed and were never marked TODO(authz), so the broad sweep missed them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:52:29 +00:00
Zac
2615143c6d feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.

- recorders: param('id') resolves project + view baseline, requireRecorderEdit
  on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
  asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
  and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
  to write); also fixes the author bug (req.session.userId -> req.user.id, which
  was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
  registration) — TODO replaced with a rationale note

Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
  asset into a project the caller lacks edit on — now asserts edit on the
  override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
  could pull in (and via GET /:id leak signed proxy URLs for) assets from a
  project they can't access — now every clip asset must belong to the
  sequence's project; pre-transaction queries moved inside try/catch so a DB
  error returns 500 instead of hanging the request

- tests: recorders-access, sequences-access (incl. cross-project clip guard),
  comments-access (incl. author-id regression)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 03:48:02 +00:00
Zac
0c3a4b625f feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
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>
2026-05-30 02:51:59 +00:00
Zac
fff0828d79 feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.

- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
  recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
  login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
  /totp/disable (password-confirmed); login returns {mfa_required, ticket}
  when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
  manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:42:57 +00:00
Zac
ec026195eb feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.

- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
  assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
  view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
  "Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
  service-token-needs-admin/grants requirement

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:37:36 +00:00
9d6bbf8112 fix(mam-api): /stream returns MP4 url + separate hls_url (fixes Premiere import)
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>
2026-05-29 21:44:52 -04:00
b449ef0ce3 fix(worker): YouTube importer prefers H.264 so originals import in Premiere
YouTube now packs AV1 inside .mp4, so the old `bv*[ext=mp4]` format selector
grabbed AV1 for the downloaded ORIGINAL. Premiere rejects AV1 on hi-res import
("unsupported file type"). The h264 proxy was fine, but the original wasn't.

Prefer avc1 (H.264) so the original is Premiere-native, falling back to the
previous any-mp4 behaviour only when no H.264 rendition exists. avc1 tops out
at 1080p on YouTube (above that is AV1/VP9 only), which is the universally
importable ceiling anyway.

Verified on a real URL: old selector -> av01 1080p; new selector -> avc1 1080p
(same resolution, now Premiere-native).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:21:44 -04:00
39ef551489 feat(uxp): ship the icon-rail panel redesign as v2.2.2 (recover from redesign branch)
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>
2026-05-29 20:45:29 -04:00
8f26f1bd9a ui(web-ui): Projects above Library in nav + $ icon for Billing
- 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>
2026-05-29 20:17:42 -04:00
a7ef0397e1 fix(web-ui): show live download % on YouTube import bar
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>
2026-05-29 19:53:19 -04:00
cf1fe136d0 fix(worker): route import queue to a consumer + refresh stale yt-dlp
YouTube imports were stuck in `ingesting` forever: after the capability-
routing split (fdec2e3), every worker's WORKER_QUEUES enumerated an explicit
list that omitted `import`, so index.js's `want('import')` was false on every
worker and nothing consumed the import queue. Add `import` to worker-p4
(heavy/primary worker; import is concurrency-1 and network-bound, one consumer
is enough).

Second defect that would have surfaced once routed: the baked yt-dlp came from
the distro package (Ubuntu 22.04 apt = 2022.04.08, ~4 years stale), which
current YouTube rejects. Pull the latest self-contained yt-dlp release binary
at image build instead (GPU image: yt-dlp_linux; alpine image: python zipapp).
Rebuild the worker image to refresh.

Verified on zampp1: import jobs drain ingesting -> processing -> ready,
failed=0, yt-dlp 2026.03.17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:51:27 -04:00
0818f15498 fix(s3): land NodeHttpHandler request/connection timeout in main
The s3 client request-timeout fix (the original browser playback-hang fix)
was applied directly on zampp1 but never committed to main. Without it a
stalled RustFS GET hangs /video and /hls indefinitely. Landing it so a clean
deploy from main no longer regresses playback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:26:59 -04:00
4473427515 Merge remote-tracking branch 'wilddragon/feat/recorder-codec-bitrate' into integrate 2026-05-29 17:25:28 -04:00
9b47250388 feat(recorder): default All-Intra HEVC (NVENC) + custom bitrate, auto fps/res, source-bitrate warning
#2 Recorder codec/bitrate:
- Default recorder codec → hevc_nvenc (All-Intra HEVC NVENC); ProRes/H.264/DNxHR
  still selectable. recorders.js default flips prores_hq → hevc_nvenc.
- Custom target bitrate (Mbps) input, shown only for bitrate-controlled codecs
  (NVENC/x264/x265/DNxHD); ProRes shows quality-based (no bitrate).
- Framerate + resolution are auto-detected from source (manual fields removed).
- Container derived from codec (HEVC/ProRes/DNxHR → fragmented MOV, H.264 → MP4);
  drops the stub container picker (closes #150 direction).

#3 SRT/RTMP customization + bitrate warning:
- Same codec/bitrate/auto controls apply to network recorders (shared form).
- Warns in the modal when the configured target bitrate exceeds the probed
  source stream bitrate (via /recorders/probe) — re-encoding above source adds
  storage, not quality.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:04:00 -04:00
8ea750f5df feat(playback): HLS VOD rendition for browser (supplements MP4 proxy)
Browser playback of recorded assets moves to HLS, retiring the MP4
range-stitching path for VOD. MP4 proxy is kept for the Premiere panel.

- worker/hls.js: remuxToHls() stream-copies the proxy MP4 → fMP4 HLS
  (playlist.m3u8 + init.mp4 + segment_*.m4s) via existing segmentToHls,
  uploads to hls/<id>/, sets assets.hls_s3_key. hlsWorker backfills from
  an existing proxy.
- proxy.js: generate HLS inline after the MP4 upload (local file, no
  re-download, no re-encode); best-effort/non-fatal.
- worker/index.js: register 'hls' worker wherever 'proxy' runs.
- mam-api: GET /assets/:id/hls/:file serves playlist/init/segments as
  whole-object GETs (no Range → sidesteps RustFS bug), strict filename
  validation. /stream prefers hls_s3_key (type:'hls'). reprocess?type=hls
  backfills. Migration 025 adds assets.hls_s3_key.
- Frontend unchanged: hls.js path already handles type:'hls'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:18:15 -04:00
35fd9c0253 feat(web-ui): serve UXP (.ccx) plugin, remove legacy ZXP panel
Replaces the ZXP/CEP Premiere panel downloads with the UXP plugin:
- data.jsx PREMIERE_RELEASES → single UXP 2.2.2 entry (ccx pointer)
- home-page Premiere modal + Settings→Capture SDKs render the .ccx
  download and UXP install copy (UXP Developer Tool / Creative Cloud)
- add downloads/dragonflight-mam-2.2.2.ccx (built from premiere-plugin-uxp)
- remove the 1.0.0/1.0.1/1.1.0/1.2.0 .zxp + windows-setup.exe artifacts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:20:46 -04:00
0ee0cb91ef build(capture): nvenc-enabled ffmpeg Dockerfile (validated build)
Brings the node-local NVENC ffmpeg build into the branch and fixes it:
- multi-stage build with nv-codec-headers + --enable-nvenc/--enable-cuvid
- removes --pkg-config-flags="--static", which broke x265 detection
  ("ERROR: x265 not found using pkg-config") and prevented the image from
  ever building. Shared linking is used; runtime stage already apt-installs
  the shared codec libs (libx265-199 etc).
- self-validating: build fails if nvenc encoders are absent.

Rebuilt image confirmed to expose hevc_nvenc/h264_nvenc/av1_nvenc on the L4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:33:37 -04:00
9210b41589 fix(capture): working All-Intra HEVC NVENC config (validated on L4)
NVENC rejects -g 1 ("GopLength > numBFrames+1" at InitializeEncoder), so
true all-intra is achieved with -bf 0 -forced-idr 1 -g 600 plus
-force_key_frames expr:1 (forces an IDR on every frame). ffprobe confirms
all frames are pict_type=I. Container is fragmented MOV; MXF muxing of HEVC
fails on this ffmpeg build ("Operation not permitted").

Validated end-to-end via direct ffmpeg on zampp2/L4:
hevc, Main 10, 1920x1080, yuv420p10le, ptypes=[IIIIII...]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:23:44 -04:00
f2542bc929 feat(nvenc): GPU sidecar passthrough + All-Intra HEVC capture codec
Phase 0.2 of the NVENC All-Intra HEVC ingest plan.

node-agent/handleSidecarStart:
- Accept useGpu: true in the sidecar start body
- When useGpu: adds Runtime=nvidia, DeviceRequests=[gpu], and injects
  NVIDIA_VISIBLE_DEVICES=all + NVIDIA_DRIVER_CAPABILITIES=video,compute,utility
  into the container env. CPU-codec recorders are unaffected (useGpu defaults false).

mam-api/recorders (start endpoint):
- Derive useGpu from recorder.recording_codec — true for hevc_nvenc/h264_nvenc
- Pass useGpu to remote sidecar start body
- Apply same Runtime/DeviceRequests to the local Docker spawn path

capture/capture-manager:
- Update hevc_nvenc codec entry with all-intra flags:
  -g 1 -bf 0 (every frame IDR, no B-frames — required for growing-file
  edit-while-record), -rc vbr, -profile:v main10, pixFmt p010le (10-bit 4:2:0)

Next: validation gate (§8) — test MXF OP1a then fragmented MOV on one
DeckLink channel, mount in Premiere while recording.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:35:23 -04:00
fdec2e307d feat(worker): capability-routed GPU worker pool + per-node job attribution
WORKER_QUEUES env lets a worker subscribe to a subset of queues. Deploy one GPU-pinned container per card: heavy encodes (proxy/conform/trim) on Tesla P4 (zampp1) + L4 (zampp2) via NVENC; light jobs (thumbnail/filmstrip) on the 2x Quadro P400 (zampp1). BullMQ competing-consumers distribute across nodes. RUN_PROMOTION gates the growing-files scanner to one worker. Each worker stamps WORKER_LABEL onto job data so the Jobs UI Node column shows which node/GPU ran each job. Redis/DB/S3 for the zampp2 worker come from its .env (pointed at zampp1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 04:00:10 +00:00