Compare commits

...

683 commits

Author SHA1 Message Date
8bc460025d screens-home: fix launcher tile icons
- Dashboard tile: home → layout (matches sidebar nav icon)
- Playout tile: monitor → signal (matches sidebar nav fix)
2026-05-31 00:19:34 -04:00
3578c7b4e9 fix(playout): Privileged only for decklink (SRT/NDI/RTMP/HLS crashed when GPU exposed without driver) 2026-05-30 18:59:27 -04:00
cddcc9a29e fix(mam-api): selfHeartbeat writes last_seen_at so primary node isn't stale-failover-killed 2026-05-30 18:57:20 -04:00
0e844c0fc3 fix(scheduler): use updated_at as grace anchor when last_heartbeat_at NULL
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.
2026-05-30 17:32:15 -04:00
551af09dc7 fix(playout): install libnss3 so CEF can init (NSS -8023 was killing the channel ~30s in) 2026-05-30 17:16:54 -04:00
4d6a999665 fix(playout): pre-create NSS dir + CEF cache so CEF/HTML producer doesn't SIGABRT 2026-05-30 17:14:07 -04:00
f971d57bb9 fix(playout): use unzip not python zipfile (preserves exec bits) 2026-05-30 17:00:25 -04:00
7ab70948a0 fix(playout): entrypoint handles 2.4.x bin/casparcg layout + LD_LIBRARY_PATH for bundled libs 2026-05-30 16:50:04 -04:00
13bbd4216e fix(playout): correct 2.4.0 zip layout — binary is at casparcg_server/bin/casparcg 2026-05-30 16:49:48 -04:00
fcd8e8dd2e fix(playout): entrypoint finds binary in /opt/casparcg for 2.4.x tarball layout 2026-05-30 16:44:23 -04:00
67ac007706 fix(playout): downgrade CasparCG to 2.4.0 ubuntu22 zip (2.5 requires AVX2, ZAMPP has AVX only) 2026-05-30 16:44:07 -04:00
b4f2fb12ff fix(mam-api): heartbeat writes last_seen_at so playout failover sees healthy nodes 2026-05-30 16:32:11 -04:00
aa7f836493 fix(playout): strip XML comments from casparcg.config (2.5 rejects them) 2026-05-30 16:30:54 -04:00
c2409bd037 fix(mam-api): add last_seen_at to cluster_nodes for playout failover
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>
2026-05-30 13:39:06 -04:00
42064acefa shell: fix nav icon conflicts
- schedule: jobs → clock (was sharing hamburger icon with Jobs)
- playout: monitor → signal (was sharing TV icon with Monitors)
2026-05-30 13:30:42 -04:00
2e2b091653 icons: fix 4 icon issues found in audit
- jobs: replace hamburger (nav menu) with bulleted list (task queue)
- grid: add rx="1" to match library icon (consistency)
- hdd: replace circle+dot (vinyl) with cylinder (storage)
- proxy: replace upload-arrow with sliders (transcode/transform)
2026-05-30 13:29:18 -04:00
Zac
c502d4a16f feat(web-ui): update home tagline + add "Let's create" motto
Tagline "Self-hosted broadcast media-asset management" ->
"Media Asset Management & Production Platform"; add italic accent motto
"Let's create" below it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:04:28 +00:00
Zac
9d098e9778 feat(auth-ui): interactive permissions matrix, admin 2FA reset, Downloads button
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>
2026-05-30 15:59:27 +00:00
Zac
02631f7b96 fix(playout): locate CasparCG 2.5 binary at /usr/bin/casparcg-server-2.5
First 2.5 build got past the deb install but the binary-discovery step
produced an empty $BIN (test -n failed): the 2.5 deb names its executable
casparcg-server-2.5, which the old case pattern (*/casparcg, */CasparCG
Server) didn't match. Broaden the match to /usr/bin/*casparcg*server*, fall
back to the known /usr/bin/casparcg-server-2.5, symlink it to
/usr/local/bin/casparcg, and make /opt/casparcg a real dir for our config
(no longer symlinked onto /usr/bin). Entrypoint launches `casparcg <config>`
from PATH instead of ./casparcg in a cwd.

Still NOT runtime-validated: 2.5 may reject the 2.3-era casparcg.config
schema (a bad config shows up as "Configuration file --version was not
found"); the deb ships a reference config at
/usr/share/casparcg-server-2.5/casparcg.config to diff against at smoke time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:34:02 +00:00
Zac
9436434599 fix(playout): build CasparCG 2.5.0 from .deb (2.3.3 tarball was a dead URL)
The image never built: CASPAR_URL pointed at a v2.3.3-stable Linux tarball
that CasparCG never published (2.3.x is Windows-only; Linux builds start at
2.4.0, and 2.4.1+ ship only as .deb). Rewrite to install the 2.5.0 noble
server + CEF debs on an ubuntu:24.04 base (Node 20 via nodesource), letting
apt resolve the GL/ffmpeg/openal runtime deps. Binary install dir is
discovered from the deb file list and symlinked to /opt/casparcg so the
entrypoint + config still run from there. Move CasparCG log/data dirs to
/media (writable mount) since the install dir may be read-only.

NOT runtime-validated: the 2.5 casparcg.config schema and the AMCP consumer
syntax (ADD <ch> STREAM/FILE) were authored against 2.3 and must be smoke-
tested against 2.5 before a channel start can be trusted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:31 +00:00
Zac
f837e57969 feat(web-ui): add Playout tile to home screen
Fetches /playout/channels separately and degrades silently when the
endpoint or schema is absent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:59:59 +00: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
34352e3299 docs(playout): work log — commit map, decisions, testing checklist
Replaces the earlier aspirational "complete" log with the actual commit
sequence on feat/playout-mcr, the §7 decisions as built, the media-flow
diagram, port-contention + failover scope, and a runtime testing checklist
(migration → image build → SRT smoke → failover kill test).
2026-05-30 14:05:57 +00:00
Zac
d505a488ac build(playout): compose wiring + .env knobs
- Add /mnt/NVME/MAM/wild-dragon-media:/media to mam-api (rw) and worker-p4
  (rw); web-ui (ro, for serving HLS preview segments).
- worker-p4 WORKER_QUEUES gains 'playout-stage' so master-tier nodes pick up
  the loudnorm stage jobs (they already have ffmpeg + the media mount).
- New build-only 'playout' service with profile ["build-only"] so
  `docker compose --profile build-only build playout` produces the
  wild-dragon-playout:latest image without compose trying to up it as a
  long-running service. mam-api spawns these on demand.
- mam-api env adds PLAYOUT_IMAGE + PLAYOUT_AMCP_BASE_PORT (5250 default).
- .env.example: PLAYOUT_IMAGE, PLAYOUT_AMCP_BASE_PORT.
2026-05-30 14:05:57 +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
512267159a docs(playout): MCR design spec — Phase A playlist + Phase B 24/7
Single-doc design covering the playout subsystem: CasparCG-backed sidecars,
multi-channel placement, S3→/media staging, scheduling phases, the data
model, channel placement vs port contention.

§7 questions are answered inline (2026-05-30): −23 LUFS at stage time,
1080p5994 default, HLS preview v1, auto-restart-on-healthy-node failover
(DeckLink alert-only).
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
a28dc43ed5 docs: HLS VOD playback design (supplement MP4 proxy) 2026-05-29 16:13:29 -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
0f6c715a30 docs: All-Intra HEVC (NVENC) growing-file ingest design
Captures the current working system (capture sidecar, finalize flow, live monitor, capability-routed GPU worker pool, deploy gotchas) and the target design: GPU All-Intra HEVC master to offload the ProRes CPU wall while keeping edit-while-record, scaling to 8 signals + multi-vendor (Blackmagic/Deltacast/AJA). Includes a validation gate (prove Premiere growing-HEVC edit on one channel first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 04:16:17 +00: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
92b460f503 fix(recorder): finalise live asset on stop + add live SDI monitor
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>
2026-05-29 03:20:20 +00:00
500599a955 fix: pass CAPTURE_TOKEN env var to mam-api container 2026-05-28 22:13:36 -04:00
634f1842bd fix: add Bearer auth to capture sidecar callback and pass CAPTURE_TOKEN
- capture/src/index.js: read MAM_API_TOKEN from env; include
  Authorization: Bearer header in shutdown callback fetches to mam-api
  (POST /assets and POST /assets/:id/mark-empty). Without this, mam-api
  AUTH_ENABLED=true rejects the callback with 401, leaving assets stuck in live
- recorders.js: pass MAM_API_TOKEN=${CAPTURE_TOKEN} in sidecar env so the
  capture container receives the token at boot
- api_tokens: inserted capture-sidecar token (unbound, prefix b3d3d3c4)
2026-05-29 01:57:39 +00:00
453103aee6 fix: use external MAM_API_URL for remote capture sidecars; add cluster metrics endpoint and dashboard resource graphs
- 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
2026-05-29 01:04:24 +00:00
6f64b55824 feat(ui): add 'Cancel all failed' button to Jobs screen
Pair with the existing 'Retry all failed'. Drops every failed job from
the queue at once. Single confirm prompt. Optimistic local update so the
list clears instantly instead of waiting for the 5s poll tick.

.jobs-cancel-all CSS tinted danger-red without being a loud .btn danger,
matching the per-row Cancel pattern.
2026-05-29 00:02:55 +00:00
303f12e0f9 ui: add Billing admin nav, drop Editor nav, replace Editor tile with Premiere panel download modal
- shell.jsx: add Billing item under Admin (routes to the parody pricing
  page); drop Editor from the Operations section
- app.jsx: route 'billing' to TokensParody; remove 'editor' and
  'tokens-parody' routes
- screens-admin.jsx: rename parody h1 from 'Tokens' to 'Billing'; drop
  the cross-link from the real Tokens page (no longer needed)
- screens-home.jsx: replace the Editor launcher tile with a 'Premiere
  panel' tile that opens a new PremiereDownloadModal listing every
  registered release (ZXP + Windows installer) with version + LATEST
  badge + release date + notes
- styles-screens.css: .premiere-release-* row styles for the modal

Editor screen + nav button retired; users get the Premiere panel as the
recommended editor instead, with all download options in one place from
Home.
2026-05-29 00:01:19 +00:00
342b56af35 ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.

## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)

## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
  styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)

## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
  width on progress bar). Live DOM was 487 inline-styled elements due
  to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
  job-row-actions, job-row-status-* utility classes in styles-screens.css

## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
  Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
  not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor

## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
  beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)

## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing

## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
  live recorders, 'Last 24 hours' tiles for newly created assets, plus
  an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen

## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title

## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
  badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 23:50:07 +00:00
f54c49d2dc fix(web-ui): fix duplicate DeckLink groups in new-recorder modal; refactor device pickers
The /cluster/devices/blackmagic endpoint returns one entry per port (flat
array). The old SDI picker iterated over each entry and synthesised
port_count buttons per entry — 4 entries × 4 synthesised ports = 16 buttons
rendered as 4 identical duplicate groups.

Fix: extract DevicePortPicker component that groups the flat per-port
response by node_id (Map keyed on node_id, one group per physical node,
ports sorted by index). One button rendered per actual API entry.

Also extract ManualDevicePicker for the fallback empty-state dropdowns.
Both components shared between SDI and Deltacast pickers.

Visual improvements:
- Port label shows device node (io0, io1…) from device path instead of
  redundant index number
- Node header only shows model+hostname, not repeated per port
- TEST CARD badge styled inline for Deltacast test-card ports
2026-05-28 23:18:55 +00:00
888ca65045 feat(capture): Deltacast SDI framework — test-card capture, cluster detection, UI
## capture service
- capture-manager.js: add 'deltacast' source_type to _buildInputArgs.
  Uses 'deltacast://<index>' with ffmpeg deltacast demuxer when
  /dev/deltacast<N> exists; falls back to lavfi testsrc2 + sine test card
  (matching deltacast-sdi-recorder standalone app) when hardware absent.
- routes/capture.js: add GET /devices/deltacast endpoint (enumerates
  /dev/deltacast* + DELTACAST_PORT_COUNT env fallback). Extend /probe to
  handle source_type=deltacast.

## node-agent
- detectHardware(): add 'deltacast' array to capabilities payload.
  Enumerates /dev/deltacast* nodes; falls back to DELTACAST_PORT_COUNT env.
  Adds DELTACAST_MODEL env support. Logs dc= count in heartbeat line.
- sidecar /start: bind /dev/deltacast* device nodes into capture containers
  when sourceType='deltacast'.

## mam-api
- cluster.js: add GET /cluster/devices/deltacast and
  GET /cluster/devices/deltacast/signal endpoints — same shape as
  blackmagic equivalents for UI parity.
- recorders.js /start: pass DELTACAST_PORT_COUNT env to capture container;
  bind /dev/deltacast* device nodes on local spawn.
- migration 024: ALTER TYPE source_type ADD VALUE 'deltacast' (idempotent).
- schema.sql: add 'deltacast' to source_type ENUM for fresh installs.

## web-ui
- modal-new-recorder.jsx: add 'Deltacast' source type card; fetch
  /cluster/devices/deltacast on selection; port picker with TEST CARD
  badge when hardware absent; falls through to manual index entry if
  no devices detected.
2026-05-28 23:12:40 +00:00
b6f5b9b407 fix(capture): disable concurrent SDI proxy ffmpeg — DeckLink Duo 2 rejects second reader
DeckLink Duo 2 does not support two simultaneous ffmpeg processes on the
same port. The second (proxy) process immediately gets 'Cannot Autodetect
input stream or No signal', producing an empty upload that could crash the
container before the hires upload completes.

Fix: remove the parallel proxy spawn for SDI entirely. proxyKey is now
always null for SDI recordings (same as SRT/RTMP). needsProxy=true is
already set when proxyKey is null, so the BullMQ worker generates the
proxy from the hires master after stop — same pattern that works for
network sources.

Also revert bad regex change: ffmpeg -sources decklink output on this
hardware uses hex-address format ('81:76669a80:00000000 [DeckLink Duo (1)]')
not bare indented names — original regex was correct.
2026-05-28 22:36:06 +00:00
354731a363 fix(capture): fix DeckLink device name enumeration for SDI port 2+; add per-take project selector on Recorders page
- capture-manager.js, routes/capture.js: fix ffmpeg -sources decklink
  parse regex from v4l2 hex-address format (never matched DeckLink output)
  to correct indented-line format. Port 2+ (index 1+) was falling through
  to a wrong model-name fallback, causing ffmpeg to open the wrong input
  and produce black frames. Now logs the detected device list and the
  selected name at start.

- recorders.js (/start): accept per-take projectId override in request
  body. If provided, clips go to that project instead of the recorder's
  default project_id. Used for both the live-asset INSERT and the
  PROJECT_ID env var passed to the capture container.

- screens-ingest.jsx (RecorderRow): add project dropdown shown when
  recorder is stopped. Defaults to the recorder's configured project;
  operator can change it before hitting Record without editing the
  recorder config.
2026-05-28 22:26:08 +00:00
Claude
1fcb927d26 feat(web-ui): library Download button + dismissable size warning (#145)
Adds an inline hi-res download trigger to the asset library.

UI:
- Small 22×22 download icon button in the top-right corner of each
  asset thumbnail. Hidden by default, fades in on card hover or focus
  so the resting-state grid stays clean.
- Only renders for assets that have an `original_s3_key` — proxies
  and unfinished captures never offer it.
- Mirrored as a "Download original…" entry in the right-click
  AssetContextMenu (between Rename and the bin actions).

Flow:
- First click (or any click while the warning is enabled) opens
  DownloadWarningModal: terse copy explaining the file is the full
  original ingest, can be multi-GB, and that speed depends on the
  user's network connection. Footer: Cancel · Download. Body: a
  "Don't show this again on this device" checkbox.
- Ticking the checkbox persists `df.lib.download.warnDismissed=1`
  in localStorage. Subsequent clicks skip the modal and start the
  download straight away.

Download itself reuses /api/v1/assets/:id/hires (presigned S3 URL)
— no proxy round-trip through mam-api, no in-browser progress UI
beyond what the browser already shows.

Spec: #145
Settings → Account "re-enable the warning" toggle is not in this
patch and will land separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:14:24 -04:00
Claude
6bc6478270 feat(worker): conform — queue proxy build for the conformed output
ProRes / DNxHR conformed outputs are unplayable in the browser
(HTML5 video: MEDIA_ERR_SRC_NOT_SUPPORTED). The library was
referencing the ProRes original as the only source.

After the asset row is inserted, queue an H.264 proxy build the same
way services/mam-api/src/routes/assets.js does on ingest:
  proxyQueue.add('generate', {
    assetId,
    inputKey:  outputKey,         // the conformed mov / mp4
    outputKey: `proxies/${id}.mp4`,
  });

The proxy worker writes the H.264 mp4, updates assets.proxy_s3_key,
and from then on /assets/:id/stream prefers the proxy over the
original. The library player can decode it natively.

Failure to enqueue is logged but doesn't fail the conform job — the
asset still exists and can have a proxy re-queued later.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:49:01 -04:00
Claude
446a563647 fix(worker): conform — write ProRes/DNxHR to MOV, not MP4
The final concat-demux + encode step erred with:
  [mp4] Could not find tag for codec prores in stream #0,
        codec not currently supported in container
  [out#0/mp4] Could not write header (incorrect codec parameters ?)

ProRes and DNxHR live in QuickTime (.mov), not MP4. The output path,
S3 key, and asset-row filename were all hardcoded to .mp4.

Pick the container from the codec:
  prores / prores_hq / prores_4444 / dnxhr_hq → mov
  h264 / h265 / anything else                 → mp4

outputExt is computed once at the top of the worker (before tmpfile
creation) and reused for the temp output, the S3 key
(jobs/<id>/conformed.<ext>), and the assets row's filename column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:40:53 -04:00
Claude
71d8944a01 fix(worker): conform — use aformat for channel layout (ffmpeg 8 dropped aresample ocl)
ffmpeg 8.x removed the `ocl` shortcut option from aresample (it was a
deprecated alias for out_chlayout). The per-segment trim+normalise call
errored immediately:
  [fc#-1] Error applying option 'ocl' to filter 'aresample': Option not found

Split the chain: aresample handles the sample rate, aformat asserts +
auto-converts to stereo + fltp.

  aresample=48000,aformat=channel_layouts=stereo:sample_fmts=fltp

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:37:35 -04:00
Claude
686b90294b fix(worker): conform — 2-pass strategy (normalise on trim, demux on concat)
ffmpeg 8.x's concat filter kept dying with the opaque
  [fc#0] Error sending frames to consumers: Invalid argument
even after we locked fps + sample rate + pixel format + SAR in the
filter graph. Mixed sources (AV1+H.264, 23.98+60 fps, 44100+48000 Hz,
tv-range+unspecified-range pixel format) just don't survive the
concat filter cleanly in this build.

Switch to the more reliable 2-pass pattern:

1. At the trim step, re-encode each segment to a uniform intermediate
   spec: libx264 ultrafast, 1920x1080 (letterboxed), yuv420p,
   seqFps target rate, 48kHz stereo AAC. Per-segment ffmpeg.

2. At the concat step, use the concat *demuxer*. Because every input
   now matches exactly, the demuxer is well-behaved. Transcode the
   concatenated stream to the final target codec (ProRes 422 HQ etc).

Costs an extra intermediate encode (libx264 ultrafast ≈ realtime on
this hardware) but eliminates the filter-graph fragility on mixed-
source timelines, which is the workload that actually matters.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:52 -04:00
Claude
fcf4c8bbe7 fix(worker): conform — lock fps + sample rate in concat filter graph
After the demuxer → filter switch, concat still failed with
  [fc#0] Error sending frames to consumers: Invalid argument
on Job 8. The filter graph normalised pixels (scale+pad+yuv420p) but
left the time-domain axes mixed:

  segment-1: 23.98 fps video, 44100 Hz audio
  segment-2: 60    fps video, 48000 Hz audio
  segment-3: …

ffmpeg 8's concat filter requires identical frame rate + audio sample
rate + channel layout across inputs. Force them on each leg:

  video: fps=<seqFps>, setpts=PTS-STARTPTS
  audio: aresample=48000,
         aformat=channel_layouts=stereo:sample_fmts=fltp,
         asetpts=PTS-STARTPTS

setpts/asetpts re-zero each input's clock so concat's per-input PTS
window resets cleanly between segments.

Target fps comes from the sequence's frame_rate (rounded) — same axis
the sequence editor stores. Sample rate is pinned to 48000 (broadcast
standard) so the AAC encode is consistent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:21:23 -04:00
Claude
94b6710e2d fix(worker): conform — concat-filter for mixed source formats
ffmpeg concat demuxer dies with "Error sending frames to consumers:
Invalid argument" when input segments don't share codec / pixel format
/ framerate / resolution. Mixed-source timelines hit this every time —
e.g. an AV1 clip + an H.264 clip going through the same concat.

Switch to the concat *filter*. It re-encodes through a filter graph
so disparate inputs are normalised inline. Each input is scaled to
1920x1080 with letterbox, format=yuv420p, audio resampled. concat=n=N
joins them into [outv]/[outa].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:04:55 -04:00
Claude
6412b5c252 fix(worker): conform — preserve audio + map ProRes/DNxHR codecs
Three cooperating bugs left the rendered output silent and in the
wrong codec:

1. executor.js trimSegment used `-frames:v` with no audio mapping.
   ffmpeg dropped the audio track on each segment before they reached
   the concat step. Add `-c:a copy -shortest` so each segment carries
   its original audio.

2. conform.js audioFlag was `audio === 'include' ? aac : -an`. The
   panel's v2.2.1 defaults send `audio: 'broadcast'`, which didn't
   match 'include' → `-an` explicitly stripped audio at the encode
   step. Switch to the opposite default: only an explicit 'none' or
   'off' disables audio; everything else gets AAC 320k @ 48kHz.

3. conform.js video codec map only matched `codec === 'prores'`. The
   panel sends `'prores_hq'` (and the conform slide panel can send
   `'prores_4444'` / `'dnxhr_hq'`). All of those fell through to
   libx264 and silently rendered H.264 instead of the requested codec.
   Add a real codec map with the right prores_ks profiles (3=HQ,
   4=4444) and DNxHR. Skip -crf for ProRes since the profile encodes
   quality.

The asset-row metadata's `codec` column is normalised the same way so
the new asset record matches what was actually written.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:32:49 -04:00
Claude
56d7479a35 fix(mam-api): pass project_id into conform job so render can register the asset
The conform worker's final step INSERTs the rendered output into the
assets table:

  INSERT INTO assets (project_id, filename, display_name, …)
  VALUES ($1, …)
  -- project_id NOT NULL

It reads projectId from job.data, but the /sequences/:id/conform
endpoint never set it. Render finished cleanly, ffmpeg ran, output
uploaded to S3, then the final asset row INSERT failed:
  null value in column "project_id" of relation "assets"

Pass seq.project_id from the loaded sequence row. The rendered output
lands as an asset under the same project as its source sequence —
the natural target.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:24:04 -04:00
Claude
aeecb6e32a fix(worker): conform — resolve clips from sequence_clips instead of filename
Panel had been sending xmeml with clipitem/name = the local Premiere
file path's basename (e.g. "dragonflight-Interstellar - Docking Scene
1080p IMAX HD.mp4"). The worker's old filename lookup ran
  SELECT id, original_s3_key FROM assets WHERE filename = $1
which never matched, because the assets row's filename is the
original MAM ingest name without the "dragonflight-" prefix.

Fix: when job.data has sequenceId (always set by the conform endpoint
at routes/sequences.js:317), pull edits directly from sequence_clips,
which the panel already wrote with authoritative asset_id mappings on
push. We JOIN to assets for original_s3_key + filename and order by
(timeline_in_frames, track) so segment indices stay deterministic.

The XML is still parsed for sequence-level metadata (name, fps) when
provided, but its clipitems are no longer authoritative.

The legacy filename path (EDL input or fcpXml without sequenceId)
stays unchanged for backward compatibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:08:49 -04:00
Claude
0abef056e7 fix(uxp+mam-api): Export Timeline render — xmeml schema + BullMQ job poll
Two cooperating bugs left Export Timeline stuck at "Rendering Hi-Res"
forever:

A. worker emitted "Invalid FCP XML: no sequence element" because
   Timeline.generateFcpXml produced fcpxml (FCP X schema:
   <fcpxml><resources>/<library>/...) while the worker's parseFcpXml
   expects xmeml (FCP 7 schema: <xmeml><sequence>...). Two completely
   different formats.

   Rewrite generateFcpXml to emit xmeml v5 with the structure the
   parser walks:
     xmeml/sequence/{name,duration,rate{timebase,ntsc},
                     media/video/{format/samplecharacteristics,
                                  track[@currentExplodedTrackIndex]
                                  /clipitem/{name,duration,rate,in,out,
                                             start,end,file/{name,pathurl}}}}
   Clipitem in/out are SOURCE frames (the underlying media in/out);
   start/end are TIMELINE frames (the cut position). The worker uses
   the rate timebase to parse them.

B. /api/v1/jobs/:id rejected the panel's polls with
   "Invalid id — must be a UUID". The handlers below correctly parse
   BullMQ-prefixed ids ("conform:42"), but router.param('id',
   validateUuid('id')) ran first and 400'd everything that wasn't a
   UUID. The panel's pollConform swallows the resulting fetch error
   silently and polls forever.

   Drop the validator. Comment in the file explains why.

Bumps panel to v2.2.2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:58:13 -04:00
Claude
540d333758 feat(uxp): v2.2.1 — Export Timeline is now one-click push + render → asset
Contract: clicking Export Timeline does the whole pipeline with no
prompts. Behavior matches what the user actually expected from the
button label:

  1. readActiveSequence — pulls the Premiere timeline + clip map
  2. resolveExportProject — picks the target MAM project. First run
     uses the first project on the server and caches its id in
     localStorage (df.uxp.exportProjectId). Subsequent runs reuse
     the cache. If the cached project was deleted server-side we
     transparently re-pick.
  3. Timeline.startConform with sensible defaults:
       codec=prores_hq, quality=high, resolution=source, audio=broadcast
     This both pushes the sequence + clip rows AND queues a real
     conform job (the prior Push-to-MAM button never queued a job,
     which is why "no jobs spin up" happened earlier).
  4. pollConform every 2s, mapping job progress 20→95% on the
     panel progress bar.
  5. On completion, toast + Library.refresh() so the rendered hi-res
     asset shows up in the grid without needing to click around.

The Conform slide panel stays wired for Advanced → Export & Conform
so power users can still override the codec/preset for one-off jobs.
The Push-only slide panel that this replaces is now orphaned chrome
and will be removed in a later cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:28:51 -04:00
Claude
e4e69973e5 ui(uxp): v2.2.0 — density pass: drop details panel, tighter buttons, collapsible Advanced
User feedback after v2.1.9: panel still chrome-heavy. The Asset Info
panel duplicates what the card already shows; 8 buttons across 3
full-width rows still claim too much vertical real estate.

Three surgical changes:

1. Drop the Asset Info details panel entirely. Card meta (name +
   duration + codec) already carries everything we showed in the
   key:value table. Library._showDetails / hideDetails become no-ops
   so the existing call sites in main.js + library.js don't need
   conditional branches.

2. Shrink .action-row .btn to 20px tall, 10.5px font, 6px horiz
   padding, 3px radius. Two rows of compact buttons fit where one
   bulky row used to.

3. Collapse Advanced section behind a toggle (▸ / ▾). Default
   collapsed so the main 6 buttons stay the primary action surface;
   click the row to expand and reveal Export & Conform / Fetch &
   Relink All.

Per DESIGN.md "density over whitespace."

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:03:56 -04:00
Claude
c3b087020d ui(uxp): v2.1.9 — visible version chip + diagnose multi-version install
UPIA stacks every install in its own
  C:\Program Files\...\UXP\Plugins\External\net.wilddragon.dragonflight.uxp_<version>\
folder without removing prior versions. After 10 deploys today there are
11 of them coexisting, and Premiere's loader can pick the wrong one,
which is why v2.1.8 didn't appear to land.

This change makes the running version visible at a glance:

- main.js reads manifest.json at runtime via require('uxp').storage
  .localFileSystem.getPluginFolder() so the displayed version is
  whatever Premiere actually loaded — never a hand-edited constant
  that could drift.
- index.html adds #panel-version inside the status strip (between
  host and ⋯) and #brand-version below the brand tag on connect.
- styles.css: small mono chip in --text-4, low key but readable.

If the chip ever shows the wrong version we know the loader picked
a stale dir; if it shows nothing the manifest read itself failed.

The install script needs to remove old _<version> dirs going forward;
the next commit will add that cleanup step to the deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:00:50 -04:00
Claude
c2a6c1557b ui(uxp): v2.1.8 — density redesign on top of v2.1.7
Three changes, surgical so timeline.js / conform / relink / growing
all keep working:

A. Header → 24px status strip + ⋯ menu
   `connected-bar` rule kept as an alias to `.status-strip` so any code
   path that still emits the old class falls through cleanly. Markup
   replaced with .signal-dot + #connected-host + .btn-ghost ⋯ that
   toggles a .menu containing the Disconnect button. Menu auto-closes
   on outside click. Reclaims ~12px of permanent vertical chrome and
   removes the always-visible Disconnect.

B. Compact action footer
   `.action-row .btn` now: 22px tall, 11px font, 0.01em letterspacing.
   `.advanced-section .action-row .btn` goes a step smaller (20px /
   10.5px). Global `.btn` untouched so #connect-btn stays at full
   weight on the connect pane.

D. Token alignment with services/web-ui DESIGN.md
   --bg-0 #0B0D11 (was #0e0f12), --accent #5B7CFA (was #4f7cff),
   plus the full --text-1..4 / --success / --warning / --danger / --live
   palette. Legacy --ok / --warn aliased to --success / --warning so
   existing rules keep resolving.

C (per-card meta) was already in v2.1.7 — no change needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:51:47 -04:00
Dragonflight Deploy
b04882a310 release(uxp): v2.1.7 — fix resource busy + export timeline robustness 2026-05-28 11:12:49 -04:00
60e5093c6b fix(uxp): null-safe Time object access in readActiveSequence
getStartTime/getEndTime/getInPoint/getOutPoint can return null for
non-clip track items (gaps, transitions) that slip past the
getProjectItem check. Accessing .seconds on null threw a TypeError
that the outer catch swallowed — silently dropping every clip and
leaving clips[] empty, so the export panel never opened.

Also skip clips where all four time values resolve to 0 (filler items).
2026-05-28 11:12:32 -04:00
382f432693 fix(uxp): resolve "Resource busy" on re-import of same asset
- _writeBuffer: catch EBUSY (Windows file-lock) and treat as success —
  the file is already there from the previous import and Premiere has it
  locked; no need to re-write it.
- proxy / hires: stat the destination first; if the file already exists
  skip the download entirely and go straight to importIntoProject.
- importIntoProject: importFiles returning false means the file is
  already in the Premiere project — not an error, treat as success.
2026-05-28 11:11:32 -04:00
Dragonflight Deploy
e533566ae2 release(uxp): v2.1.6 — fix thumbnail auth (bearer fetch + blob URL) 2026-05-28 09:53:34 -04:00
c3e4306d9f fix(uxp): fetch thumbnails via API.request() to carry Bearer token
img.src direct assignment never sends Authorization headers, so all
thumbnail requests returned 401 once the global auth gate was enabled.
Now fetches via API.request(), converts response to a blob URL, and
assigns that to img.src. Falls back to the placeholder div on error.
2026-05-28 09:41:26 -04:00
eeb0d9f65f UXP v2.1.5b: main.js relink handler await fix + artifact 2026-05-28 09:15:24 -04:00
7f0ca5922f UXP v2.1.5: main.js — fix relink handler: await getActiveProject, use requestExternal + arrayBuffer 2026-05-28 09:15:06 -04:00
469521d524 UXP v2.1.5 release artifact 2026-05-28 09:07:15 -04:00
07840441b9 UXP v2.1.5: bump manifest version 2026-05-28 09:07:00 -04:00
5774f61ac7 UXP v2.1.5: timeline.js — await all premierepro calls; runtime is async 2026-05-28 09:06:43 -04:00
1fb790a569 UXP v2.1.5: import-flow — await all premierepro calls (runtime is async despite docs saying sync) 2026-05-28 09:05:49 -04:00
11cb93aa51 UXP v2.1.4 release artifact 2026-05-28 08:32:55 -04:00
dbc67636b2 UXP v2.1.4: bump manifest version 2026-05-28 08:32:35 -04:00
460b590d46 UXP v2.1.4: timeline.js — replace API.requestFollow with API.requestExternal for batch relink S3 downloads 2026-05-28 08:32:18 -04:00
f3a640a7c5 UXP v2.1.4: api.js — remove redirect:manual (not supported in UXP fetch); UXP auto-follows redirects 2026-05-28 08:31:09 -04:00
baa289f6c3 UXP v2.1.4: import-flow — drop redirect:manual (not supported in UXP fetch, causes null body); use arrayBuffer() fallback if body.getReader unavailable 2026-05-28 08:30:30 -04:00
e5f218655e UXP v2.1.3 release artifact 2026-05-28 07:50:52 -04:00
8119b57b45 UXP v2.1.3: bump manifest version 2026-05-28 07:50:29 -04:00
9765fd91f7 UXP v2.1.3: main.js — fix inline relink handler (sync getActiveProject, no require inline) 2026-05-28 07:50:18 -04:00
a25e4b6071 UXP v2.1.3: timeline.js — correct Premiere DOM API calls per official docs 2026-05-28 07:48:57 -04:00
046d99f57a UXP v2.1.3: import-flow — use window.path.join, replace fs.createWriteStream with fd-based chunked write 2026-05-28 07:47:44 -04:00
fcc737e05b UXP v2.1.2 release artifact 2026-05-28 07:20:14 -04:00
8f93302f45 UXP v2.1.2: bump manifest version 2026-05-28 07:20:09 -04:00
17ca9bfc75 UXP v2.1.2: import-flow — replace path.join (not in UXP) with manual join helper 2026-05-28 07:19:46 -04:00
f8fa0fa010 UXP v2.1.1 release artifact 2026-05-28 02:25:30 -04:00
907058de83 UXP v2.1.1: bump manifest version 2026-05-28 02:25:21 -04:00
bfe0316067 UXP v2.1.1: add conform-proj-select to Conform panel 2026-05-28 02:24:58 -04:00
5d94838830 UXP v2.1.1: main.js — fix recordImport from proxy/hires return values, add conform project select, fix conform panel 2026-05-28 02:24:07 -04:00
76fff5efc2 UXP v2.1.1: import-flow.js — expose _tempPath/_streamToFile, return {localPath,safeName} from proxy/hires 2026-05-28 02:22:58 -04:00
5432c2dfa1 UXP v2.1.0 release artifact 2026-05-28 02:21:36 -04:00
b3b2655272 UXP v2.1.0: bump version in manifest 2026-05-28 02:20:43 -04:00
16366267c4 UXP v2.1.0: main.js — full rewrite, wire all panels, tabs, export, conform, relink, mount live 2026-05-28 01:01:29 -04:00
066718c968 UXP v2.1.0: timeline.js — new module: sequence read, FCP XML, export, conform, batch relink via UXP premierepro API 2026-05-28 01:00:19 -04:00
60d0b09c63 UXP v2.1.0: ui.js — add formatDuration, sanitizeFilename, slide panel helpers, escapeXml 2026-05-28 00:59:21 -04:00
2608d7a465 UXP v2.1.0: library.js — project filter, status badges, details panel, growing tab, growing poll 2026-05-28 00:58:57 -04:00
cd18988d6d UXP v2.1.0: api.js — add projects, live-path, sequences, conform, batch-trim endpoints 2026-05-28 00:57:59 -04:00
be57eb0a50 UXP v2.1.0: CSS — full rewrite, all new panels, tabs, details, badges 2026-05-28 00:57:23 -04:00
25356ca439 UXP v2.1.0: full feature parity with V1 CEP — tabs, details, export, conform, relink, mount live 2026-05-28 00:56:15 -04:00
Claude
4bea3c94f8 fix(premiere-plugin-uxp): v2.0.2 — resolve temp folder defensively (no os.tmpdir)
UXP's `os` is a stripped subset of Node's — `os.tmpdir()` isn't exposed in
the build PPro 26.0.x ships, so both Import Proxy and Import Hi-Res failed
immediately with "os.tmpdir is not a function".

Replace with a defensive resolver tried in order:
  1. os.tmpdir if present (newer UXP builds)
  2. require('uxp').storage.localFileSystem.getTemporaryFolder() → .nativePath
     (the documented portable approach)
  3. process.env.TEMP / TMP / LOCALAPPDATA\\Temp (Windows always sets these)
  4. os.homedir() + AppData/Local/Temp

tempPath() is now async; both Import.proxy and Import.hires await it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:43:44 -04:00
Claude
f1a3d6a24a fix(premiere-plugin-uxp): v2.0.1 — replace unsupported CSS + surface load count
The v2.0.0 grid stayed empty in Premiere 26 because UXP's CSS engine
doesn't support `grid-template-columns: repeat(auto-fill, minmax(...))`
or `aspect-ratio`. Cards rendered with 0 height and the flex column
collapsed, so the actions row stuck to the top of the pane.

Switch to flex-wrap with fixed-width (140px) cards and explicit 80px
thumb heights — both work in UXP's stripped CSS.

Also fix the /auth/me response shape — it returns the user fields
directly, not wrapped in `{ user: ... }`. Header now shows
"display_name @ host" instead of falling back to bare host.

Add a toast on each library load reporting "Loaded N assets (total M)"
so we can tell empty-grid (zero assets) from CSS-broken-grid (cards
exist but invisible) at a glance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:35:04 -04:00
Claude
91e4691230 feat(premiere-plugin-uxp): v2.0.0 — UXP port replacing CEP for import
CEP `csInterface.evalScript` callback is broken in Premiere Pro 26.0.x —
nothing called from the panel ever returns, so importFiles deadlocks. Adobe's
path forward is UXP. This is the minimum viable port that restores the
Import Proxy / Import Hi-Res workflow.

Scope (v2.0.0):
- Connect to a Dragonflight server (URL + Bearer token; persisted)
- Asset library (search, refresh, grid with thumbnails)
- Import Proxy via streamed download → Project.importFiles
- Import Hi-Res via presigned S3 URL → Project.importFiles

Layout:
  manifest.json     UXP v5, host=premierepro, minVersion=26.0.0
  index.html        Panel shell
  styles.css        Mirrors web UI dark tokens
  src/ui.js         DOM helpers, toast, progress, formatting
  src/api.js        HTTP client (Bearer; manual redirect-follow drops auth
                    when hopping to a different host per UXP security policy)
  src/library.js    Asset grid render + selection
  src/import-flow.js  Streaming download (fs.createWriteStream) +
                      premierepro.Project.importFiles into rootBin
  src/main.js       Bootstrap, event wiring
  build/pack.mjs    Packs into .ccx; installs via UnifiedPluginInstallerAgent

Coexists with services/premiere-plugin/ (CEP) — keeps the CEP panel for any
features that still work there while running v2.0.0 for import. Future v2.x
will add live preview, conform, timeline export, settings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:19:28 -04:00
Claude
8b48f03f6b diag(premiere-plugin): v1.2.5 — no-op IIFE writes to Documents/ + reports lf.open result 2026-05-28 03:59:40 +00:00
Claude
9085835074 diag(premiere-plugin): v1.2.4 — fix pre-importFiles log syntax (safePath as string literal not bareword) 2026-05-28 03:50:42 +00:00
Claude
f5959620c8 diag(premiere-plugin): v1.2.3 — file-log around importFiles call
Writes timestamped pre/post lines to C:/Temp/df-import-log.txt around the importFiles call so we can see whether importFiles hangs (only pre line present) or returns and evalScript callback gets lost (both lines present). Diagnostic only.
2026-05-28 03:46:00 +00:00
Claude
e3afe38697 fix(premiere-plugin): suppress importFiles UI prompts + 60s timeout guard
app.project.importFiles() can deadlock if a hidden Premiere modal appears (off-screen, behind window, etc) — the evalScript callback never fires and the panel spinner hangs forever.

Two changes:

1) Pass suppressUI=true to all five importFiles call sites (main.js inline IIFE + 4 in premiere.jsx). Premiere proceeds even if it would have prompted (audio sample rate, project link, scale-to-frame, etc).

2) Wrap importFileToPremiereProject in a 60s timeout race so even if importFiles does block, the panel surfaces a real error instead of leaving the spinner stuck.

Bumps to v1.2.2.
2026-05-28 03:19:44 +00:00
Claude
e7eff0ee8c release(premiere-plugin): v1.2.1 with downloadFile Bearer + 15s timeout fix 2026-05-27 23:07:27 -04:00
Claude
e8ceb991a3 fix(premiere-plugin): inject Bearer in downloadFile + add 15s timeout
downloadFile() uses native https.get which bypasses the window.fetch interceptor that injects Authorization. Same-server URLs (proxy /video) hit requireAuth and 401. Inject the Bearer header manually when url starts with state.serverUrl.

Also add a 15s setTimeout so an unreachable presigned URL (or CEP-Node TLS hiccup on broadcastmgmt.cloud) fails fast with an error instead of hanging the spinner forever.
2026-05-28 03:00:02 +00:00
Zac Gaetano
ac7730195d fix(web-ui): forward X-Forwarded-Proto from outer proxy so mam-api emits Set-Cookie
This is the real cause of the login loop. mam-api sets its session cookie
with Secure=true (production config). express-session refuses to emit a
Secure Set-Cookie unless req.secure is true. With `app.set('trust proxy')`
on, req.secure derives from X-Forwarded-Proto.

web-ui's nginx was unconditionally sending `X-Forwarded-Proto: $scheme`.
Inside the web-ui container nginx listens on port 80, so $scheme is always
"http" — regardless of whether the outer NPM proxy terminated TLS. mam-api
saw http, decided the connection was insecure, and silently dropped the
Set-Cookie from the login response. Login succeeded server-side (session
row landed in PG, last_login_at updated) but the browser never received a
cookie, so the very next /auth/me check came back 401 and AuthGate bounced
to the login screen. Infinite loop.

The previous Connection: "upgrade" → $connection_upgrade fix wasn't wrong
(the hardcode is a real latent bug worth fixing) — it just wasn't the
proximate cause.

Fix: a second `map` directive forwards the outer X-Forwarded-Proto through
when present, falling back to $scheme only when no proxy header exists (so
direct localhost curls still work). Both /api/ and /capture/ now send the
correct value upstream, mam-api sees https, req.secure is true, Set-Cookie
flows through, login works.

Verified by curling the existing direct-to-mam-api path: with X-Forwarded-
Proto: https on the request, Set-Cookie comes back; without it, no
Set-Cookie. That's the exact difference between web-ui-proxied and
direct-to-mam-api in our previous diagnostic curls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:11:27 -04:00
Zac Gaetano
c24c6156dc fix(web-ui): stop nginx from eating Set-Cookie on /api/ and /capture/
Login was infinite-looping in production. Server side was healthy (sessions
landing in PG, /me returning 200 to a manually-signed cookie) but the
browser never received `Set-Cookie`. Bisected the proxy chain layer by
layer with direct curls on the box:

  - mam-api direct (port 47432) → Set-Cookie present
  - web-ui nginx (port 47434)   → Set-Cookie STRIPPED
  - NPM (https://dragonflight.live) → Set-Cookie stripped (because web-ui ate it)

Root cause was this in /api/ and /capture/:

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

The literal "upgrade" was being sent on every request, not just real
WebSocket negotiations. Nginx then routes the upstream response through
its tunnel/upgrade code path, which doesn't preserve all response headers
the same way — Set-Cookie got silently dropped. mam-api doesn't speak
WebSockets today so it never sent a 101, and the bad pattern went
unnoticed until session-cookie auth shipped.

Fix is the standard conditional pattern: a `map` directive at the top of
default.conf computes $connection_upgrade as "upgrade" only when the
client actually requested Upgrade, otherwise "close". Both location blocks
now send `Connection $connection_upgrade` instead of the hardcoded literal.
WebSocket support on either location continues to work unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:00:35 -04:00
Zac Gaetano
7e3e6b2a28 fix(auth): force HTTPS on dragonflight.live so login cookies stick
User reported infinite login loop on dragonflight.live. Root cause: openresty
fronts both http:// and https:// without redirecting, and a user landing on
http:// gets the Set-Cookie response silently dropped — cookies are Secure-only
when TRUST_PROXY=true, and the CORS allowlist refuses the http:// origin.
Result: login appears to succeed, next request has no session cookie, AuthGate
bounces back to login.

Two defensive layers (the openresty box is not in our reach):
- web-ui index.html: tiny inline redirect; if location is http://dragonflight.live,
  rewrite to https:// before anything else runs. Bounded to that exact hostname
  so local / LAN access on http://172.18.91.x stays as-is.
- mam-api: emit Strict-Transport-Security on HTTPS responses when AUTH_ENABLED=true.
  After one successful HTTPS visit, browsers auto-upgrade future http:// requests
  on their own — closes the loophole even if someone bypasses the index.html JS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:00:35 -04:00
5571768706 feat(panel): add .connected-bar CSS for compact connected state 2026-05-27 19:31:16 -04:00
350c23f9d1 fix(panel): restore main.js — add disconnectFromServer + connected-bar toggle 2026-05-27 19:28:39 -04:00
Zac Gaetano
8028c4c4dd feat(auth): bound-hostname tokens for node-agent + return role from /me
- requireAuth bearer path now selects api_tokens.bound_hostname and users.role,
  populates req.tokenBoundHostname and req.user.role. /cluster/heartbeat can
  now authenticate via a bound api_token (issued via POST /auth/tokens with
  bound_hostname).
- routes/tokens.js POST accepts bound_hostname; GET returns it so users can
  see which tokens are bound.
- Remove /cluster/heartbeat from SERVICE_PATHS so requireAuth runs on it (the
  bearer auth handles the gate; the heartbeat handler still enforces the
  body.hostname === bound match).
- /auth/me now returns role (final-review I2). Closes the gap where every
  signed-in user appeared as 'viewer' in the UI regardless of actual role.
- loadUser SELECTs role for session auth.
- Backend tests still 37/15/0/22 — no test changes needed; existing token
  CRUD tests stay passing since bound_hostname is optional.
2026-05-27 19:27:59 -04:00
e6da1432e5 fix(panel): restore main.js with disconnect feature (was accidentally emptied) 2026-05-27 19:22:15 -04:00
e22cf625bf feat(panel): add disconnectFromServer(); toggle connection-form / connected-bar
- On connect success: hide form, show compact connected-bar with hostname
- On disconnect: clear assets, reset buttons, restore form
- Wire disconnect-btn click to disconnectFromServer()
2026-05-27 19:21:50 -04:00
552506ec7a feat(panel): collapse connection form when connected; add Disconnect button
On startup the full form shows. On successful connect the form hides and a
compact connected-bar appears with the server hostname and a Disconnect button.
2026-05-27 19:21:38 -04:00
Zac Gaetano
e5c9c770d0 fix(compose): plumb TRUST_PROXY + ALLOWED_ORIGINS through to mam-api container
Task 18 documented the two new env vars in .env.example and README but never
added them to docker-compose.yml's mam-api environment block. Without that,
the vars in .env never reach the container — so AUTH_ENABLED=true was running
with effective TRUST_PROXY=false (req.ip = proxy IP, rate-limit collapses to
per-proxy bucket) and ALLOWED_ORIGINS unset (CORS allows any origin).
2026-05-27 19:16:09 -04:00
a0a6bc9f20 feat(panel): migrate accent palette from hue-32 orange-red to hue-266 blue
Aligns CEP panel with Dragonflight web-ui tailwind.config.js color system.
All bg/accent/text/border tokens updated; signals unchanged.
2026-05-27 19:14:24 -04:00
Zac Gaetano
c8e98ffa0d fix(auth): sync DEV_USER_ID with migration 023 — use all-zeros UUID
Migration 023 was fixed in 9dc572b to use '00000000-0000-4000-8000-000000000000'
because 'v' isn't a valid hex digit, but the DEV_USER_ID constant in
middleware/auth.js still referenced the original '...000000000dev'. Every
route that passes DEV_USER_ID as a query parameter (users list, login lookup,
setup-required count) was throwing 22P02 invalid input syntax for type uuid.
The errors were swallowed by Promise.allSettled in the SPA's data load so the
app appeared to work in dev mode, but enabling AUTH_ENABLED=true would have
broken login entirely.
2026-05-27 19:08:07 -04:00
9dc572b913 fix(migration): replace invalid UUID in 023 dev user seed 2026-05-27 18:45:21 -04:00
Zac Gaetano
14ece1a160 Merge branch 'feat/auth-system' 2026-05-27 15:47:56 -04:00
Zac Gaetano
03d0d098f5 fix(auth): final-review integration fixes — Users page alias + PATCH, CSRF on uploads + heartbeat, drop .bak
Final-review findings:
- Mount usersRouter at /api/v1/users in addition to /api/v1/auth/users so the
  existing SPA Users page works; add PATCH /:id for inline edits (display_name,
  role, password).
- Add X-Requested-With: dragonflight-ui to raw XHR/fetch paths that bypass
  apiFetch (file uploads, SDK uploads, EDL export) — without it, requireUiHeader
  403s before reaching the route.
- Exempt SERVICE_PATHS (/cluster/heartbeat) from requireUiHeader so node-agent
  heartbeats keep working when NODE_TOKEN is unset.
- Remove stale auth.js.bak.
2026-05-27 15:42:42 -04:00
Zac Gaetano
8ede44ae87 docs(auth): flip AUTH_ENABLED default + document setup + recovery 2026-05-27 15:25:29 -04:00
Zac Gaetano
2aec4636cb feat(web-ui): Settings → Account (change password) + API Tokens sections 2026-05-27 15:20:57 -04:00
Zac Gaetano
cfe21e315e feat(web-ui): LoginScreen + SetupScreen (layout B from brainstorm) 2026-05-27 15:17:33 -04:00
Zac Gaetano
7e240d86c8 feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render 2026-05-27 15:08:14 -04:00
Zac Gaetano
96effaaa3c fix(mam-api): TRUST_PROXY boot warning + CSRF integration tests + bounded rate-limit map
Fixes three issues in the authentication system:

C1: Add boot-time warning when AUTH_ENABLED=true but TRUST_PROXY!=true.
    Without TRUST_PROXY=true behind nginx, req.ip becomes the proxy IP for all
    clients, collapsing per-IP rate limiting into a shared pool. Operators must
    explicitly set TRUST_PROXY=true to make per-IP rate limiting effective.

C2: Mount requireUiHeader middleware in test helpers (auth.test.js,
    users.test.js, tokens.test.js). The CSRF header validation was not being
    exercised in the test suite. Tests now send X-Requested-With: dragonflight-ui
    headers that are actually validated by the middleware.

I1: Implement bounded rate-limit Map with MAX_ENTRIES=10000 and LRU eviction.
    Unbounded Maps are vulnerable to spray attacks: attackers can force memory
    exhaustion by requesting with distinct IPs. Now we evict the oldest entry
    (by insertion order) when the map reaches capacity.
2026-05-27 15:03:35 -04:00
Zac Gaetano
d209a192c3 feat(mam-api): login rate limit + X-Requested-With CSRF header check 2026-05-27 14:58:02 -04:00
Zac Gaetano
56b661ef65 feat(mam-api): API token CRUD — show raw once, bearer-authenticate via SHA-256 lookup 2026-05-27 14:52:07 -04:00
Zac Gaetano
b7f5a84d2d feat(mam-api): user CRUD + admin password reset + last-user delete guard 2026-05-27 14:47:03 -04:00
Zac Gaetano
0bbaf80d2a feat(mam-api): GET /auth/me + POST /auth/password 2026-05-27 14:42:53 -04:00
Zac Gaetano
d75a0241eb feat(mam-api): POST /auth/logout 2026-05-27 14:38:05 -04:00
Zac Gaetano
bcfc19e530 fix(mam-api): real dummy bcrypt hash + log last_login_at failures
Code-review feedback:
- Dummy hash for user-enumeration-defense timing was 63 chars (bcrypt strings
  are 60 chars). Worked by accident because bcrypt 5.x is lenient about
  trailing chars; a future tightening would silently regress the timing
  defense. Replaced with a real pre-computed bcrypt hash.
- last_login_at UPDATE now logs errors instead of silently swallowing them,
  matching the pattern in requireAuth for api_tokens.last_used_at.
- Removed dead import of comparePassword from auth.test.js.
2026-05-27 14:35:59 -04:00
Zac Gaetano
f8b6f7d5ef feat(mam-api): POST /auth/login + redirect-loop regression test 2026-05-27 14:28:18 -04:00
Zac Gaetano
c9f9698b58 feat(mam-api): POST /auth/setup — first-run admin creation 2026-05-27 14:24:56 -04:00
Zac Gaetano
49a9543942 feat(mam-api): auth router skeleton + setup-required endpoint 2026-05-27 14:21:32 -04:00
Zac Gaetano
cb7cc9a43e fix(mam-api): narrow cluster carve-out to /cluster/heartbeat only
Code-review feedback: startsWith('/cluster') was a prefix match that exposed
destructive operator endpoints (POST /containers/:id/restart, DELETE /:id,
GET /devices/blackmagic/*) unauthenticated. Only POST /heartbeat is genuine
node-agent traffic; everything else in cluster.js is operator/UI surface
that should go through requireAuth. Long-term: issue node-agent a bound
api_token and drop the carve-out entirely.
2026-05-27 14:18:27 -04:00
Zac Gaetano
9de4fe9ab9 feat(mam-api): mount requireAuth gate at /api/v1 with auth + cluster carve-outs 2026-05-27 14:13:21 -04:00
Zac Gaetano
88c3aa5149 fix(mam-api): SESSION_SECRET boot guard + cleaner CORS rejection
Code-review feedback:
- Hard-fail boot when AUTH_ENABLED=true and SESSION_SECRET is unset, so
  express-session can't silently use an in-memory random secret that
  invalidates sessions on restart and breaks multi-node clusters.
- CORS rejection now returns cb(null, false) instead of cb(new Error)
  so misconfigured origins surface as clean CORS errors in the browser
  instead of HTTP 500s. Log a warn line for operator visibility.
- pruneSessionInterval units comment.
2026-05-27 14:11:09 -04:00
Zac Gaetano
a094df03ea feat(mam-api): wire express-session + tighten CORS allowlist 2026-05-27 14:06:41 -04:00
Zac Gaetano
1a723fe4c2 fix(mam-api): requireAuth — stamp last_seen_at after user confirmation
Code-review feedback: writing last_seen_at = now before loadUser() lets
the stamp persist if the lookup throws (resave:false still writes when
modified), extending the idle window without confirming the user exists.
Also clarify DEV_USER_ID is a specific placeholder, not a generic sentinel.
2026-05-27 14:04:15 -04:00
Zac Gaetano
0248a68f57 feat(mam-api): requireAuth middleware — session + bearer + idle/absolute timeout 2026-05-27 13:59:50 -04:00
Zac Gaetano
3bca290e09 fix(mam-api): test glob — use find so npm test picks up files at any depth
/bin/sh (which npm uses) doesn't expand ** recursively. Task 1's smoke test
under test/ stopped being discovered once Task 3 added tests under test/auth/.
find + sort keeps depth-agnostic discovery portable across shells.
2026-05-27 13:54:12 -04:00
Zac Gaetano
3fc8116dd3 feat(mam-api): auth utilities — password hash/compare + token gen/hash/parse 2026-05-27 13:51:15 -04:00
Zac Gaetano
14931d6362 fix(mam-api): migration 023 — broaden ON CONFLICT + document password_updated_at backfill
Code-review feedback: ON CONFLICT (id) only catches id collisions; a pre-existing
'dev' username would trigger a unique_violation on the username index and roll
back the migration, hard-failing the mam-api boot. Switch to bare ON CONFLICT
DO NOTHING so any unique conflict is no-op-safe.
2026-05-27 13:48:08 -04:00
Zac Gaetano
1d3c0385dd feat(mam-api): migration 023 — auth timestamps + idempotent dev user seed 2026-05-27 13:44:07 -04:00
Zac Gaetano
5011d45391 chore(mam-api): wire node:test runner + test app + DB helper 2026-05-27 13:38:46 -04:00
Zac Gaetano
99fae69960 docs(auth): implementation plan for user auth system 2026-05-27 12:17:11 -04:00
1e51a4ca5d fix(premiere-plugin): align panel CSS with web-ui design system
Component-level alignment pass against services/web-ui/src/css/components/:

- btn-primary text: #070403 (near-black) → #f5f7fb (near-white, matches web-ui)
- btn-danger text: #fcf7f7 → #fbf6f6 (precise oklch(98% 0.005 25) conversion)
- btn-secondary border: border-strong → border; hover: bg-hover + border-strong
- button.secondary legacy: same border/hover fix
- asset-card bg: bg-surface → bg-panel (matches wd-card-asset)
- asset-card hover: remove accent glow + transform + shadow; border → var(--border) only
- asset-card hover brightness: moved to img child only (matches web-ui pattern)
- asset-card selected: remove box-shadow ring; bg → bg-raised
- asset-card border-radius: explicit 6px (was var(--r-md))
- asset-card transition: simplified to border-color with design-system easing
- asset-filename: font 11px → font: 500 12px/1.3 (web-ui uses 13px; 12px for panel density)
- asset-meta: 10px text-secondary → font: 400 11px/1.3 font-mono + text-tertiary + tabular-nums
- asset-status-badge: border-radius 100px → 3px (matches wd-badge)
- chip: pad/gap aligned with wd-badge; font: 600 10px/1; added chip--idle + chip--info variants
- form-label: 10px text-secondary → font: 600 11px/1 + text-tertiary + margin-bottom: 4px
- details-header-label: aligned to font shorthand + 0.08em spacing
- details-label: aligned to font: 600 10px/1
- export-panel-title: font shorthand
- Add @keyframes wd-shimmer + .skeleton utility
- Add @media (prefers-reduced-motion) block
- Update file header comment
2026-05-27 12:00:00 -04:00
Zac Gaetano
c2fd48b0ce docs(auth): clarify dev-user FK seeding and cluster route carve-out 2026-05-27 11:59:19 -04:00
Zac Gaetano
183e10f8e6 docs(auth): spec for user auth system, brainstormed 2026-05-27 2026-05-27 11:57:44 -04:00
ad9e1ef5f1 fix(premiere-plugin): replace oklch() with hex/rgba for CEP Chromium compat
CEP's embedded Chromium (used by Premiere Pro panels) does not support
oklch() color syntax. All color tokens were rendering as invalid/transparent,
causing the panel to appear unstyled. Converted all oklch() values to their
precise hex/rgba equivalents via OKLab→sRGB math. No design changes.
2026-05-27 10:44:39 -04:00
ada8105948 chore(web-ui): bump Premiere panel latest to v1.2.0 in data.jsx (#125) 2026-05-27 10:14:12 -04:00
c84519b606 release(premiere-plugin): publish v1.2.0 ZXP 2026-05-27 10:12:22 -04:00
33239a780e design(premiere-plugin): align panel UI with web-ui design system
- Add motion tokens (ease-out-quart/expo, dur-fast/normal/slide)
- Add z-layer tokens, overlay, thumb-black, accent-hover/bright
- Restructure signal/status tokens; flip to signal-primary/status-alias pattern
- Add signal-info/info-bg for --status-blue backwards compat
- Buttons: md=32px, sm=28px, lg=36px, icon=28sq, font 500/13px
- Inputs/select: 32px tall, bg-deep background, focus-visible outline
- Slide panel: 460px wide, 52px header, 18px padding, ease-out-expo, min-height:0
- Asset card: intrinsic height, thumb-black thumbnail, border-faint, brightness hover
- Status badges: 18px, font-sans, 0.08em tracking
- Chips: 18px, font-sans, no border, signal-bg backgrounds
- Tabs: 36px, no text-transform, pill badge
- Action bar: bg-deep background
2026-05-27 10:09:45 -04:00
7a6113fc90 capture: live port signal presence indicators on Capture screen and nav badge
- Capture screen now polls /cluster/devices/blackmagic/signal every 3s
- Per-port chips show signal state (RECEIVING/CONNECTING/LOST/ERROR/IDLE) with pulsing dot
- BMD SVG card diagram rendered per node card
- Sidebar nav badge on Capture item shows live/total port count (pulsing green dot)
2026-05-27 13:53:32 +00:00
de311321f4 design(premiere-plugin): align panel UI with Dragonflight web-ui design system (v1.2.0)
Rewrites css/styles.css to mirror services/web-ui/src/css/components/*
exactly. Brings the Premiere panel into pixel-level parity with the
main Dragonflight web UI:

- Tokens: add --accent-hover, --accent-bright, --thumb-black, --overlay,
  --shadow, --signal-info, motion tokens (ease-out-quart, ease-out-expo,
  dur-fast/normal/slide), z-layer vars. Keep --status-* aliases pointing
  at --signal-* for main.js backwards-compat. Remove unused --accent-dim
  (hue 52 leftover).

- Buttons: match wd-btn — md=32px (was 28px), sm=28px, lg=36px, icon=28sq.
  focus-visible accent-subtle outline. active opacity 0.85. Replace
  hardcoded oklch(68%) hover with --accent-hover. btn-danger now solid
  signal-bad like wd-btn--danger (was transparent w/ red border).

- Inputs/select/search-input: 32px tall, bg-deep background (was
  bg-surface), accent-subtle focus outline matching wd-input.

- Slide panel: 460px wide (was 420), 52px header (was 40), 18px body
  padding, --overlay scrim, ease-out-expo transform. min-height:0 on body.

- Asset card: removed fixed 155px height (now intrinsic), thumb-black bg
  for thumbnails, brightness 1.04 hover filter mirroring wd-card-asset.

- Status badges: 18px tall like wd-badge, font-sans, 0.08em tracking.

- Chips: 18px tall, font-sans (was font-mono 20px), wd-badge proportions.

- Tabs: 36px, accent underline on active, badge styled as pill.

- Empty state, progress bar, preset cards, clip list, message banners,
  form groups, details panel, action bar, connection bar — all spacing
  + typography refined to web-ui standards.

Manifest bumped to 1.2.0. No JS changes required.: manifest.xml
2026-05-27 09:01:04 -04:00
c48c7e6d7d feat(audio-tab): full audio track inspector with meters, mute/solo, faders
Issue #80 — replaces the stub AudioTab (two static waveforms) with a
broadcast-ops-grade audio panel:

- DB: add audio_metadata JSONB column to assets (migration 022)
- Worker: getMediaInfo now extracts per-stream audio metadata
  (codec, channels, channel_layout, sample_rate, bit_depth, bit_rate,
  language, title, disposition)
- Worker: proxy job persists audio_metadata into the assets row
- API: new GET /assets/:id/audio returns structured track list
- Frontend AudioTab: per-track rows with:
  - Track name/index with language badge
  - SVG waveform per track (color-coded)
  - L/R level meters via Web Audio API AnalyserNode
  - Per-track metadata row (codec, layout, sample rate, bit depth, bitrate)
  - Mute / Solo buttons with proper solo-logic
  - Per-track volume fader
  - Master section with summed L/R meters and master fader
- MetadataTab: show audio track summary when audio_metadata present
- CSS: full audio-tab layout, responsive collapse at 900px
2026-05-27 04:53:52 +00:00
48d54a32cf dashboard: add missing dash-* CSS classes; cluster: add stat-row/stat-card CSS
The Dashboard page was rendering as plaintext because all .dash-* CSS
classes (dash-section, dash-onair-*, dash-jobs-*, dash-cluster-*,
dash-statusbar, etc.) were missing. Added them with the full dark-theme
design-system styling matching the rest of the app.

The Cluster page's .stat-row and .stat-card classes were also missing,
causing node statistics (counts, CPU, GPUs, memory) to render unstyled.
Added grid-based stat row and card styles.
2026-05-27 04:09:15 +00:00
4172b0d70a rip out entire auth/login flow
- remove requireAuth from all route files
- delete auth.js, tokens.js, users.js routes
- delete auth middleware
- remove session middleware and all auth deps from index.js
- delete login.html and auth-guard.js from web-ui
2026-05-27 03:39:58 +00:00
opencode
9726dbb2df Revert "auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap"
This reverts commit 002e5acb82.
2026-05-27 03:28:05 +00:00
opencode
002e5acb82 auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
Scope (locked in via planning Q&A):
  - Identity: local accounts only (PG users table) + existing bearer
    tokens for headless callers.
  - Transport: httpOnly cookie session for browser, Bearer for API.
  - RBAC: admin / editor / viewer roles, plus an orthogonal
    is_client flag for external (agency, talent, customer) accounts.
  - Bootstrap: ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD env
    seed the first admin on a clean install. Set ADMIN_BOOTSTRAP_RESET
    to force-reset the named user (break-glass).
  - Rate limit: in-memory, 10 fails per 15min per (IP, username).
  - Password policy: \u22658 chars, mixed case, digit, symbol; small
    blocklist of common passwords; cannot equal username.
  - Self-service: change own display name + password. Everything
    else (role, is_client, other-user mgmt) is admin only.
  - Audit log: append-only table, indexed by actor + event_type +
    created_at, populated by every auth/admin event.

Files added:
  - services/mam-api/src/db/migrations/022-auth-rework.sql
        users.is_client + last_login_at + failed_attempts; audit_log
        table with FK to users (ON DELETE SET NULL).
  - services/mam-api/src/middleware/audit.js
        Fire-and-forget audit() helper. Caller never awaits, failure
        logs but never throws — auditing cannot break the request
        that triggered it.
  - services/mam-api/src/middleware/passwordPolicy.js
        Shared checkPassword(pw, { username }) used by setup, user
        create/update, and self-service password change.
  - services/mam-api/src/tasks/bootstrapAdmin.js
        Runs after migrations. No-ops unless ADMIN_BOOTSTRAP_USER +
        ADMIN_BOOTSTRAP_PASSWORD are set AND (users table empty OR
        ADMIN_BOOTSTRAP_RESET=true).
  - services/mam-api/src/routes/audit.js
        Admin-only GET /audit (paginated, filter by event_type /
        actor / target / date) and GET /audit/event-types.
  - services/web-ui/public/modal-account-settings.jsx
        Profile + Password tabs. Triggered by sidebar user button.

Files rewritten:
  - services/mam-api/src/routes/auth.js
        - POST /login: regenerate(), no manual save(); audit success/
          fail/lockout; updates last_login_at + failed_attempts.
        - POST /logout: destroys session, audits logout.
        - GET /me: returns is_client + last_login_at. Synthetic admin
          when AUTH_ENABLED=false.
        - GET /setup-status: drives login.html UI state.
        - POST /setup: blocked once any user exists; password policy.
        - POST /password: self-service. Requires current pw, runs
          policy, audits, invalidates other sessions implicitly via
          users.js if changed by admin.
        - PATCH /me: self-service display_name update.
  - services/mam-api/src/routes/users.js
        - is_client field in create/update/list/get.
        - Guardrails: cannot delete or demote last admin, cannot
          delete self, admins cannot be flagged is_client.
        - Password change invalidates all sessions for that user
          (DELETE FROM sessions WHERE sess->>'userId' = id).
        - Audit on every mutation.
        - Password policy enforced.
  - services/mam-api/src/middleware/auth.js
        - requireAuth now exposes req.user.is_client.
        - New requireRole(["admin","editor"], { rejectClients: true })
          helper. Applied to cluster, sdk, capture routes (infra).
        - Synthetic user when AUTH_ENABLED=false has is_client=false.
  - services/mam-api/src/index.js
        - Loads bootstrap admin after migrations.
        - Wires /api/v1/audit.
        - Cleans up an earlier comment block.
  - services/web-ui/public/login.html
        - Password hint added next to setup-mode password field.
  - services/web-ui/public/shell.jsx
        - Sidebar user footer is a button that opens AccountSettings.
        - CLIENT badge next to role when is_client=true.
        - Nav filters: clients lose ingest tree + jobs + editor;
          viewers lose ingest + editor; only admins see the Admin
          section. Power button hidden when synthetic user.
  - services/web-ui/public/screens-admin.jsx
        - Users table: new Client column with inline toggle.
        - InviteUserModal: Client checkbox + password hint, gated
          off when role=admin.
        - Last login column replaces Created in primary view.
        - CSV export includes client + last_login.
  - services/web-ui/public/data.jsx
        - ZAMPP_DATA.ME carries is_client + display_name.
  - services/web-ui/public/index.html
        - Loads dist/modal-account-settings.js.
  - services/web-ui/public/styles-rest.css
        - .user-row grid widened to 6 columns.
  - docker-compose.yml
        - Plumbs SESSION_COOKIE_SECURE + ADMIN_BOOTSTRAP_* env vars.

Deploy:
  cd /opt/wild-dragon
  git pull origin main
  # In .env:
  #   AUTH_ENABLED=true
  #   SESSION_SECRET=<openssl rand -hex 48>
  #   ADMIN_BOOTSTRAP_USER=admin
  #   ADMIN_BOOTSTRAP_PASSWORD=<strong>
  docker compose build mam-api web-ui
  docker compose up -d --force-recreate --no-deps mam-api web-ui
2026-05-27 03:21:16 +00:00
a48e1d9dd7 dashboard: rebuild as control-room status board (on air / up next / attention / work) 2026-05-26 23:10:23 -04:00
opencode
d1f9557dd1 auth: park login flow — circle back
Auth work is parked until after ship. While AUTH_ENABLED=false:
  - login.html now auto-redirects to / on load (no one should ever see
    the login screen while auth is off; it was confusing).
  - sidebar power button is hidden entirely when /auth/me returns a
    synthetic user, so there's no broken-feeling no-op control.
  - Removed connect-pg-simple createTableIfMissing flag in case
    v9.0.1's handling of that option was responsible for the recent
    boot 502 (the schema is created by migration 021 anyway).

The /auth/login + session.regenerate() + cookie fix from c34a721
stays in place — when we re-enable auth it'll work end-to-end. The
sessions table from migration 021 stays. Operator action to restore
auth later: set AUTH_ENABLED=true + SESSION_SECRET=<random> in the
mam-api environment and restart.
2026-05-27 03:04:37 +00:00
34bf1c7b7f fix: remove gradient text from launcher wordmark and token counter (design ban) 2026-05-26 23:02:06 -04:00
opencode
e71c330bdd fix(auth): remove manual session.save() — was suppressing Set-Cookie header
Login was returning 200 + correct user JSON + writing a row to the
sessions table, but emitting zero Set-Cookie headers. Root cause:

  session.regenerate() → set fields → session.save() → res.json()

Calling session.save() manually writes the store but bypasses
express-session's res.end() hook, which is the only path that adds
the Set-Cookie header to the response. The cookie was never sent to
the browser even though the session existed server-side — hence the
redirect loop.

Fix: remove the manual save(). Set the session fields and call
res.json() directly inside regenerate()'s callback; express-session
handles store write + Set-Cookie automatically on res.end().
2026-05-27 02:59:22 +00:00
5de1e3dc3d dashboard: add dense stat cards, cluster bars, job rows, sparkline fixes 2026-05-26 22:58:23 -04:00
e5e0656a6a dashboard: redesign stat cards, compress header, improve density 2026-05-26 22:54:45 -04:00
opencode
65684aa577 fix(auth): ensure sessions table exists + log session.save errors
The redirect loop after successful login was almost certainly the
`sessions` table never being created. `schema.sql` defines it but
only runs on first-init via the postgres entrypoint; instances
bootstrapped via mam-api's own migration loop never got the table.
express-session's `req.session.save()` then failed silently and the
cookie pointed at a sid that wasn't in the store — every subsequent
request looked like a brand-new visitor.

  - New migration 021-ensure-sessions-table.sql (idempotent).
  - connect-pg-simple now configured with `createTableIfMissing: true`
    as belt-and-braces.
  - `POST /auth/login` now explicitly waits for session.save() and
    surfaces both regenerate() and save() errors instead of treating
    them as 'success'. Logs sid + req.secure + req.protocol so we can
    confirm trust-proxy is doing the right thing behind NPM.
2026-05-27 02:54:25 +00:00
opencode
cfcbec0c85 fix(auth): make AUTH_ENABLED=true workable end-to-end
Three concrete issues kept the login flow broken on dragonflight.live:

1. mam-api trusted no proxy headers, so behind nginx/Cloudflare the
   session cookie's `secure` flag and the rate-limiter's IP keying
   both saw the wrong values. Now sets `app.set('trust proxy', 1)`.

2. Session config was tied to NODE_ENV and lacked sameSite/name. Now:
   - SESSION_COOKIE_SECURE env (default: true when AUTH_ENABLED) so a
     site behind HTTPS gets Secure cookies regardless of NODE_ENV.
   - `sameSite: 'lax'` for predictable post-login redirects.
   - Renamed to `df.sid` so it's obvious in DevTools.
   - `rolling: true` extends the 7-day TTL on active use.
   - SESSION_SECRET is now required when AUTH_ENABLED=true; the
     server refuses to start with a dev default in prod.

3. login.html silently showed the sign-in panel even when no users
   exist or auth is off:
   - New GET /auth/setup-status reports {needs_setup, user_count,
     auth_enabled}.
   - login.html calls it on load and auto-flips into setup mode when
     needs_setup is true, or shows an explicit "auth is off" flash
     when auth_enabled is false (the previous symptom: logout button
     did nothing because /auth/me returned a synthetic admin no matter
     what).
   - Added a `.flash.info` style for the new neutral notice.

4. Sidebar logout used to call /auth/logout then `window.location
   .reload()`. With auth off that reload landed back on the synthetic-
   admin app and looked like nothing happened. It now redirects to
   /login.html in all states so the operator sees feedback (and the
   server-side messaging about auth being off) instead of a no-op.

Deploy notes for zampp1:
  - Set AUTH_ENABLED=true and a random SESSION_SECRET in the
    mam-api environment (e.g. /opt/wild-dragon/.env).
  - Restart mam-api.
  - First load of /login.html will auto-route to the setup form so
    you can create the first admin.
2026-05-27 02:47:09 +00:00
opencode
a86c1c72f9 fix(player): stitch S3 ranges around RustFS empty-body bug (#143)
RustFS returns empty bodies for ranged GETs whose start offset is past
~5.9 MB on single-file proxy MP4s. HEAD reports correct size, full GET
(`bytes=0-`) works, but `bytes=8179166-` comes back 206 + correct
Content-Range header with zero bytes. Confirmed via direct S3 probe
against broadcastmgmt.cloud/dragonmam (see scratch tests).

Workaround in mam-api `GET /api/v1/assets/:id/video` until the proxy
worker emits HLS (planned v1.2.1):

  - HEAD the object first to learn total size (also gives ETag /
    Last-Modified for conditional requests).
  - No-Range / unparseable-Range / pre-EOF requests \u2192 plain pipe.
  - Parsed `bytes=N-M` requests below RUSTFS_RANGE_SAFE_START
    (default 5_500_000) \u2192 direct ranged GET, RustFS handles fine.
  - Anything reaching into the broken zone \u2192 stream from offset 0,
    drop bytes below start, stop at end. Memory stays flat; extra
    bandwidth = (end+1 - requested-size) per seek.
  - Genuinely out-of-range \u2192 416 with Cache-Control: no-store so the
    browser doesn't poison its cache.

Also stashes (not yet wired up) the HLS pieces we'll need for the
follow-up: `segmentToHls` ffmpeg helper + `uploadDirectoryToS3`
worker s3 helper. Harmless additions; not referenced by any code path
yet.

Confirmed against the affected asset (a72aaa03-...): bytes=0-100k +
50% +100k native pass-through; 70% +100k and near-EOF previously hung
the browser, now stream correctly via the stitched path.

Refs #143.
2026-05-27 02:38:42 +00:00
opencode
04ce096e67 chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
  validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
  production React UMD instead of dev builds + in-browser Babel (#139,
  #122)
- Search wrapper gets role=search; global search input gets aria-label,
  role=combobox, aria-controls/aria-expanded/aria-activedescendant
  wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
  mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
  flex container — switched to flex:1 + min-height:0 (#131, #132,
  editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
  the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
  data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
  window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
  uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)

Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
  Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
  of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
  tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
  transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
  detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
  tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
  create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
  private + loopback hosts for non-admins, denies common service
  ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
  rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
  survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
  server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
  (ampp_sync_status / attempts / next_attempt_at + scheduler retry
  loop with exponential backoff) (#77)

Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)

Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
  Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
  migration tool to v1.3
2026-05-27 02:06:14 +00:00
64d739b40d feat(admin): unified Storage settings page with mount/bucket health diagnostics
- Collapses S3 + Growing-files nav into single 'Storage' section
- Adds GET /api/v1/storage/overview with fs/df probes + HeadBucket check
- MountHealthStrip shows green/red pills, free space, S3 latency
- Reuses existing S3SettingsCard + GrowingSettingsCard below health strip
2026-05-26 22:45:50 +00:00
opencode
1535bbaefa fix(web-ui): load js/bmd-card.js in index.html
The BMD card SVG renderer (window.BMDCards) was created in an earlier
session but never wired into index.html, so the new video-presence
indicator from a44d8bd was silently bailing at the !window.BMDCards
guard.  Loading it alongside the other helpers in /js/.
2026-05-26 22:16:19 +00:00
opencode
a44d8bd7c9 feat(admin): live video-presence indicators on cluster DeckLink ports
Adds per-port video signal state to the admin Cluster panel:

- New GET /cluster/devices/blackmagic/signal endpoint joins recorders by
  node_id+device_index and queries each active capture container's
  /capture/status (local: http://recorder-<id>:3001, remote: api_url/
  sidecar/<container_id>/status).  Returns receiving/connecting/lost/
  error/idle/no-recorder per port plus framesReceived and currentFps.

- bmd-card.js render() now accepts portSignals (Map or object) and
  overlays a colored dot on each BNC connector with pulse animation
  for receiving/connecting states.

- screens-admin.jsx Cluster panel polls the new endpoint every 5s,
  feeds the signal map into both the port chips (now show
  RECEIVING/CONNECTING/LOST + fps) and the BMD SVG card diagram
  rendered below them via a new BmdCardPanel component.

- styles-fixes.css adds bmd-card-* styles for the SVG diagram and
  bmd-port-signal --pulse animation.
2026-05-26 22:02:38 +00:00
d257a19d9d fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
mam-api /video endpoint:
- S3 InvalidRange (httpStatusCode 416) was being caught and returned as 500
  via next(err), which the video element treats as a fatal load error and
  freezes the player. Now we catch the specific 416 case, do a no-range
  HEAD-equivalent to learn the real file size, and return proper 416 with
  Content-Range: bytes */<total> so the browser can recover.

screens-asset.jsx — player health + buffer visualization:
- New states: playerState ('idle'|'loading'|'playing'|'paused'|'seeking'|
  'waiting'|'stalled'|'error'), playerError, buffered (array of {start,end}
  ms from HTMLMediaElement.buffered), stallStart, stallElapsedMs
- Wired video element events: onProgress, onWaiting, onStalled, onPlaying,
  onCanPlay, onCanPlayThrough, onSeeking, onSeeked, onError
- onError captures MediaError code+message into a console.error and the
  on-screen badge so freeze causes are now visible
- Status badge overlay (top-right of player): shows SEEKING / BUFFERING /
  STALLED / ERROR + elapsed seconds since the stall began
- PlaybackBar renders buffered ranges as translucent grey segments so you
  can see what the browser has loaded vs. what's still pending — makes
  seek-related freezes immediately obvious
2026-05-26 20:25:40 +00:00
f0f615688e release: add v1.1.0 ZXP artifact (Growing tab + visual system alignment) 2026-05-26 16:09:52 -04:00
a6f045b3d7 fix(node-agent): probe GPU via Docker API async at startup, cache result
Replaced sync execFileSync('docker') approach (no docker CLI in container)
with async Docker socket HTTP API calls:
- POST /containers/create with nvidia runtime + DeviceRequests
- POST /containers/:id/start
- Poll inspect until not running
- GET /containers/:id/logs, strip 8-byte frame headers, parse csv

probeGpusViaSmi() runs once at startup before the first heartbeat.
Result cached in _gpuCache; detectHardware() reads cache on every heartbeat.
Falls back to /dev/nvidia* scan if probe fails or runtime unavailable.
2026-05-26 18:28:03 +00:00
558c18e417 fix(node-agent): detect GPUs via docker run --gpus all ubuntu:22.04
nsenter approach failed (requires SYS_ADMIN in container).
nvidia-smi bind-mount failed (Alpine vs Ubuntu glibc incompatibility).

Working solution: spawn 'docker run --rm --gpus all ubuntu:22.04 nvidia-smi'
via the Docker socket. The NVIDIA Container Runtime injects nvidia-smi and
driver libs into any container with --gpus all, regardless of the base image.
ubuntu:22.04 is already cached on GPU nodes.

Result: GPU reported with name, memory_mb, driver_version — shows as BOUND
in the cluster UI.
2026-05-26 18:25:44 +00:00
5ff507b81b fix(node-agent): use nsenter to run nvidia-smi in host mount namespace
nvidia-smi bind-mount failed due to Alpine vs Ubuntu glibc incompatibility.
Fix: nsenter --mount=/proc/1/ns/mnt -- nvidia-smi runs in the host's mount
namespace where glibc and all NVIDIA driver libs are present.

Requires pid: host in docker-compose.worker.yml (already has network: host).
nsenter is provided by util-linux in Alpine — already in the image.

Falls back to direct nvidia-smi call (for glibc-based containers), then
to /dev/nvidia* file scan if all attempts fail.
2026-05-26 18:22:11 +00:00
726343db96 fix(node-agent): bind nvidia-smi for full GPU info (name, VRAM, driver)
index.js:
- detectGpusViaSmi(): runs nvidia-smi --query-gpu=index,name,memory.total,
  driver_version and parses the output into structured GPU objects with
  name, memory_mb, driver, device — the same fields the cluster UI uses
  to determine BOUND status
- Falls back to /dev/nvidia* file scan if nvidia-smi isn't available

docker-compose.worker.yml:
- Bind-mount /usr/bin/nvidia-smi and libnvidia-ml.so.1 from host into
  node-agent container (read-only). These are the minimum binaries needed
  for nvidia-smi to execute inside the container.
- Mounts are optional — Docker ignores them silently if paths don't exist
  (e.g. on nodes without NVIDIA hardware)
2026-05-26 18:19:23 +00:00
55ff2e717f feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
  (bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
  device paths

Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
  with online/offline color coding

Stat row: adds Capture ports total count card

Topology SVG: shows GPU count and BMD port count under each node label

Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 18:06:30 +00:00
e4d4c00f52 feat(proxy): VBR 500k-1M encoding for proxy generation
executor.js:
- transcodeVideo() now accepts videoMinRate, videoMaxRate, videoBufSize
- When set, passes -minrate/-maxrate/-bufsize to FFmpeg for ABR/VBR mode
- libx264 operates with per-scene quality variation within the envelope

proxy.js:
- Target average: 750k (gpu_bitrate_mbps=0.75)
- Min: 375k (50% of target), Max: 998k (~133%), Buffer: 2× max
- Gives effective range of ~500k-1M depending on scene complexity
- Log now shows VBR min-max-avg
- GPU fallback also passes VBR params
- Default videoBitrate changed from 10M to 750k in executor.js
2026-05-26 17:44:18 +00:00
03aa7a0673 fix(video): revert S3 redirect — RustFS rejects range+Origin; proxy with cache headers
S3 at broadcastmgmt.cloud (RustFS/openresty) returns 403 on range
requests that include an Origin header on presigned URLs. The HMAC
signature only covers 'host' in X-Amz-SignedHeaders, so the browser's
cross-origin Origin header breaks signature validation.

Reverted: /stream and /video no longer redirect to signed S3 URLs.

Fixed: /video now pipes through Node with:
  Cache-Control: private, max-age=3600
  ETag and Last-Modified forwarded from S3

This means the browser caches video segments for 1h. On seek the
browser checks its cache first — only uncached byte ranges hit the
server. Combined with the 1.5Mbps proxy (was 4Mbps), seeks should
be responsive for clips under ~10 minutes.
2026-05-26 17:40:02 +00:00
37247fdfea fix(video): direct S3 signed URL for streaming + proxy bitrate 1.5Mbps
- GET /assets/:id/stream now returns a signed S3 URL directly (4h TTL)
  instead of pointing to the /video pipe endpoint. Browser streams
  directly from S3 — no Node.js bottleneck, S3 handles range requests
  natively for smooth seeking.

- GET /assets/:id/video now redirects (302) to a signed S3 URL.
  Belt-and-suspenders: any code still calling /video gets redirected.

- proxy.js: default bitrate changed from 10Mbps to 1.5Mbps, audio
  default from 192kbps to 128kbps. DB settings already updated to
  1.5Mbps. Cuts proxy file size ~6x for the same quality content.
  Existing proxies need re-generation at new bitrate.
2026-05-26 16:57:37 +00:00
a03dd36f11 fix(premiere-plugin): hide growing-count badge until count > 0
The badge initially showed '0' before any poll completed. Toggling
display via JS expects an initial display:none so the badge does not
flash in the tab nav on first connect.
2026-05-26 16:40:47 +00:00
a03c85f08a feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze
Root causes found:
1. Scheduler crashing every 15s: assets table has no error_message column.
   Fix: remove error_message from UPDATE in scheduler.js (#66 regression).

2. Clip freezing: client-side filmstrip seek loop runs on main thread,
   seeks same proxy the player is streaming → both stall → freeze.
   Fix: replace browser seek loop entirely with server-side FFmpeg worker.

3. No dedicated filmstrip worker: filmstrip was never pre-built server-side.

Changes:
- services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql
  Add filmstrip_s3_key TEXT column to assets table

- services/worker/src/workers/filmstrip.js (new)
  BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract
  28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON
  array to S3 at filmstrips/<assetId>.json, stores key in DB

- services/worker/src/workers/thumbnail.js
  Queue filmstrip job automatically after thumbnail completes

- services/worker/src/index.js
  Register filmstrip worker (concurrency=2), export filmstripQueue
  singleton, close it on SIGTERM

- services/mam-api/src/routes/assets.js
  - filmstripQueue added
  - POST /reprocess?type=filmstrip now supported
  - GET /:id/filmstrip returns signed S3 URL for JSON frames

- services/mam-api/src/routes/jobs.js
  filmstrip queue visible in Jobs UI

- services/web-ui/public/screens-asset.jsx
  Replace browser seek loop with fetch of /assets/:id/filmstrip
  → fetch S3 JSON → render frames. Zero browser-side video seeking.
  Right-click and Files tab re-generate via API endpoint.
2026-05-26 16:39:44 +00:00
564cf6b18f fix: thumbnail img uses signed URL from API; switch transcoding to CPU libx264
- FilesTab: fetch /assets/:id/thumbnail (returns signed S3 URL JSON),
  display the resolved URL in <img> instead of pointing directly at the
  endpoint which returns JSON not image bytes
- Transcoding: settings updated on ZAMPP1 to gpu_transcode_enabled=false,
  codec=libx264 — NVENC not available in worker container (no GPU passthrough)
  The proxy worker already has a CPU fallback but this prevents the
  unnecessary failed GPU attempt on every job
2026-05-26 16:27:27 +00:00
89645f160e fix(filmstrip): seeked event never fires at t=0; add per-frame seek timeout
Two bugs:

1. Frame 0 sets currentTime=0 but probe starts at t=0 after onloadedmetadata,
   so 'seeked' never fires (no position change). Promise hangs until the 15s
   global timeout kills the whole build. Fix: when currentTime is already at
   target (within 0.05s), call done() immediately without waiting for seeked.

2. Seeks into unbuffered regions of large MP4s can stall indefinitely.
   Fix: 3s per-frame timeout captures the current decoded frame and moves on,
   so a slow/stalled seek doesn't block the remaining 27 frames.
2026-05-26 16:21:00 +00:00
e9eeb84c5f fix(files-tab): remove inline video preview from proxy row 2026-05-26 16:10:04 +00:00
4f98f2b773 feat(asset): filmstrip right-click menu + Files tab
Filmstrip:
- Right-click on the filmstrip opens a context menu with
  'Re-generate filmstrip' and 'Re-generate proxy'
- filmstripKey state forces the build effect to re-run on demand
  without waiting for a streamUrl/totalMs change
- Context menu dismisses on click, contextmenu, and scroll

Files tab (replaces empty Versions tab):
- Proxy: status badge, S3 key path, inline video preview, re-generate button
- Hi-res master: status badge and S3 key path
- Thumbnail: status badge, S3 key path, inline thumbnail image, re-generate button
- Filmstrip: status badge, frame count, scrollable strip of first 14 frames,
  re-generate button (disabled while building)
2026-05-26 16:07:33 +00:00
b3c61134fc fix(filmstrip): remove crossOrigin=anonymous from probe video element
The /video endpoint requires session auth (requireAuth middleware).
crossOrigin='anonymous' strips cookies from the request → 401 → video
never loads → 15s timeout → filmstrip stays empty for all clips.

Same-origin video does not need crossOrigin for canvas drawImage — the
taint restriction only applies to cross-origin resources.
2026-05-26 16:03:26 +00:00
5edb4df35a fix(assets): missing closing }); on POST / route (syntax error) 2026-05-26 15:05:50 +00:00
07f8ffa6d5 feat: editor coming-soon bumper + embedded Premiere panel downloads
- Editor: overlay Coming Soon screen over NLE timeline (code preserved,
  bumper sits at z-index 100 with backdrop blur). Links to download
  ZXP and Windows installer directly from the bumper.

- Settings → Capture SDKs: new Premiere Panel section lists v1.0.0
  and v1.0.1 with ZXP + Windows Installer download buttons.
  Both releases embedded as static files in web-ui under /downloads/.

- nginx: /downloads/ location serves files as Content-Disposition
  attachment with 24h cache.

Files added:
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.0.zxp
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.0-windows-setup.exe
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.1.zxp
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe
2026-05-26 14:34:28 +00:00
8e0e94de3d fix: close all 24 open issues (#40–#94)
Bug fixes:
- #91: dockerApi() 10s socket timeout (Docker daemon hang)
- #77: await syncToAmpp() with .catch() — no longer fire-and-forget
- #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status
- #73: BullMQ orphan job cleanup on hard asset delete
- #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows
- #66: scheduler tick marks stale live assets (>2h) as error
- #63: migration 017 — partial unique index prevents concurrent live asset overwrite
- #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET
- #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy)
- #40: already fixed (All projects clears openProject)
- #64: already fixed (sourceType/needsProxy handled)
- #90: GET /jobs now includes DB jobs table (trim jobs visible in UI)
- #74: nginx Content-Type header preserved; multer 500MB file size limit
- #68: GET /upload returns in-progress ingesting assets
- #58: /stream and /video endpoints fall back to original file for all video types
- #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval
- #52: thumb-status and thumb-duration moved inside position:relative wrapper
- #50: ProjectCard gets onContextMenu handler with rename/delete menu
- #49: project context menu dismisses on contextmenu + scroll events

Features:
- #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset
  Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons

UI:
- Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher,
  and login — white logo pops on dark UI; inline style removed from login.html
2026-05-26 14:10:44 +00:00
602370be26 fix(worker): use bracket notation for @_ XML attribute property access
track?.@_currentExplodedTrackIndex is invalid JS syntax — @ is not a
valid identifier character. Replaced with track?.['@_currentExplodedTrackIndex']
so the worker process no longer crashes on startup.
2026-05-26 09:41:33 -04:00
3ebe5d6639 fix(users): invalidate sessions on password change (issue #94 bug 5) 2026-05-26 07:39:14 -04:00
6ee284e3f6 fix(auth): add brute-force rate limiting on POST /login (issue #94 bug 6) 2026-05-26 07:39:14 -04:00
bacdb9f49c fix(worker): close all Queue singletons + promotion intervals on SIGTERM (issue #94 bugs 4, 7, 10) 2026-05-26 07:38:08 -04:00
6eb98d866b fix(youtube-import): export proxyQueue singleton for clean SIGTERM shutdown (issue #94 bug 7) 2026-05-26 07:38:07 -04:00
cb0efdfdae fix(proxy): export thumbnailQueue singleton for clean SIGTERM shutdown (issue #94 bug 7) 2026-05-26 07:36:54 -04:00
a6c9529c50 fix(promotion): singleton proxyQueue; await promote(); return shutdown fn (issue #94 bugs 3, 4) 2026-05-26 07:36:08 -04:00
e289554e44 fix(trim): update jobs table status on complete/fail (issue #94 bug 2) 2026-05-26 07:35:28 -04:00
bec64e668d fix(conform): mark asset error on failure; scope asset lookup by project_id (issue #94 bugs 1, 9) 2026-05-26 07:35:13 -04:00
a0b7b42524 feat(db): add growing_retention_days setting (migration 015) 2026-05-26 07:27:23 -04:00
09e2987c14 feat(db): add growing_enabled column to recorders (migration 014) 2026-05-26 07:27:17 -04:00
ee0c2a12de Use HTML img tag for screenshot 2026-05-26 01:47:28 +00:00
782ff5b7b6 Try relative path for screenshot 2026-05-26 01:38:14 +00:00
a20b0d3fe3 Use absolute URL for screenshot in README 2026-05-26 01:36:24 +00:00
f420584e1a Move feature overview to README with screenshot 2026-05-26 01:34:18 +00:00
0d85899627 Add home dashboard screenshot 2026-05-26 01:28:44 +00:00
be9ae32a3b Add feature overview documentation 2026-05-25 21:24:48 -04:00
7fc502513e fix(#78): GET /assets — include_archived filter now independent of status filter 2026-05-25 19:29:23 -04:00
2e1ac72585 fix(#79): proxy worker respects live/ingesting status on error 2026-05-25 18:36:39 -04:00
fba671ad40 fix(#53): show error banner with retry when loadData() rejects 2026-05-25 17:42:39 -04:00
33c82cab1a fix(#38,#54): fix apiFetch Content-Type header order; fix normalizeAsset seed hash 2026-05-25 17:42:06 -04:00
75c23448b4 fix(#65): GET /schedules returns 400 for unknown status query param 2026-05-25 17:40:58 -04:00
548c2ab8a4 fix(#72,#59): remove nginx /health stub — API endpoint proxies through correctly 2026-05-25 17:38:40 -04:00
15b4d45375 fix(#48): add type:module to mam-api package.json 2026-05-25 17:37:56 -04:00
4c8c3b72bb fix filmstrip: append probe to DOM, fix race condition, add 15s timeout 2026-05-25 11:26:02 -04:00
7ea3a235da fix: filmstrip — fetch video chunk as Blob URL, append probe to DOM, add timeout 2026-05-25 11:21:38 -04:00
0481fb3ecf fix: filmstrip probe video — append to DOM, fix src/handler race, add timeout 2026-05-25 11:14:55 -04:00
37c406bf4d fix filmstrip: use hls.js for HLS stream frame capture, not only direct streams 2026-05-25 09:30:40 -04:00
b345f5f6a4 fix editor: use assetsRef to avoid stale closure in handleExternalDrop 2026-05-25 09:29:05 -04:00
OpenCode
87f14b7c71 Fix asset filmstrip and editor UX 2026-05-25 05:14:36 +00:00
c501d88c63 Auto refresh library after ingest 2026-05-25 01:13:19 -04:00
78539ec8b0 Fix editor timeline interactions 2026-05-25 01:10:45 -04:00
de895dd7f8 Fix library refresh behavior 2026-05-25 01:08:38 -04:00
3dad82d992 fix(editor): drag interactions, undo history, overflow clipping
Four critical fixes:
- Remove overflow:hidden on tlRef so Timeline.init's scroll survives re-renders
- Don't call _renderClips() inside mousedown (was destroying event target mid-drag)
- Use refs for undo history to eliminate stale closure in onClipsChanged callback
- Change .tl-clip-area overflow:hidden to overflow:visible so pointer events reach clip edges
2026-05-24 21:21:23 -04:00
4673efac6a fix(editor): setScale, hand pan, sort comparator, playhead sync, rename/delete, track selector 2026-05-24 21:03:12 -04:00
721f847b28 fix: remove openreel editor; fire df:assets-changed on upload/ingest complete 2026-05-24 20:36:04 -04:00
c36c732f47 fix: comment out editor service in docker-compose to unblock deployment 2026-05-24 18:58:22 -04:00
60e306d1db fix(hls): retry on playback failure with exponential backoff 2026-05-24 16:52:04 -04:00
ce31a45124 feat(editor): Phase 1 core NLE editor React SPA rewrite 2026-05-24 16:20:38 -04:00
7189df7957 docs: add NLE editor React polish plan (phases 1-3) 2026-05-24 14:53:56 -04:00
f21157f3c7 fix: refresh bin counts after asset move
Dispatch df:bins-changed custom event from onBinDrop and
AssetContextMenu.moveToBin so the bin rail counts update
immediately after moving an asset into a bin.
2026-05-24 14:50:22 -04:00
a5ab57d144 fix: add missing > to close bin rail div opening tag
Missing > after title attribute caused Babel parse error,
preventing Library component from being registered globally.
2026-05-24 14:46:36 -04:00
0ebc7ef777 fix: use window.RenameProjectModal via React.createElement
RenameProjectModal is exported to window from screens-projects.jsx,
so Library screen must reference it via window object and use
React.createElement instead of JSX syntax.
2026-05-24 14:30:22 -04:00
d94ed00312 fix: apiFetch headers spread, droppable highlight, project rename, color stability, orphaned api.js removal
- Fix apiFetch headers spread bug (custom headers overwrote Content-Type)
- Track per-bin hover state for droppable highlight
- Refresh project rail after rename from Library screen
- Use ID-hash for project colors instead of array index
- Remove orphaned js/api.js (563 lines, never loaded)
- 'All projects' rail item clears openProject filter
- Add project boundary guard to drag-and-drop bin moves
- Stabilize refreshAssets useCallback with empty deps
- 'Last 24h' filter now actually filters by created_at
2026-05-24 14:20:00 -04:00
af905cf936 fix: bin creation 500 error + add drag-and-drop + project rename
- Fix 500 error when creating bins: missing updated_at column on bins table
  (migration 013 adds the column, schema.sql updated)
- Add drag-and-drop support for moving asset cards/list rows onto bin rail items
  with visual droppable highlight
- Add right-click context menu on project rail items (Rename/Delete)
- Expose RenameProjectModal via window so Library screen can reuse it
- Bins context menu already existed — was hidden by the 500 error
2026-05-24 13:27:24 -04:00
c312991bac feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
  conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
  trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
  ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
  (accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
  presets table, and architecture overview
- #24 PR merge: verified mergeable

All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
77130ac769 feat(server): temp segments cleanup task
Hourly cron that deletes expired temp_segments from S3 and DB.
Implements issue #34. Registered alongside scheduler in index.js.
2026-05-24 12:43:08 -04:00
a016175fc8 feat(db): migration 012 — advanced features schema
Add 'trim' to job_type enum, create temp_segments table with
expiry/job/asset indexes, and add conform_source_sequence_id
to assets for lineage tracking.

Closes #33
2026-05-24 12:42:22 -04:00
543248b8c2 Merge remote-tracking branch 'origin/feat/premiere-installer' 2026-05-24 12:03:46 -04:00
eadafffb18 fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12
End-to-end debugging against a live Premiere Pro 2025 + auth-enabled mam-api
surfaced four real bugs that made v1.0.0 install cleanly but never load,
plus the missing auth flow. All four are fixed and the panel is verified
connected (status dot green, Reconnect button shown, project list populated).

  - manifest.xml: a comment in the <Resources> block contained "--" (inside
    "--enable-nodejs"/"--mixed-context"), which is illegal per the XML spec.
    CEP 12's strict parser logged
        ERROR XPATH Double hyphen within comment
    and skipped the panel entirely. Comment rewritten without double hyphens.

  - manifest.xml: lacked the Version="X.Y" attribute on <ExtensionManifest>
    and used a non-standard AbstractionLayers/empty <ExtensionList/>
    structure. CEP rejected it with
        Unsupported Manifest version ''
    Manifest rewritten to the standard CSXS 7.0 schema (ExtensionList +
    DispatchInfoList + RequiredRuntimeList), matching the working AMPP
    panel template.

  - main.js: re-declared `const csInterface = new CSInterface()` at top
    level even though CSInterface.js already declared the same binding.
    CEP 12 shares script-realm lexical scope across <script> tags, so the
    second const threw
        Identifier 'csInterface' has already been declared
    The throw fired before setupEventListeners(), so the Connect button's
    click handler was never attached. This is the root cause of the
    original "clicking Connect does nothing" symptom; everything else was
    secondary. Removed the duplicate declaration; main.js now uses the
    binding from CSInterface.js.

  - No auth support against AUTH_ENABLED=true servers. mam-api supports
    Bearer tokens (POST /api/v1/tokens), so added:
      • API token input field (password-masked) next to Server URL
      • localStorage persistence on every keystroke
      • window.fetch monkey-patch that injects
          Authorization: Bearer <token>
        on every request whose URL starts with the configured server.
        Signed S3 download URLs are NOT touched.

Drive-by fixes that came out of the same debugging pass:
  - Server URL input listener was 'change' (fires on blur); switched to
    'input' so typing-then-clicking-Connect immediately commits.
  - restoreSettings() now strips trailing slashes from the stored URL so
    older saved values like 'http://host/' stop producing //api/v1 404s.
  - CSS selector `input[type="text"].server-url` didn't match the new
    password input → the token field was unstyled and effectively invisible.
    Generalized to `input.server-url`; restructured the connection bar into
    `.connection-controls--stacked` (flex column) of two `.server-input-row`
    rows so two input fields fit cleanly.
  - Build scripts now parse ExtensionBundleVersion from both element form
    (<ExtensionBundleVersion>X</...>) and attribute form
    (ExtensionBundleVersion="X"), since the manifest rewrite switched
    schemas.

Version bumped 1.0.0 → 1.0.1. New artifacts committed at
services/premiere-plugin/build/releases/v1.0.1/ (.exe 2 MB, .zxp 35 KB).
v1.0.0 left in place so editors who downloaded it can verify they're on
the broken version.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:24:10 -04:00
a6d789279c Merge pull request 'fix(jobs): real cancel for active jobs + multi-threaded thumbnail worker' (#29) from fix/jobs-cancel-and-concurrency into main 2026-05-23 17:23:52 -04:00
91325a4267 fix(jobs): real cancel for active jobs + multi-threaded thumbnail worker
DELETE /jobs/:id was throwing "404 not found" when the operator tried to
cancel a running job. BullMQ refuses job.remove() while a job is in the
active state; the route caught that error and fell through to the
404 branch, which was misleading because the job actually exists — the
queue was just refusing to drop it from under the worker.

Fix:
- Detect 'active' state explicitly and call moveToFailed(err, '0', false)
  first. Token '0' bypasses the per-worker lock check (the operator-side
  cancel doesn't hold the worker lock). That transitions active -> failed
  and frees the queue's concurrency slot.
- If moveToFailed itself fails (lock owned by a live worker), fall back
  to job.discard() so at least the result is thrown away.
- If remove() then fails (stalled, broken state), drop the job's Redis
  key directly via queue.client. Last-resort obliteration.
- Stop swallowing getJob() errors — if Redis is sad, surface it via
  next(err) instead of returning a misleading 404.
- Return { cancelled: true } when the job was active, so the client
  can show "Cancelled" rather than "Removed" in any future toast.

While here: thumbnail jobs now run with concurrency 4 by default
(proxy 2, conform 1, import 1 unchanged). Every queue defaulted to
concurrency 1 before, so a single stalled job blocked the entire queue.
All three are overridable via PROXY_CONCURRENCY / THUMBNAIL_CONCURRENCY
/ CONFORM_CONCURRENCY env vars for nodes with more headroom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:23:07 -04:00
2b85fb49df Merge pull request 'fix(jobs): cancel running + delete failed jobs so the queue can be unstuck' (#28) from fix/jobs-cancel-stuck into main 2026-05-23 16:54:50 -04:00
eb6c723713 fix(jobs): cancel running + delete failed jobs to unstick the queue
The Jobs page only exposed a delete button for queued + done jobs, so a
stalled-active job (worker died holding a BullMQ concurrency slot) had
no way out from the UI. Operators were watching the queue back up
behind a single stuck thumbnail job with no kill switch.

- Running jobs now show a "Cancel" button (red text). Confirm copy
  spells out that the worker may run a few seconds longer in the
  background but the queue slot frees up immediately.
- Failed jobs now show the X icon for delete in addition to the
  existing Retry button.
- Both routes hit the same DELETE /jobs/:id endpoint; BullMQ's
  job.remove() works on any state including stalled-active.
- handleDelete takes an optional mode ('cancel' | 'delete') only to
  customise the confirm prompt and error toast wording.

Right-aligned the action cell so the Retry/Cancel/Delete buttons sit
flush right like the rest of the table's actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:54:05 -04:00
6322b61a04 Merge pull request 'chore(web-ui): delete legacy standalone HTML pages; SPA is the only entry' (#27) from chore/cleanup-legacy-html into main 2026-05-23 16:49:39 -04:00
ff2865b5d8 chore(web-ui): delete legacy standalone HTML pages; SPA is the only entry
Before this commit /public had two parallel UIs: the React SPA (index.html
+ screens-*.jsx) and a stack of pre-SPA standalone pages (home.html,
recorders.html, jobs.html, ...). The SPA replaces every standalone page,
nothing in the .jsx tree links to them, and the only outside references
were login.html redirecting to home.html and the nginx fallback pointing
at home.html.

Delete 16 standalone pages (~9.2k lines of dead markup, ~430KB on disk):
  _primitives-smoke.html  api-tokens.html  capture.html  cluster.html
  containers.html         edit.html        editor.html   home.html
  jobs.html               player.html      projects.html recorders.html
  settings.html           tokens.html      upload.html   users.html

Keep:
  index.html  — the React SPA shell
  login.html  — the sign-in / setup screen

Wire the redirects to the SPA:
- login.html post-signin: home.html -> /
- nginx try_files fallback: /home.html -> /index.html

After this, sign-in lands the operator on the real React app instead of
the stale 2025-era home page. The Editor screen continues to embed the
separate editor service via the /editor/ nginx proxy (unaffected).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:48:38 -04:00
53049d1c4d Merge pull request 'feat(auth): bounce to /login.html on 401 so AUTH_ENABLED=true gives a real login' (#26) from feat/auth-login-redirect into main 2026-05-23 16:41:25 -04:00
bec4bfaf31 feat(auth): bounce to /login.html on any 401 from the api wrapper
apiFetch now redirects to /login.html when the server returns 401, so
flipping AUTH_ENABLED=true on mam-api gives the user the login screen
instead of a half-loaded app that silently failed to fetch /auth/me.

While AUTH_ENABLED=false the server's /auth/me still returns a synthetic
200 user, so this branch is dormant — safe to deploy ahead of the env
flip on the server. After the flip the operator visits /login.html
(directly or via auto-redirect), runs the "Create admin account" flow
once, and lands back on the SPA with a real session.

Guards against a redirect loop if login.html itself somehow lands here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:40:45 -04:00
0537378d82 Merge pull request 'feat(schedule): right-click menu + drag-to-resize on EPG event blocks' (#25) from feat/epg-resize-and-ctxmenu into main 2026-05-23 16:34:48 -04:00
3ffffd5b32 feat(schedule): right-click menu + drag-to-resize on EPG event blocks
Right-click any event block to open a context menu (Edit, Cancel,
Copy schedule ID, Delete) — actions per status mirror the List view so
the two surfaces stay in lockstep. Menu is viewport-clamped and
dismisses on outside click / scroll, same pattern as the asset menu in
the Library.

Drag-to-resize works for pending schedules only (the schedules PUT
rejects edits to running rows, and terminal statuses are read-only):
- Drag the left edge to move the start time
- Drag the right edge to move the end time
- Drag the body to shift the whole block in time
All gestures snap to 15-minute increments to match the new-schedule
click snap. Minimum duration is clamped to 5 minutes; the block clamps
to the visible day on both edges. While dragging the title shows the
preview range ("Start time → end time") and the block lifts with a
project-tinted shadow.

A short pointer click (< 4px travel) still opens the edit modal — the
click and drag share the same pointerdown so the operator never has
to know which gesture they made first.

Implementation: replaces the <button> block with a <div> hosting three
zones (left handle / body / right handle). Pointer events with
setPointerCapture so drags survive losing the cursor over the block,
and pointerup demotes back to click if travel was below threshold.
Optimistic local update on resize, PUT /schedules/:id with just the
two changed time fields, refetch to reconcile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
d1fcfcc8fd chore(premiere-plugin): commit v1.0.0 installer artifacts
Drops dragonflight-premiere-panel-1.0.0-windows-setup.exe (2 MB) and
dragonflight-premiere-panel-1.0.0.zxp (35 KB) at
services/premiere-plugin/build/releases/v1.0.0/ so the binaries have
stable URLs on the forge without needing a separate release artifact
flow.

Heads-up: this commits 2 MB of binary into git history. Future bumps
should use Forgejo Releases (release assets are external to git history
and easy to delete) rather than another commit under releases/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:30:39 -04:00
97f08b32de ui(jobs): widen Time + Progress columns, narrow Node + Priority
Time was clipping the full "done May 22 · 2:23 PM · 6h ago" string on
terminal-state rows; Progress's bar felt cramped next to the percent.
Node carries only "primary" / "—" so it can shrink, and Priority's
"normal" / "high" badge doesn't need 80px either. Net widening absorbed
by the flexible Asset column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:27:13 -04:00
9a6ae3b786 fix(jobs): backfill asset_name from DB so non-YouTube jobs show their asset
The Jobs screen only displayed an asset name when the enqueueing code
stuffed assetName into the BullMQ job data. YouTube imports did that;
upload-triggered proxy/thumbnail jobs didn't — so everything except
YouTube showed em dashes in the Asset column.

Fix it centrally: after we collect jobs from BullMQ, look up names
in one bulk SELECT against the assets table for any job that has an
assetId but no asset_name. Applies to /jobs, /jobs/:id, and the SSE
events stream. Lookup failures fall through silently rather than
500-ing the whole list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:23:29 -04:00
8aece9cbc4 fix(premiere-plugin): make build pipeline portable to Windows PowerShell 5.1
End-to-end verification on a fresh Windows machine surfaced three issues:

1. pwsh isn't installed by default — Windows ships powershell.exe (5.1).
   Switched all script invocations + docs from `pwsh` to `powershell`.
2. .NET's strict XML parser rejects manifest.xml because the <Resources>
   comment legally contains `--` (inside `--enable-nodejs`/`--mixed-context`
   CEF flag names). Switched build-installer.ps1 to regex extraction,
   matching what build-zxp.mjs already does.
3. winget installs Inno Setup 6 to %LOCALAPPDATA%\Programs by default, not
   Program Files (x86). Added the user-scope path to the ISCC.exe fallback
   list.

Verified: `powershell -File build-all.ps1` produces both artifacts —
dragonflight-premiere-panel-1.0.0.zxp (35 KB, signature valid)
dragonflight-premiere-panel-1.0.0-windows-setup.exe (2 MB).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:22:46 -04:00
5699cff4d0 Merge pull request 'feat(schedule): EPG stylesheet + impeccable context (PRODUCT / DESIGN.md)' (#23) from polish/schedule-epg-styling into main 2026-05-23 16:20:48 -04:00
5882c68217 feat(schedule): EPG stylesheet + impeccable context (PRODUCT/DESIGN.md)
The EPG JSX components in screens-ingest.jsx ship with the YouTube branch
but the matching stylesheet got lost during the parallel-branch shuffle.
This adds the missing .epg-* block to styles-rest.css and replaces the
dead .cal-* (month-calendar) rules left over from the previous design.

What the styles cover:
- .epg-page / .epg-toolbar — top-level flex layout + date nav row
- .epg-status — sticky "on air" strip with pulse halo on the live dot
- .epg / .epg-corner / .epg-gutter / .epg-canvas-head / .epg-canvas —
  the 2x2 sticky grid (top ruler + left gutter both sticky)
- .epg-ruler / .epg-ruler-tick — hour ticks
- .epg-row + .epg-block + .epg-block.live/.failed/.past — event blocks
  with project-color 4px inner bar (no side-stripes; impeccable ban)
- .epg-now / .epg-now-pip — vertical hot-red now-line with broadcast glow
- .epg-week + .epg-week-day — stacked 7-day sections for week view
- .epg-empty — recorder-less / loading empty state

Also adds PRODUCT.md and DESIGN.md so future design passes have the
context files the impeccable skill requires. Both drafted from the
existing codebase (tokens, screen patterns) rather than synthesised
from a prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:19:25 -04:00
0ff2625876 fix(premiere-plugin): remove broken Inno Setup [Code] heuristic
The Premiere-running check passed a Boolean to Exec's var ResultCode (Integer)
parameter — Pascal type error. The block also did nothing useful: it only
checked but never warned or prompted. Drop it. {InstallDelete] of the legacy
folder still works through the Tasks checkbox + Check function.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:18:19 -04:00
c0d1251c1f fix(tokens): add missing showCalc state — page was crashing on render
The Tokens screen referenced showCalc / setShowCalc in the Cost calculator
button and modal but never declared the state hook, so the component
threw ReferenceError on mount and rendered blank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:18:19 -04:00
9266a1d471 fix(premiere-plugin): correct zxp-sign-cmd version + promise API; commit generated signing cert
The initial pass referenced zxp-sign-cmd@0.2.2 which never shipped (latest
is 2.0.0) and used the v1.x callback API. v2 is promise-based — rewrote
build-zxp.mjs accordingly.

Also commits the freshly-generated self-signed cert + passphrase from the
first local build run. From now on every build reuses these so Adobe's
ZXP signature-continuity rule is satisfied across versions.

Verified end-to-end: `npm install && node build-zxp.mjs` produces
dist/dragonflight-premiere-panel-1.0.0.zxp (34.7 KB), signature verifies,
cert valid until 2051.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:17:31 -04:00
f874009329 feat(premiere-plugin): ZXP + Windows installer build pipeline
Replaces the manual robocopy / install-windows.ps1 flow with two real
distributable artifacts:

  - dragonflight-premiere-panel-<version>.zxp          (Mac + Win)
  - dragonflight-premiere-panel-<version>-windows-setup.exe (Win)

The Windows installer copies the bundle to %APPDATA%\Adobe\CEP\extensions,
sets PlayerDebugMode=1 for CSXS 8..13, registers an uninstaller, and
offers to remove any legacy com.wilddragon.mam.panel folder so editors
don't end up with duplicate panels.

The .zxp is signed with a self-signed cert generated on first build and
committed to build/cert/ so signature continuity is preserved across
builds (Adobe rejects ZXP upgrades with a different cert fingerprint).

Also migrates the CEP bundle ID from com.wilddragon.mam.panel to
net.wilddragon.dragonflight.panel to match the wild-dragon -> dragonflight
repo rename. Manifest, .debug, CSInterface.js, install docs, and the
growing-files quickstart all updated.

build/ is normally swept by the root .gitignore; added an explicit
negation so the packaging pipeline stays tracked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:13:20 -04:00
9ad88e4df4 feat(ingest): YouTube importer — paste link, asset travels normal pipeline
Adds Ingest → YouTube. UI takes a URL + project, API enqueues a BullMQ
"import" job, worker shells out to yt-dlp, lands the MP4 in S3 at the
same originals/{assetId}/... path uploads use, then hands off to the
existing proxy queue. Imported assets share one lifecycle with uploads
from that point on.

Worker container picks up yt-dlp + python3 (apk on alpine, apt on the
GPU variant). The new 'import' queue is registered in jobs.js so it
appears in the Jobs SSE stream and retry/delete work for free.

Spec: docs/superpowers/specs/2026-05-23-youtube-importer-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:05:41 -04:00
7a2710dc9a docs: design spec for YouTube importer
Adds a paste-URL ingest path under Ingest → YouTube. Worker hosts
yt-dlp, downloads to S3, then hands off to the existing proxy +
thumbnail pipeline so imported assets share one lifecycle with uploads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:04:28 -04:00
674dccca4e Merge pull request 'fix(web-ui): CSS must-revalidate so deploys aren't masked by browser cache' (#22) from fix/css-cache-revalidate into main 2026-05-23 15:41:17 -04:00
f525506718 fix(web-ui): css must-revalidate so deployed styles are picked up immediately
Nginx was serving css with `expires 1y; Cache-Control: public, immutable`,
which combined with version-less <link href="styles-rest.css"> meant every
browser permanently pinned whatever stylesheet it cached first. Users were
seeing pre-polish-round-2 CSS even after the new image was deployed —
the calendar grid rendered as a vertical stack of weekday names because
the .cal-* rules didn't exist in the cached file.

Move css into the same bucket as js: must-revalidate via ETag. Fonts,
icons, and raster assets stay in the immutable 1y bucket since they don't
change between deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:40:26 -04:00
908cf8a62d Merge pull request 'jobs: show completion timestamp for done/failed jobs' (#21) from polish/jobs-completed-stamp into main 2026-05-23 15:29:37 -04:00
0551512fef feat(jobs): show absolute completion timestamp for done/failed jobs
The Time column now anchors on the wall clock when a job is in a terminal
state — "done 2:23 PM · 5m ago" / "failed May 22 · 14:24 · 1h ago" — so the
operator can correlate with logs and other timestamps without hovering.
Queued/running jobs keep the relative-only format since their timestamp is
constantly moving. Widen the column to 180px to accommodate the longer label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:26:24 -04:00
e8299fb9f6 polish: live refresh, schedule calendar, jobs times, real sidebar user (#20) 2026-05-23 15:18:55 -04:00
6a1d271576 feat(ui): polish round 2 — live refresh, schedule calendar, jobs times, real sidebar user
- recorders: dispatch df:recorders-changed on create/start/stop/delete so the
  list updates immediately instead of waiting for the 10s poll tick
- library: poll every 4s while any asset is live/processing (15s otherwise) and
  listen for df:assets-changed so a stopped recorder's LIVE badge drops and
  the thumbnail appears without a manual refresh
- auth: synthetic /auth/me (AUTH_ENABLED=false) now uses LOCAL_OPERATOR / USER /
  USERNAME instead of hardcoding "Admin", and flags synthetic:true
- shell: Sidebar takes `me` as a prop, drops the misleading "Admin" fallback,
  and surfaces an "auth off" hint when the response is synthetic
- jobs: replace the always-empty ETA column with a Time column that shows
  queued/started/done/failed N ago (full timestamp on hover); widen column
- schedule: new month-calendar view (default) with events plotted on day cells
  by status; clicking a day pre-fills the new-schedule modal with a 30-min
  window on that day; List view kept behind a toggle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:52:04 -04:00
7e64675aa5 fix: settings S3 surfaces fetch errors; recorder signal dot pulses
- screens-admin.jsx S3SettingsCard: when /settings/s3 fails, log to
  console and surface the message in the existing SettingsMsg banner
  instead of silently returning empty fields. Also logs the response
  payload on success so the next "endpoint blank" report is easier to
  diagnose. (closes part of #15)
- screens-ingest.jsx recorder row: wrap the signal value in a dot+text
  pair; add CSS so the dot pulses green when status=receiving and
  matches the value color otherwise. The pulse is the kind of cue the
  Live signal column was missing per #2.
2026-05-23 13:19:48 -04:00
2515258dd4 style(home): launcher styles + sidebar brand-logo treatment
Adds .launcher, .launcher-tile, .launcher-hero, .launcher-grid styles
plus .brand-logo replacement for the gradient-D mark in the sidebar.

The shipped img/dragon-logo.png has a light-gray background; we use
mix-blend-mode: screen so the black dragon silhouette sits on the
dark theme without showing the gray box, and a soft accent glow under
the hero version. Smaller sidebar version uses the same trick.
2026-05-23 10:56:10 -04:00
ccbebe172d feat(shell): add Dashboard nav entry; swap fake "D" mark for real logo
The sidebar header used a gradient "D" tile as a placeholder. Now it
uses the actual dragon-coiled-D logo so the brand reads consistently
between the launcher hero and the chrome.

Also adds a 'Dashboard' nav item directly under 'Home' so the
operations view is one click away.
2026-05-23 10:54:36 -04:00
74fc8323f0 feat(app): route dashboard separately from home; add to crumbs map
The launcher (Home) and the operations view (Dashboard) are now
distinct routes. Home is the landing page; Dashboard is reached from
the sidebar or from the launcher's "Open dashboard" tile.
2026-05-23 10:53:31 -04:00
740ab31f8c feat(app): wire the new Dashboard route alongside the Home launcher
home → launcher (big-button entry into each section)
dashboard → operations view (was Home; metrics, recent activity, queue)

Crumb labels updated so Home stays one level (just the wordmark) and
Dashboard gets its own breadcrumb.
2026-05-23 10:48:42 -04:00
72fc9cb755 feat(home): restore launcher home page; move current home to Dashboard
The original first-version home page (big-button launcher with the
Dragonflight wordmark) is back at /. The Frame.io-style metrics +
recent-activity layout we've been treating as "home" is now the
Dashboard, reachable from the sidebar and from the launcher's
"Open dashboard" button.

- Renames existing Home → Dashboard (all the cards, sparklines, live
  feed, job-queue, cluster mini-list are unchanged).
- New Home component: hero with the dragon-coiled-D logo (existing
  img/dragon-logo.png), wordmark "DRAGONFLIGHT", a tag line, and 5
  big tiles (Library, Recorders, Editor, Jobs, Settings) plus a
  smaller Dashboard tile. Live cluster + recorder status pip at the
  bottom mirrors what's in the topbar.
- The launcher pulls /metrics/home so the tile counts ("34 assets",
  "0 live", "0 running") reflect reality.
2026-05-23 10:48:06 -04:00
7a6296585c fix(asset): show 'Generate proxy' CTA when an asset has a hi-res
master but no browser-playable proxy

Previously the player's "Retry processing" button only appeared for
assets in status='error'. Old recorder captures (e.g. the archived
'sRT Test_…' clips from May) live as status='archived' or 'ready' with
original_s3_key set but proxy_s3_key null. The /stream endpoint
correctly returned {url: null, reason: 'no_proxy'} for those, but the
player just showed "Preview not yet available" with no path forward —
which reads as "ingest worked, won't play, no idea why."

Two changes:

1) Capture {reason, has_source} from /stream so the UI can tell why
   playback isn't available.

2) Render a "Generate proxy" button (using the existing
   POST /assets/:id/retry endpoint, which the backend now accepts for
   any asset with original_s3_key but no proxy_s3_key) whenever the
   stream lookup returned no_proxy and the source exists. Original
   error-status retry path is preserved.

Closes the visible half of #1 — the user can now self-recover proxy-
less clips from the library without DB surgery.
2026-05-23 10:30:42 -04:00
1afb150237 feat(assets): cleanup-live-orphans + retry handles non-error states
Two changes for issue #7 (HLS cleanup + orphan reaper) and the user's
"SRT clips ingest but won't play" complaint:

1) New POST /assets/cleanup-live-orphans — lists every directory under
   /live/<uuid>/ and deletes the ones whose UUIDs don't match an asset
   row. These accumulate when a recorder crashes mid-capture: the live
   HLS dir is created but no asset is ever finalized in the DB, so the
   files just sit on disk forever.

2) POST /assets/:id/retry now also works for assets that are 'ready'
   or 'archived' but have no proxy_s3_key. The original behavior (only
   re-queue when status='error') made it impossible to re-generate a
   proxy for older recorder captures that landed without one — the
   user could see a thumbnail in the library but the player would just
   show "Preview not yet available" with no retry path.
2026-05-23 10:28:42 -04:00
508e978fe5 fix(worker): route SVG (and other image assets) through the image-poster
path instead of failing the video transcode

Previously IMAGE_CODECS contained the raster ffprobe codec names ('png',
'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls') but not 'svg'.
An SVG-as-asset (e.g. an architecture diagram dragged into a project) was
correctly tagged media_type='image' in the DB but ffprobe reported its
codec as 'svg', which fell through to the video branch, found
durationMs===null, and died with 'Empty or truncated source: codec=svg,
resolution=0x0'. That clogs the failed-jobs list with red rows that have
nothing to do with broken captures.

Two fixes here:

1) Add 'svg' to IMAGE_CODECS so the existing transcodeImage()/poster
   path handles it.

2) Also bail to the poster path when the asset row itself says
   media_type='image', even if ffprobe didn't return a codec name we
   recognize (defensive — catches future formats like AVIF without
   requiring an explicit catalog update).

Closes part of #13.
2026-05-23 10:26:59 -04:00
d07fb13401 ui: search + right-click menu polish so they read as real controls
- Topbar search now sits on bg-2 with a stronger border, subtle inset
  highlight, and a hover state. Search icon and kbd hint get more
  contrast. Focus state lifts the field with a soft accent ring.
- Search results dropdown gets a slightly inset header look so the
  list reads as connected to the field.
- Right-click context menu (ctx-menu) gets a stronger background,
  a tighter section header, separator color tuning, and a soft
  outline so it feels like a popover instead of floating text.
2026-05-23 09:53:17 -04:00
a8a2061eec fix(asset): comment composer shows real user from ZAMPP_DATA.ME, removes dead add-reviewer button 2026-05-23 09:15:20 -04:00
14d689aaf3 fix(shell): sidebar user name/avatar/role from ZAMPP_DATA.ME instead of hardcoded ZG 2026-05-23 09:13:37 -04:00
eed4180b70 feat(data): fetch /auth/me on load, store ZAMPP_DATA.ME with name/initials/role 2026-05-23 09:12:40 -04:00
854775e322 fix(admin): removeNode URL bug, container empty-state text, PasswordResetModal replaces prompt() 2026-05-23 09:07:56 -04:00
004bdd0778 fix(projects): RenameProjectModal replaces prompt() 2026-05-23 09:02:23 -04:00
6fe5f7d450 fix(library): RenameAssetModal replaces prompt(), inline bin name input replaces prompt() 2026-05-23 09:02:09 -04:00
claude
13906cd0fe feat(library,bins): inline bin creation in the left rail
Library's Bins section now always renders (not just when bins exist)
with a + button that prompts for a name and POSTs /api/v1/bins with
the open project's id. Bins re-fetch on project change so the rail
shows project-scoped bins when a project is open, or global view
otherwise.

Bins list now hydrates from local state instead of stale ZAMPP_DATA
so newly-created bins appear without a full reload. Without an open
project the + button is dimmed with a helpful tooltip — "Open a
project to create a bin".
2026-05-23 04:27:23 +00:00
claude
7170a9945c polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
  opens a modal that PUTs /schedules/:id with new name/times/recurrence
  (recorder reassignment is intentionally locked — delete + recreate
  to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
  catalog (ingest, growing-files, scheduler, library + comments,
  jobs, settings, cluster), accurate ports, refreshed architecture
  diagram, ops scripts inventory
2026-05-23 04:26:03 +00:00
claude
7700548dee test: deploy/api-smoke.sh — exercises every API surface
Walks GET endpoints for auth, projects, assets, recorders, jobs, bins,
users, groups, cluster, settings, metrics, schedules, sdk, and the
freshly added comments routes. Deep-links one asset + one recorder by
ID so per-asset endpoints (stream, thumbnail, comments) get coverage.

Prints HTTP codes inline and exits non-zero on any failure. Treats
2xx/3xx as pass; 400/401 also pass since they indicate the route
exists and auth/validation is working as designed.

Usage:
  deploy/api-smoke.sh                      # localhost:47432
  API=http://10.0.0.25:47432 deploy/api-smoke.sh

NewRecorderModal: hardened ZAMPP_DATA hydration with defensive
defaults so first-load timing doesn't blow up the modal.
2026-05-23 04:24:10 +00:00
claude
90a9e4361a feat(comments): persistent frame-anchored comments on asset detail
- migration 010: asset_comments table (id, asset_id, user_id, body,
  frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
  PATCH/DELETE with author join (display_name + initials), nullable
  user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
  empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
  returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
  empty-state copy ('Add one below to mark a frame'), tooltips on
  the timestamp and resolved buttons

Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
2026-05-23 04:21:11 +00:00
claude
7da171cf1f polish: defensive hydration defaults on ZAMPP_DATA accessors
Guards against the brief window between app mount and the first
data load completing — empty arrays render gracefully instead
of throwing on .filter / .map.
2026-05-23 04:17:36 +00:00
claude
24820e921e polish: schedule past-time confirm, recorder name sanitization, asset detail player controls
- Schedule: if start_at is more than a minute in the past, confirm()
  before submitting (operator may want to fire immediately, but
  shouldn't do it accidentally)
- Recorders: generateClipName now sanitizes the recorder name so the
  S3 key / SMB path / ffmpeg arg stays clean — spaces become
  underscores, anything outside [A-Za-z0-9._-] is dropped, capped at 40
- Asset detail: audio mute + fullscreen buttons now key off streamUrl
  state (rather than videoRef.current which is null on first render)
  so they reliably appear when a stream is available
2026-05-23 04:12:42 +00:00
claude
47ad01d0b2 polish(projects,jobs,bins): row menus, real status bars, bulk retry
Projects:
- Per-row 3-dot menu in list view: Open / Rename / Delete (PATCH + DELETE)
- ProjectCard's bottom bar now shows real ready/in-flight/error counts
  for the project's assets instead of fake 70/20 segments
- After mutations, project list refreshes from /projects + recomputes
  asset counts client-side

Bins:
- GET /api/v1/bins now returns every bin across every project when
  no project_id is supplied; result rows include project_name + asset_count
- Asset right-click 'Move to bin' filters to bins in the same project as
  the asset and surfaces project_name as a tooltip

Jobs:
- 'Retry all failed' button in the header appears when there are
  failed jobs and POSTs /retry for each one in parallel
- Failed-row error message now clips with title= tooltip so 3KB
  ffmpeg stderr doesn't blow out the row layout

window.PROJECT_COLORS exposed for cross-screen access.
2026-05-23 04:09:13 +00:00
f474a77bcb feat(web-ui): style the asset right-click menu (.ctx-menu)
The AssetContextMenu in screens-library.jsx has shipped without
matching styles, so the menu rendered as raw HTML on the page. Adds
.ctx-menu / .ctx-header / .ctx-divider / .ctx-section-label / .ctx-empty
plus button + danger styles matching the existing .row-menu look.
2026-05-23 00:04:25 -04:00
claude
f186cdeacd polish(ui): wire dead buttons across asset detail, shell, containers, cluster
Asset detail:
- Download now fetches /assets/:id/hires presigned URL and triggers a
  named browser download instead of doing nothing
- More icon now opens a kebab menu (Copy ID, Delete permanently)
- Approve button removed (no backend); audio + fullscreen icons
  in the player controls now actually toggle mute / requestFullscreen

Shell:
- Sidebar Sign-out now POSTs /auth/logout + reloads (no-op when auth disabled, by design)
- Topbar Notifications bell removed (dead, no backend)
- Topbar search wired: typing + Enter routes to Library with the term
  pre-loaded into Library's own search box
- Cluster-healthy pip now polls /metrics/home every 30s so it reflects
  real online-vs-total instead of always showing green

Editor:
- Dead Export / Publish / Mark in / Mark out / Add to timeline / Step
  buttons are now visibly disabled with explanatory titles; a PREVIEW
  badge sits next to the sequence name so the WIP state is obvious

Containers / Cluster admin:
- Logs button opens a modal with the docker tail command + Copy button
  instead of a JS alert
- Restart now shows an inline toast (pending/ok/fail) instead of alerts
- Cluster Add Node / Drain / Logs replace alert() with a styled advice
  modal that supports multi-line commands + Copy
- Dead Cluster topology Graph/List tab toggle removed (only Graph is
  implemented anyway)
2026-05-23 04:04:08 +00:00
630dc75787 fix(web-ui): hide search wrapper (with dropdown) on narrow screens
Previously the responsive rule hid only .search, leaving the dropdown
positioned on its own wrapper. Target .search-wrap so input + results
both hide together.
2026-05-22 23:55:36 -04:00
899876c6cf feat(web-ui): style global search dropdown
Adds .search-wrap / .search-results / .search-result styles for the
new topbar command-palette dropdown. Per-kind pill colors distinguish
asset / project / recorder / job / user / nav results at a glance.
2026-05-22 23:55:14 -04:00
61d02d522b feat(web-ui): pass search-select handlers from App to Topbar
Wires onOpenAsset and onOpenProject through Topbar so that selecting
an asset/project from the global search opens the asset detail or
navigates to the project view. Adds openProjectFromAnywhere helper.
2026-05-22 23:53:19 -04:00
45c0e0f914 feat(web-ui): wire global search in topbar with results dropdown
Replaces the static topbar input with a working command-palette-style
search that queries ZAMPP_DATA across assets, projects, recorders,
jobs, users, and nav targets. Cmd/Ctrl+K focuses the input, arrow keys
move selection, Enter opens, Esc dismisses. Selecting an asset opens
the asset detail; project opens project view; other kinds navigate.
2026-05-22 23:52:49 -04:00
claude
992fbdfa20 fix(recorders,library): empty-capture handling + right-click context menu
Proxy failures ("moov atom not found"):
- root cause: failed/aborted SRT/RTMP recordings still uploaded 0-byte
  (or ftyp-only ~1KB) objects to S3, which ffmpeg can't probe
- worker proxy.js now bails on inputs < 4 KiB with a clear message
  before handing the file to ffmpeg
- capture-manager.stop() returns framesReceived + empty flag
- capture shutdown handler skips POST /assets entirely on empty
  sessions, instead calls new POST /assets/:id/mark-empty to flip
  the pre-created live asset to 'error' with a note

Library asset right-click menu:
- new AssetContextMenu component on screens-library.jsx; right-click
  any asset in grid or list view to open
- actions: Open, Rename, Move to bin (lists up to 10 bins), Remove
  from bin, Copy asset ID, Delete permanently (hard=true)
- viewport-aware positioning (won't clip past window edges)
- dismisses on outside click / contextmenu / scroll
- Library now refreshes via /assets after mutations; normalizeAsset
  exposed on window so the re-fetch shape matches boot
- ctx-menu styles in styles-rest.css
2026-05-23 03:52:30 +00:00
claude
9877ed351f fix(recorders): queue proxy on finalize + custom clip names
- POST /api/v1/assets: when transitioning from 'live' to 'processing'
  with a hi-res key but no proxy, queue a proxy job instead of just
  flipping status='ready'. Recorder-captured clips now get a proxy
  + thumbnail like upload-path assets do
- POST /api/v1/recorders/:id/start now accepts { clipName } in the body;
  operator-supplied name (sanitized to [A-Za-z0-9 ._-], capped at 80)
  overrides the auto-generated <recorder>_<timestamp> fallback
- RecorderRow gets a 'Clip name (optional)' input visible when stopped;
  Enter triggers Record, value sent on POST start, cleared on stop
- New POST /api/v1/assets/:id/generate-proxy and
  POST /api/v1/assets/backfill-proxies for one-shot cleanup of pre-fix
  clips that have a hi-res master but no proxy
2026-05-23 03:41:03 +00:00
claude
b128c9f5a9 fix(metrics): use real job_status enum values (queued/processing/complete) 2026-05-23 03:31:14 +00:00
claude
ef4c301149 feat(home,users): real metrics, working Users row actions + Groups CRUD
- Home: new /api/v1/metrics/home endpoint buckets last 24h of assets,
  jobs done/failed into hourly counts; sparklines now render real
  time-series instead of decorative sine waves
- Home stat cards are now clickable (route to relevant page) and the
  delta lines show real activity ("+N added in last 24h", "N completed")
- Home live-feed tiles use HlsPreview for recorders with a live_asset_id
- Users: row 3-dot menu is now a real popover with Rename / Reset
  password / Delete actions wired to PATCH /users/:id and DELETE
- Users: role is now an inline <select> that PATCHes immediately
- Users: Created column replaces fake 'last active' (no last_login
  tracking yet); group count is real
- Groups tab is now functional — list groups, create, expand to
  show + manage members (add/remove), delete; backed by existing
  /api/v1/groups CRUD
- Policies tab is now an honest 'coming soon' stub
- New icons: key, lock, edit; new .row-menu popover styles
2026-05-23 03:30:10 +00:00
claude
53196d38ce feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
  (pending / recording / completed / cancelled / failed), 10s
  auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
  recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
  (list/create/edit/cancel/delete), scheduler.js tick loop polling every
  15s; transitions trigger /recorders/:id/start and /stop via in-process
  HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
  completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-23 03:19:24 +00:00
claude
6398879b56 feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
  with explicit 'applied to every ingested file' wording, expose
  CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
  SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
  BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
  staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
  Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
  builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
  hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
  UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-23 02:58:32 +00:00
dc0bd51648 docs: growing-files + Premiere panel quickstart 2026-05-22 19:16:34 -04:00
c3991a1e75 docs: growing-files + Premiere panel quickstart 2026-05-22 19:16:04 -04:00
328f7b4f31 feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
3fc8fbc230 add per-seat/per-stream/per-month strikethrough hero to Tokens page 2026-05-22 17:29:23 -04:00
ceceedf201 probe fallback: basic TCP/UDP connectivity when capture service is offline 2026-05-22 17:26:26 -04:00
4864db03f3 probe: fallback to basic TCP/UDP connectivity check when capture service is offline 2026-05-22 17:22:30 -04:00
8b57a9a35a expand codec list, add MXF container, remove proxy settings (fixed profile) 2026-05-22 17:20:01 -04:00
fa787bbe1e fix hover-to-play: remove status filter so any asset triggers stream fetch 2026-05-22 17:18:56 -04:00
0aa0922fd3 remove hardcoded recorders badge 2026-05-22 17:18:20 -04:00
1abf22623d feat: hover-to-play video preview on library asset cards
Fetches stream URL on hover after 350ms delay; renders muted autoplay
video overlay over the thumbnail. Supports both mp4 and HLS streams.
Only triggers for ready/live assets to avoid pointless API calls.
2026-05-22 16:58:11 -04:00
4afd0c7b21 feat: add /cluster/containers endpoint via Docker socket
Lists all containers on the local host; supports POST /containers/:id/restart.
Falls back to [] gracefully if Docker socket is unavailable.
2026-05-22 16:57:33 -04:00
6f2de45819 feat: wire real video playback via GET /assets/:id/stream
- Fetch stream URL on asset open; show <video> element for mp4/hls
- Use hls.js for live HLS streams (loaded via CDN in index.html)
- Sync video play/pause/seek/timeupdate to React state
- Show loading state while fetching stream, status message when unavailable
- Add Retry processing button for error-status assets
- totalMs derived from video metadata when available, falls back to parseDuration
2026-05-22 13:37:55 -04:00
e3c3d60103 index: add hls.js for live stream HLS playback 2026-05-22 13:31:57 -04:00
81324c8e52 shell: add Field component (used by modal-new-recorder, was missing from global scope) 2026-05-22 12:56:33 -04:00
bec58ab138 screens-asset: fix thumbGrad crash, parseDuration NaN, guard missing ACTIVITY 2026-05-22 12:49:33 -04:00
451bed834f screens-admin: wire all buttons — invite user, export CSV, cluster refresh, container logs/restart, node drain/remove 2026-05-22 12:27:02 -04:00
d00e1c666e screens-ingest: wire delete button on RecorderRow 2026-05-22 12:24:10 -04:00
ddb4cf0c51 feat: add POST /jobs/:id/retry endpoint for re-queuing failed BullMQ jobs 2026-05-22 12:18:53 -04:00
fea0f2962b fix: wire Jobs Retry (POST /jobs/:id/retry) and Delete (DELETE /jobs/:id) buttons 2026-05-22 12:18:23 -04:00
506ee2d695 fix: wire New Project button — modal + POST /projects + state refresh 2026-05-22 12:17:54 -04:00
88689a4eb2 fix: wire Library Upload button to navigate to Upload screen 2026-05-22 12:17:29 -04:00
dc269bec00 fix: make Settings S3 form functional — load from API, save & test 2026-05-22 12:08:10 -04:00
665ab5238d feat: live status polling in RecorderRow, immediate refresh on mount 2026-05-22 11:35:13 -04:00
bb508d3256 feat: add probe button to SRT/RTMP sources, fix node labels 2026-05-22 11:33:45 -04:00
994fd799d0 fix: stop endpoint handles missing/dead containers gracefully 2026-05-22 11:32:44 -04:00
6510871448 fix: implement real upload (XHR + S3 multipart) and fix SDI recorder device_index + manual fallback: modal-new-recorder.jsx 2026-05-22 11:10:01 -04:00
26399f8d0a fix: implement real upload (XHR + S3 multipart) and fix SDI recorder device_index + manual fallback: screens-ingest.jsx 2026-05-22 11:10:00 -04:00
529d14cb6b fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: modal-new-recorder.jsx 2026-05-22 10:55:22 -04:00
fb44bd8aff fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: screens-editor.jsx 2026-05-22 10:55:20 -04:00
24a1d57165 fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: screens-ingest.jsx 2026-05-22 10:55:19 -04:00
48ee66e744 fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: screens-home.jsx 2026-05-22 10:55:18 -04:00
0342aa0a5a fix admin screen: move data destructuring inside components, normalize field names: screens-admin.jsx 2026-05-22 10:15:42 -04:00
406f28c663 feat(ui): wire ingest screens to real API (recorders, capture devices): screens-ingest.jsx 2026-05-22 10:07:13 -04:00
835545e061 feat(ui): wire library, jobs, ingest, editor screens to live API data: screens-editor.jsx 2026-05-22 10:05:57 -04:00
1392e28a88 feat(ui): wire library, jobs, ingest, editor screens to live API data: screens-jobs.jsx 2026-05-22 10:05:56 -04:00
bc03ee866b feat(ui): wire library, jobs, ingest, editor screens to live API data: screens-library.jsx 2026-05-22 10:05:54 -04:00
69f0d130ee feat(ui): wire screens to live API data; add thumbnail lazy-loading: screens-projects.jsx 2026-05-22 10:04:25 -04:00
07af51b05c feat(ui): wire screens to live API data; add thumbnail lazy-loading: screens-home.jsx 2026-05-22 10:04:24 -04:00
3574ae8a43 feat(ui): wire screens to live API data; add thumbnail lazy-loading: visuals.jsx 2026-05-22 10:04:23 -04:00
7dda7cc89c feat(ui): wire data.jsx to real API; add loading gate in app.jsx: app.jsx 2026-05-22 10:02:55 -04:00
98025001e8 feat(ui): wire data.jsx to real API; add loading gate in app.jsx: data.jsx 2026-05-22 10:02:54 -04:00
068e3a0828 fix(ui): replace FauxFrame SVG scenes with clean dark placeholder; strip fake LiveStrip animation: screens-editor.jsx 2026-05-22 09:31:58 -04:00
6ad277275b fix(ui): replace FauxFrame SVG scenes with clean dark placeholder; strip fake LiveStrip animation: visuals.jsx 2026-05-22 09:31:57 -04:00
f58fe95f0d fix(ui): remove placeholder elements — no scanlines, no DEV BUILD, no tweaks panel: screens-projects.jsx 2026-05-22 09:30:50 -04:00
6e763e8270 fix(ui): remove placeholder elements — no scanlines, no DEV BUILD, no tweaks panel: screens-home.jsx 2026-05-22 09:30:49 -04:00
6ac3050a05 fix(ui): remove placeholder elements — no scanlines, no DEV BUILD, no tweaks panel: index.html 2026-05-22 09:30:47 -04:00
e13d111b9f feat(ui): Dragonflight redesign — admin screens (users, tokens, containers, cluster, settings): screens-admin.jsx 2026-05-22 08:24:08 -04:00
1eaf9dff5c Add Z-AMPP UI: screens-ingest + screens-admin: screens-admin.jsx 2026-05-22 08:22:38 -04:00
20dfa504e5 Add Z-AMPP UI: screens-ingest + screens-admin: screens-ingest.jsx 2026-05-22 08:22:37 -04:00
575e350831 feat(ui): Dragonflight redesign — editor, admin, and new recorder modal: modal-new-recorder.jsx 2026-05-22 08:21:40 -04:00
7899066107 feat(ui): Dragonflight redesign — editor, admin, and new recorder modal: screens-editor.jsx 2026-05-22 08:21:39 -04:00
9289bd0c74 feat(ui): Dragonflight redesign — ingest, jobs, editor, admin screens: screens-jobs.jsx 2026-05-22 08:20:16 -04:00
0945f488f6 feat(ui): Dragonflight redesign — ingest, jobs, editor, admin screens: screens-ingest.jsx 2026-05-22 08:20:15 -04:00
bd9dfd2cce Add Z-AMPP UI: screens-jobs + screens-editor + modal-new-recorder: modal-new-recorder.jsx 2026-05-22 08:19:03 -04:00
b8e1796c33 Add Z-AMPP UI: screens-jobs + screens-editor + modal-new-recorder: screens-editor.jsx 2026-05-22 08:19:02 -04:00
f8bd80e38e Add Z-AMPP UI: screens-jobs + screens-editor + modal-new-recorder: screens-jobs.jsx 2026-05-22 08:19:01 -04:00
90d2c1cf82 feat(ui): Dragonflight redesign — screen components batch 2: screens-asset.jsx 2026-05-22 08:18:25 -04:00
55e82bdeb7 Add Z-AMPP UI: screens-asset + screens-projects: screens-projects.jsx 2026-05-22 08:17:18 -04:00
7007d2df93 Add Z-AMPP UI: screens-asset + screens-projects: screens-asset.jsx 2026-05-22 08:17:17 -04:00
ed3084e60f feat(ui): Dragonflight redesign — screen components batch 1: screens-projects.jsx 2026-05-22 08:17:06 -04:00
3392a8944d feat(ui): Dragonflight redesign — screen components batch 1: screens-library.jsx 2026-05-22 08:17:05 -04:00
c801eb4781 feat(ui): Dragonflight redesign — screen components batch 1: screens-home.jsx 2026-05-22 08:17:04 -04:00
4a77c1bed8 Add Z-AMPP UI: screens-home + screens-library: screens-library.jsx 2026-05-22 08:15:36 -04:00
100fc054cc Add Z-AMPP UI: screens-home + screens-library: screens-home.jsx 2026-05-22 08:15:35 -04:00
001533fdf0 feat(ui): Dragonflight redesign — visuals + tweaks panel: tweaks-panel.jsx 2026-05-22 08:15:35 -04:00
c0345e47c9 feat(ui): Dragonflight redesign — visuals + tweaks panel: visuals.jsx 2026-05-22 08:15:34 -04:00
dfdf0845a0 Add Z-AMPP UI: shell + app: app.jsx 2026-05-22 08:14:33 -04:00
f24912ea9e Add Z-AMPP UI: shell + app: shell.jsx 2026-05-22 08:14:32 -04:00
a9e0313fe4 Add Z-AMPP UI: visuals + tweaks-panel: tweaks-panel.jsx 2026-05-22 08:13:37 -04:00
d54d960b8f Add Z-AMPP UI: visuals + tweaks-panel: visuals.jsx 2026-05-22 08:13:36 -04:00
2706903353 feat(ui): Dragonflight redesign — foundation JSX files: app.jsx 2026-05-22 08:13:03 -04:00
b6dcecb672 feat(ui): Dragonflight redesign — foundation JSX files: shell.jsx 2026-05-22 08:13:02 -04:00
14bfcabcaf feat(ui): Dragonflight redesign — foundation JSX files: icons.jsx 2026-05-22 08:13:01 -04:00
3b1610a167 feat(ui): Dragonflight redesign — foundation JSX files: data.jsx 2026-05-22 08:13:00 -04:00
d5fd705d66 feat(web-ui): Z-AMPP core JSX files (data, icons, visuals, tweaks, shell, app): icons.jsx 2026-05-22 08:09:04 -04:00
a700124f50 feat(web-ui): Z-AMPP core JSX files (data, icons, visuals, tweaks, shell, app): data.jsx 2026-05-22 08:09:03 -04:00
10952df591 feat(web-ui): add styles-asset and styles-rest CSS: styles-rest.css 2026-05-22 08:07:16 -04:00
afd3fd3374 feat(web-ui): add styles-asset and styles-rest CSS: styles-asset.css 2026-05-22 08:07:15 -04:00
352d21496f feat(web-ui): asset detail + rest CSS: styles-rest.css 2026-05-22 08:06:40 -04:00
016adff949 feat(web-ui): asset detail + rest CSS: styles-asset.css 2026-05-22 08:06:39 -04:00
fcd5a9aa09 feat(web-ui): add remaining design CSS files: styles-modal.css 2026-05-22 08:04:35 -04:00
304d3713b7 feat(web-ui): add remaining design CSS files: styles-screens.css 2026-05-22 08:04:33 -04:00
6befb0f46a feat(web-ui): Z-AMPP screen + component CSS: styles-modal.css 2026-05-22 08:03:57 -04:00
e655ccdf64 feat(web-ui): Z-AMPP screen + component CSS: styles-screens.css 2026-05-22 08:03:55 -04:00
2c88fb0a03 feat(web-ui): Z-AMPP design system CSS: styles-fixes.css 2026-05-22 08:02:36 -04:00
7b13d8bd0f feat(web-ui): Z-AMPP design system CSS: styles.css 2026-05-22 08:02:35 -04:00
21db567396 feat(web-ui): new design system CSS from Claude Design: styles-fixes.css 2026-05-22 08:02:08 -04:00
68df3797f1 feat(web-ui): new design system CSS from Claude Design: styles.css 2026-05-22 08:02:07 -04:00
dccca554e0 Add Dragonflight React SPA design - index.html and CSS: styles-fixes.css 2026-05-21 23:53:21 -04:00
1b63429def Add Dragonflight React SPA design - index.html and CSS: index.html 2026-05-21 23:53:19 -04:00
87da3c0b58 feat: migrate cluster.html to wd-* design system 2026-05-21 23:17:48 -04:00
06551c66a6 feat: migrate editor.html to wd-* design system 2026-05-21 23:16:46 -04:00
136820c8f9 feat: migrate capture.html to wd-* design system 2026-05-21 23:16:29 -04:00
7c88692c1c feat: migrate recorders.html to wd-* design system 2026-05-22 03:16:27 +00:00
1e0015322c feat: migrate projects.html to wd-* design system 2026-05-21 23:15:57 -04:00
6176791174 feat: migrate player.html to wd-* design system 2026-05-21 23:15:18 -04:00
9ff80f8cc1 feat: migrate upload.html to wd-* design system 2026-05-21 23:14:51 -04:00
738d6298d2 feat: migrate edit.html to wd-* design system 2026-05-21 23:14:19 -04:00
a84bc3ecfe feat: migrate api-tokens.html to wd-* design system 2026-05-21 23:14:09 -04:00
daa203a43e feat: migrate index.html to wd-* design system 2026-05-21 23:13:13 -04:00
33d2a4004d feat: migrate jobs.html to wd-* design system 2026-05-21 23:12:58 -04:00
6e43ab30c2 rebrand: Recorders — Dragonflight, ember orange hue-32 2026-05-22 02:54:10 +00:00
cc45cc6347 rebrand: _primitives-smoke — Dragonflight brand 2026-05-21 22:52:12 -04:00
c31933a53c rebrand: Projects — Dragonflight, ember orange hue-32 2026-05-21 22:51:20 -04:00
efe005378a rebrand: Editor (in development) — Dragonflight, ember orange hue-32 2026-05-21 22:49:43 -04:00
5874c93956 rebrand: Editor — Dragonflight, ember orange hue-32 2026-05-21 22:49:00 -04:00
cd5fc3a05c rebrand: upload.html — Dragonflight title + sidebar 2026-05-21 22:44:25 -04:00
e0a2d0c95c rebrand: jobs.html — Dragonflight title + sidebar 2026-05-21 22:42:56 -04:00
4572c88f58 rebrand: capture.html — Dragonflight title + sidebar + hue-32 2026-05-21 22:40:48 -04:00
c752227e20 rebrand: api-tokens.html — Dragonflight title + sidebar 2026-05-21 22:39:20 -04:00
d4e5af459e rebrand: settings.html — Z-AMPP → Dragonflight 2026-05-21 22:35:33 -04:00
29360e38e8 rebrand: users.html — Z-AMPP → Dragonflight 2026-05-21 22:33:21 -04:00
e5f4c00729 rebrand: containers.html — Z-AMPP → Dragonflight 2026-05-21 22:31:41 -04:00
c6aeedb5fc rebrand: Dragonflight — cluster.html brand names 2026-05-21 22:27:36 -04:00
32cf6bf63e rebrand: Dragonflight — tokens.html brand names and footer text 2026-05-21 22:26:24 -04:00
024833cc95 rebrand: Dragonflight — login.html brand names, description text 2026-05-21 22:22:58 -04:00
b4642b3c78 rebrand: Dragonflight — index.html brand names, splash screen, accent colors 2026-05-21 22:22:12 -04:00
b82cc73cf1 rebrand: Dragonflight — home.html wordmark, accent gradients, brand names 2026-05-21 22:19:00 -04:00
873920d27f rebrand: Dragonflight — ember orange accent (hue 266→32) 2026-05-21 22:16:32 -04:00
37767f9939 fix(cluster): pickIp() only treats 172.17.x as docker bridge, not all of RFC1918 172.16/12 2026-05-21 21:27:15 -04:00
00f3f2905f feat(cluster): expose db/redis ports for worker-node connectivity 2026-05-21 21:23:23 -04:00
30b4deffc6 fix(capture): proper SDK 16 patch via upstream FFmpeg master diff
The previous patch_decklink.py mixed v14_2_1 versioned types (Fix 1 renamed the allocator class) with no-ops for SetVideoInputFrameMemoryAllocator + QueryInterface-around-GetBytes (Fixes 2 & 3). That inconsistency compiled but silently dropped every video frame: VideoInputFrameArrived saw _v14_2_1 allocator output but tried to read it via the SDK 16 unversioned IDeckLinkVideoBuffer path, and the SDK released the buffer before FFmpeg could consume it.

Bisected with the BMD-provided Capture sample at SDK 16 mode 5 (Hp29) which got frames cleanly, confirming the signal was fine and the bug was in FFmpegs decklink demuxer.

Fix: pull libavdevice/decklink_{enc,dec,common}{.cpp,.h} from upstream FFmpeg master (commits past 7.1 that fully rename every decklink interface to its _v14_2_1 versioned form) and apply that diff in reverse during build. Now build is internally consistent and frames flow.

Verified: SDI1 recorder on zampp2 hits 423 frames in 14s @ 29 fps, ProRes HQ at 91 Mbps.
2026-05-22 00:53:03 +00:00
96f0f58e68 capture: yadif deint=1 so progressive SDI passes through unchanged
Bug: yadif=mode=1 unconditionally doubled output framerate for SDI input. On 1080p29.97 progressive sources the encoder produced zero frames (time advanced, size stayed at 1KiB MOV header).

Fix: deint=1 makes yadif only process frames flagged as interlaced; progressive frames pass through at the source rate.
2026-05-22 00:14:02 +00:00
a8656fc1a8 capture: custom FFmpeg 7.1 build with DeckLink + D-Bus mounts + SDI deinterlace
Dockerfile is now a two-stage build that compiles FFmpeg from source with --enable-decklink against the Blackmagic SDK 16.x headers in services/capture/sdk/ (operator-supplied, gitignored). build-with-decklink.sh + patch_decklink.py drive the build.

docker-compose.yml mounts /dev/shm, /run/dbus, /run/systemd into mam-api, capture, web-ui so the BMD runtime can talk to the host.

capture-manager.js wraps SDI sources with -vf yadif=mode=1 (deinterlace).

recorders.html defaults to SDI source type now that we have a working DeckLink path.
2026-05-22 00:01:43 +00:00
1074104d34 fix(capture): FFmpeg 7.x DeckLink compatibility 2026-05-22 00:00:02 +00:00
486e3c27e4 fix(decklink): mount /dev/blackmagic in sidecar + remote node routing via node-agent
Two bugs fixed:
1. SDI capture sidecar never had /dev/blackmagic bound — ffmpeg opened the
   decklink input inside a container with no device nodes, so frame=0.
   Fix: local spawns now push '/dev/blackmagic:/dev/blackmagic' onto Binds
   when source_type='sdi'.

2. recorders.js always spawned sidecars against the local Docker socket
   (zampp1), even when a recorder's node_id pointed at zampp2 (where the
   card is). Fix: resolveNodeTarget() looks up the recorder's cluster node;
   if it's a different hostname the sidecar is spawned via a new
   POST /sidecar/start endpoint on the remote node-agent.

node-agent gains three new routes (all talk to the local Docker socket):
  POST   /sidecar/start         — create + start container (host network,
                                   privileged, /dev/blackmagic bind for sdi)
  DELETE /sidecar/:id           — stop + remove
  GET    /sidecar/:id/status    — inspect + poll capture service

docker-compose.worker.yml: add /var/run/docker.sock and LIVE_DIR to
node-agent so it can spawn sidecars, and document build-capture prerequisite.: docker-compose.worker.yml
2026-05-21 18:51:11 -04:00
bbed2a7059 fix(decklink): mount /dev/blackmagic in sidecar + remote node routing via node-agent
Two bugs fixed:
1. SDI capture sidecar never had /dev/blackmagic bound — ffmpeg opened the
   decklink input inside a container with no device nodes, so frame=0.
   Fix: local spawns now push '/dev/blackmagic:/dev/blackmagic' onto Binds
   when source_type='sdi'.

2. recorders.js always spawned sidecars against the local Docker socket
   (zampp1), even when a recorder's node_id pointed at zampp2 (where the
   card is). Fix: resolveNodeTarget() looks up the recorder's cluster node;
   if it's a different hostname the sidecar is spawned via a new
   POST /sidecar/start endpoint on the remote node-agent.

node-agent gains three new routes (all talk to the local Docker socket):
  POST   /sidecar/start         — create + start container (host network,
                                   privileged, /dev/blackmagic bind for sdi)
  DELETE /sidecar/:id           — stop + remove
  GET    /sidecar/:id/status    — inspect + poll capture service

docker-compose.worker.yml: add /var/run/docker.sock and LIVE_DIR to
node-agent so it can spawn sidecars, and document build-capture prerequisite.: recorders.js
2026-05-21 18:51:10 -04:00
8186b181cc fix(decklink): mount /dev/blackmagic in sidecar + remote node routing via node-agent
Two bugs fixed:
1. SDI capture sidecar never had /dev/blackmagic bound — ffmpeg opened the
   decklink input inside a container with no device nodes, so frame=0.
   Fix: local spawns now push '/dev/blackmagic:/dev/blackmagic' onto Binds
   when source_type='sdi'.

2. recorders.js always spawned sidecars against the local Docker socket
   (zampp1), even when a recorder's node_id pointed at zampp2 (where the
   card is). Fix: resolveNodeTarget() looks up the recorder's cluster node;
   if it's a different hostname the sidecar is spawned via a new
   POST /sidecar/start endpoint on the remote node-agent.

node-agent gains three new routes (all talk to the local Docker socket):
  POST   /sidecar/start         — create + start container (host network,
                                   privileged, /dev/blackmagic bind for sdi)
  DELETE /sidecar/:id           — stop + remove
  GET    /sidecar/:id/status    — inspect + poll capture service

docker-compose.worker.yml: add /var/run/docker.sock and LIVE_DIR to
node-agent so it can spawn sidecars, and document build-capture prerequisite.: index.js
2026-05-21 18:51:09 -04:00
539429c058 tokens.html: remove orphan sidebar-footer + duplicate /nav; use main flex wrapper 2026-05-21 16:40:28 -04:00
01a9d6c3db settings.html: remove orphan sidebar-footer + duplicate /nav; use main flex wrapper 2026-05-21 16:40:28 -04:00
Zac
ddd3b3eca1 Revert shell.css primitive rework (5 commits eea1ed6..a8f5bce) 2026-05-21 20:34:08 +00:00
a8f5bce9ee home.html: drop per-page body+shell rules (now in shell primitive) 2026-05-21 16:22:49 -04:00
683f0ff101 containers.html: drop inline shell hack (now in shell primitive) 2026-05-21 16:22:49 -04:00
47c0e1f933 users.html: drop inline shell hack (now in shell primitive) 2026-05-21 16:22:48 -04:00
6cad11f687 app.css: import new shell primitive; drop redundant html base rule (now in shell.css) 2026-05-21 16:22:47 -04:00
eea1ed6bcb shell.css: codify body + .wd-shell + .wd-main as a primitive (fix dead-space layout bug) 2026-05-21 16:22:46 -04:00
c6bcbbd214 web-ui(wave 2): migrate settings.html to new primitives
Surgical migration: stylesheet swap to /dist/app.css + sidebar markup
updated to wd-sidebar primitives. Page-specific content + JS unchanged.
2026-05-21 13:35:04 -04:00
e7495dfe29 web-ui(wave 2): migrate tokens.html to new primitives
Surgical migration: stylesheet swap to /dist/app.css + sidebar markup
updated to wd-sidebar primitives. Page-specific content + JS unchanged.
2026-05-21 13:35:03 -04:00
5650b279c3 web-ui(wave 2): migrate users.html to new primitives 2026-05-21 13:33:22 -04:00
596fe228ed web-ui(wave 2): migrate containers.html to new primitives 2026-05-21 13:33:22 -04:00
e0cfe80a9e web-ui(wave 2): migrate home.html to new primitives
Swap stylesheet to /dist/app.css. Sidebar markup ported to wd-sidebar /
wd-nav-item / wd-sidebar-* primitives (active state = leading accent dot).
Logout button promoted to wd-btn--ghost--sm--icon.

Hero (portrait, wordmark, tagline) and the 10 illustrated cards keep
their bespoke design — they're a brand moment. Hardcoded oklch values
in the inline style replaced with var(--accent-bright), var(--signal-bad),
var(--bg-base), var(--accent-border), var(--overlay), etc. wherever the
brand palette already provides them.

All JS ids preserved (assetCount, projectCount, recorderCount,
containerCount, nodeCount, jobCount, ingestCount, captureStatus,
tokenBurn, userAvatar, userName, userRole, logoutBtn, systemBuild).
loadStats() poll cycle unchanged.
2026-05-21 13:19:16 -04:00
16a34a2fad web-ui(wave 2): migrate login.html to new primitives
Keeps the hero + AMPP Safe stamp + brand panel. Right column now uses
wd-form-group / wd-input / wd-label / wd-btn primitives loaded from
/dist/app.css. All JS ids and handlers preserved verbatim (login-form,
setup-form, flash, show-setup, show-login).

Visual changes: tighter form spacing, focus rings use accent-subtle
ring, flash messages use the top-strip toast pattern.
2026-05-21 13:09:39 -04:00
75b94a5025 web-ui(wave 2): token cleanups from wave-1 code review
Promoted 14 new tokens (--accent-hover, --signal-{good,bad,warn}-hover,
--accent-bright, --thumb-black, --overlay, --shadow, --ease-out-{quart,expo},
--dur-{fast,normal,slide}, --z-topbar) and substituted every raw oklch /
cubic-bezier / hardcoded z-index occurrence in the 12 primitive files.

cubic-bezier appearances dropped from 8 files to 0 (only in tokens.css).
Bundle byte count: 138 KB -> 139 KB. Visual regression: zero (smoke page
still renders identically).
2026-05-21 17:08:02 +00:00
265f4174d5 docs: UI shell rework wave-2 implementation plan
7 tasks: token cleanups from wave-1 review (task 0), then migrate login/home/settings/tokens/users/containers.html one-by-one with deploy-and-verify between each. Smallest blast radius first.
2026-05-21 13:06:04 -04:00
447b2b2b81 web-ui: add _primitives-smoke.html for wave-1 visual QA
Delete at end of wave 4 once every shell page has migrated to the new primitives.
2026-05-21 12:43:00 -04:00
3b89cf2d5f web-ui: fix wave-1 build pipeline (primitives missing from bundle)
Three bugs found during task 20 verify, all fixed:

1. Tailwind CLI does NOT read postcss.config.js. Switched Dockerfile to
   npx postcss + postcss-cli so the postcss plugin chain actually runs.

2. postcss-import was not installed but app.css uses @import for the
   primitive component files. Added postcss-import + cssnano (for prod
   minification under --env production).

3. @import statements must come BEFORE any other rules per CSS spec.
   app.css had @tailwind base/components ABOVE @import, so postcss-import
   silently skipped every component @import. Moved all @imports to the
   top, @tailwind directives below. Bundle went from 121KB with 0 wd-*
   classes to 138KB with 116 wd-* classes.

Also added tailwind safelist for wd-/is-/nav-dev-badge so the wave-2
migration of HTML files cannot accidentally tree-shake primitives.
2026-05-21 16:41:55 +00:00
f9236101b9 web-ui: wave-1 finish — self-host fonts + multi-stage Dockerfile
Fonts: Inter 400/500/600 + JetBrains Mono 400/600 (woff2 from rsms/inter and JetBrains/JetBrainsMono github).

Dockerfile: two-stage build. Stage 1 (node:20-alpine) runs tailwindcss --minify to emit public/dist/app.css. Stage 2 (nginx:alpine) ships the static result.

NOTE on task 19 (nginx caching for /dist /fonts): SKIPPED. The existing nginx.conf already caches *.css and *.woff2 for 1y immutable via the generic location ~* \\.(css|...|woff2|...)$ regex. Adding explicit /dist/ and /fonts/ blocks would be redundant.
2026-05-21 16:32:55 +00:00
6561cecf33 web-ui: fix corrupted .gitignore from earlier patch 2026-05-21 12:31:18 -04:00
a4bb6e7b0c web-ui: add node_modules + public/dist to .gitignore for wave-1 build 2026-05-21 12:30:56 -04:00
1f995c9029 web-ui: wave-1 foundation — services/web-ui/tailwind.config.js 2026-05-21 12:30:41 -04:00
891a8f82b7 web-ui: wave-1 foundation — services/web-ui/src/css/components/topbar.css 2026-05-21 12:30:40 -04:00
23ae848f5b web-ui: wave-1 foundation — services/web-ui/src/css/components/tokens.css 2026-05-21 12:30:40 -04:00
a16c235f71 web-ui: wave-1 foundation — services/web-ui/src/css/components/toast.css 2026-05-21 12:30:39 -04:00
e56704b69f web-ui: wave-1 foundation — services/web-ui/src/css/components/slide-panel.css 2026-05-21 12:30:39 -04:00
1c0ed05ac9 web-ui: wave-1 foundation — services/web-ui/src/css/components/sidebar.css 2026-05-21 12:30:38 -04:00
a6c9f88068 web-ui: wave-1 foundation — services/web-ui/src/css/components/motion.css 2026-05-21 12:30:38 -04:00
310eca0810 web-ui: wave-1 foundation — services/web-ui/src/css/components/list-row.css 2026-05-21 12:30:38 -04:00
a76e6b9a81 web-ui: wave-1 foundation — services/web-ui/src/css/components/form-controls.css 2026-05-21 12:30:37 -04:00
836a163cc8 web-ui: wave-1 foundation — services/web-ui/src/css/components/field-group.css 2026-05-21 12:30:37 -04:00
052a880b0f web-ui: wave-1 foundation — services/web-ui/src/css/components/empty-state.css 2026-05-21 12:30:36 -04:00
2f3e04cfc3 web-ui: wave-1 foundation — services/web-ui/src/css/components/card-operational.css 2026-05-21 12:30:36 -04:00
080f82e198 web-ui: wave-1 foundation — services/web-ui/src/css/components/card-asset.css 2026-05-21 12:30:36 -04:00
c08025eeb2 web-ui: wave-1 foundation — services/web-ui/src/css/components/button.css 2026-05-21 12:30:35 -04:00
30cb6663dd web-ui: wave-1 foundation — services/web-ui/src/css/components/badge.css 2026-05-21 12:30:35 -04:00
e256a771d5 web-ui: wave-1 foundation — services/web-ui/src/css/app.css 2026-05-21 12:30:34 -04:00
3df6a4434e web-ui: wave-1 foundation — services/web-ui/postcss.config.js 2026-05-21 12:30:34 -04:00
9d99811272 web-ui: wave-1 foundation — services/web-ui/package.json 2026-05-21 12:30:33 -04:00
c97759dc4e docs: UI shell rework wave-1 implementation plan
Detailed 22-task plan for the foundation wave (build pipeline + theme
port + every CSS primitive, no page migration yet). Smoke-test page at
_primitives-smoke.html is the wave-1 QA gate. Waves 2-4 will get their
own plan documents after each prior wave ships.
2026-05-21 10:53:31 -04:00
b77a370eb7 docs: clarify responsive viewport tiers in UI rework spec
Self-review caught an ordering ambiguity in the responsive section: 1280x800
is the fully-supported minimum, tablet 768-1279 is best-effort. Rewording
so the tiers list top-down by viewport size.
2026-05-21 10:45:59 -04:00
b36e859c06 docs: UI shell rework design spec (2026-05-21)
Full design spec for the flyon-ui-based shell rework. All 7 design
sections (build system, sidebar, topbar, cards, forms/slide-panel,
states/motion, a11y/responsive/rollout) approved by user during
brainstorming. Next step is the implementation plan via writing-plans.
2026-05-21 10:45:08 -04:00
fd955076dd web-ui: fix codec/settings panel clipping in recorders.html
Flex-child overflow footgun: .slide-panel-body had flex:1 and overflow-y:auto
but without min-height:0 it never shrank below content height, so the new
Master/Proxy codec blocks overflowed past the panel bottom and the footer
(Cancel / Probe / Save buttons) was unreachable. Lock the panel to 100vh,
add min-height:0 to the body. Also drop redundant margin-bottom on
.codec-block since the body already has gap spacing.
2026-05-21 14:10:24 +00:00
89ceef444e web-ui: include auth-guard.js on home.html and projects.html so the IN DEV badge renders on those pages too 2026-05-21 14:01:52 +00:00
00bf112b5a web-ui: replace editor.html with under-construction screen
The timeline editor isn't ready yet. Replace the 49 KB prototype page
with a clean construction screen (still rendering the standard sidebar
so users can navigate away). The 'IN DEV' badge on the sidebar nav item
is injected by auth-guard.js across all pages.
2026-05-21 09:59:29 -04:00
16a1fe604f web-ui: tag IN DEV pages in sidebar from auth-guard
Adds a tiny CSS rule + DOM patch that walks .nav-item links on every
page and appends an 'IN DEV' badge to those matching a known in-dev
page (currently just editor.html). Avoids touching all 13 HTML files
for the same single-line nav change.
2026-05-21 09:59:29 -04:00
f6c0594088 web-ui: rewrite recorders.html with tabbed codec settings + BMD card picker
- Replace flat codec dropdowns with Master/Proxy blocks, each with
  Video/Audio/Container tabs.
- Replace BM1/BM2 device dropdown with cluster-node picker plus
  inline BMDCards.render(...) SVG -- click a port to set device_index.
- Wire full codec field set (video bitrate, framerate, audio codec/
  bitrate/channels, container) end-to-end to /api/v1/recorders.
- Auto-hide bitrate input for profile-driven codecs (ProRes, DNxHR,
  PCM, FLAC); show for H.264/265/NVENC, AAC, AC-3, Opus, DNxHD.
- Resolve SDI source display in cards via /cluster/devices/blackmagic
  (hostname + model + port) instead of raw device index.

Finishes the pending item from
docs/superpowers/plans/2026-05-21-cluster-codec-revamp.md.
2026-05-21 09:47:32 -04:00
8403355ba9 docs: add handoff plan for cluster + codec revamp session
Captures the full commit list, current cluster state, the 172.18 LAN topology gotcha, the remaining recorders.html UI rewrite, and how the next agent should pick it up.
2026-05-21 13:13:42 +00:00
4a3a672cbe cluster: stable hostname for mam-api, jq-based smoke test
mam-api self-heartbeat now reads NODE_HOSTNAME so primary rows survive container restarts instead of resurrecting with the random container ID. test-cluster.sh rewritten to use jq (the python f-strings had a parse bug that silently passed the IP check) and limited the docker-bridge alarm to 172.17.x since the user LAN occupies 172.18.0.0/16.
2026-05-21 11:50:52 +00:00
8aa378348e deploy: add cluster smoke-test script; remove rate-limit probe
test-cluster.sh validates primary /health, dedup hostnames, real LAN IPs, per-node /health, NVENC encode, and Blackmagic enumeration.
2026-05-21 11:38:30 +00:00
97628bb67d chore: remove cloudflare rate-limit probe 2026-05-21 07:34:47 -04:00
46676bf80d test: rate-limit probe 2026-05-21 00:32:09 -04:00
0ebb3cffe4 onboard-node: auto-detect host LAN IP and pass NODE_IP + BMD_MODEL
Resolves the host's primary LAN address with `ip route get` so the
node-agent reports it in the heartbeat instead of a docker bridge IP.
Adds BMD_MODEL forwarding so the recorder UI knows which card to draw,
and reads back the agent's /health response to confirm the resolved IP.
2026-05-21 00:20:46 -04:00
d39f86d9c5 ui: add bmd-card.js — visual DeckLink port picker
Renders an inline SVG of the detected card (Duo 2 / Quad 2 / Mini
Recorder / Mini Monitor / UltraStudio 4K Mini, with a generic fallback)
showing each connector in its real physical position. Click to select.
Used by recorders.html for SDI source selection.
2026-05-21 00:19:51 -04:00
f4a83eedc4 capture-manager: dynamic ffmpeg args from per-recorder codec config
Adds a VIDEO_CODECS / AUDIO_CODECS / CONTAINER catalogue and a
buildEncodeArgs() that composes -c:v / -c:a / -b:v / -b:a / -r / -ac / -f
from the recorder's saved settings. Master and proxy each get their own
codec stack, both honour the container format chosen in the UI, and the
S3 keys now use the actual container extension instead of hardcoded mov/mp4.
2026-05-21 00:19:00 -04:00
4c65753358 recorders route: accept full codec field set + node/port pinning
POST/PATCH now persist all new codec columns via a whitelist. /start
forwards every codec setting to the capture container as an env var. The
live-asset created during /start now uses the recorder's container ext
(.mov vs .mp4 etc.) instead of always assuming .mov.
2026-05-21 00:17:45 -04:00
0efef0d81b cluster route: fallback IP from request + /devices/blackmagic endpoint
Heartbeat handler now overrides 172.x docker bridge IPs with the
request's source address when the request itself came from a real LAN.
Adds GET /devices/blackmagic that flattens every node's capabilities so
the recorder UI can show a card picker spanning the whole cluster.
2026-05-21 00:16:36 -04:00
485af25d4a capture bootstrap: forward every codec env var to captureManager.start 2026-05-21 00:16:03 -04:00
3b4af6ef11 node-agent: prefer NODE_IP and skip docker bridge interfaces
In bridge mode the agent was reporting the container's 172.x address
because the first non-internal interface in os.networkInterfaces() was
docker0. Now honours NODE_IP, skips lo/docker*/br-*/veth*/etc, and
down-ranks the 172.16-31 range so real LAN IPs win. Also exposes the
detected IP on /health for the onboarding script to print.
2026-05-21 00:15:03 -04:00
40a66bae03 worker compose: run node-agent in host network mode
The agent now uses network_mode: host so os.hostname() and the network
interface list reflect the actual host instead of a per-container random
hex hostname (root cause of duplicate Zampp2 rows) and a 172.x docker
bridge IP. Also passes NODE_IP and BMD_MODEL through for explicit overrides.
2026-05-21 00:14:33 -04:00
049beb8818 recorders: add granular codec / container / port columns
Expands recorders with video bitrate, framerate, audio codec / bitrate
/ channels, container format, and a node_id/device_index pair so the UI
can pin SDI recorders to a specific node + DeckLink port instead of
relying on a flat "BM1/BM2" index. capture-manager.js consumes these via
env vars and builds ffmpeg args from them.
2026-05-21 00:14:11 -04:00
a39c9831c5 cluster: dedupe rows + enforce unique hostname index
Migration 004 wrapped table creation in IF NOT EXISTS, so deploys with
a pre-existing cluster_nodes table never picked up the inline UNIQUE
constraint and accumulated duplicate hostnames on every container
restart. This migration purges older duplicates and adds the unique
index idempotently so the ON CONFLICT (hostname) upsert finally works.
2026-05-21 00:14:01 -04:00
066b9b17d3 feat: expand GPU transcoding settings (extension, framerate, rc mode, audio) 2026-05-20 23:41:42 -04:00
629022ab5f fix(worker): use npm install instead of npm ci — no package-lock.json present 2026-05-20 23:29:15 -04:00
cc8ee63639 fix(node-agent): replace express with built-in http — no external deps needed 2026-05-20 22:59:03 -04:00
21d31f1678 mam-api: switch base image to node:22-slim (glibc) so host nvidia-smi binary runs 2026-05-20 19:19:33 -04:00
4d101bc812 mam-api: mount host nvidia-smi into container for GPU detection 2026-05-20 19:18:03 -04:00
1e4e2e436f mam-api: add NODE_IP env passthrough and NVIDIA GPU device reservation 2026-05-20 18:59:55 -04:00
74299629e6 feat: detect GPUs via nvidia-smi and populate cluster_nodes capabilities 2026-05-20 17:25:11 -04:00
a4b9b5be82 fix: prefer NODE_IP env var in getLocalIp() for Docker deployments 2026-05-20 16:16:09 -04:00
a926da1c30 feat: add settings key-value table migration 2026-05-20 15:57:23 -04:00
11e1de1cf8 feat: add S3 / Object Storage settings section 2026-05-20 15:55:34 -04:00
7032cee6b3 feat: call loadS3ConfigFromDb() on startup after migrations 2026-05-20 15:53:26 -04:00
02cfa68b92 fix(assets): replace static S3_BUCKET with getS3Bucket() for dynamic config 2026-05-20 15:49:40 -04:00
737e69d72f fix(upload): replace static S3_BUCKET with getS3Bucket() for dynamic config 2026-05-20 15:48:48 -04:00
ab504841c3 feat(settings): add S3 / object-storage settings routes (GET, PUT, test) 2026-05-20 15:48:14 -04:00
b1457f0aad feat(s3): dynamic DB-driven config with rebuildS3Client + Proxy export 2026-05-20 15:47:40 -04:00
beb58d3cd6 Add Settings nav link to sidebar 2026-05-20 15:07:36 -04:00
2f48d0243b Add Settings nav link to sidebar 2026-05-20 15:06:41 -04:00
cfdd0d1a55 Add Settings nav link to sidebar 2026-05-20 15:05:16 -04:00
0318d15c76 Add Settings nav link to sidebar 2026-05-20 15:02:09 -04:00
0433fc8805 fix(home): prevent bottom cutoff — safe center + remove min-height: 100% 2026-05-20 15:01:50 -04:00
777fa7fc2b Add Settings nav link to sidebar 2026-05-20 14:56:04 -04:00
53392608e5 Add Settings nav link to sidebar 2026-05-20 14:51:37 -04:00
b7c7bb1662 Add Settings nav link to sidebar 2026-05-20 14:50:02 -04:00
81d51577b8 Add Settings nav link to sidebar 2026-05-20 14:47:54 -04:00
dd1c40c9c8 Add Settings nav link to sidebar 2026-05-20 14:45:49 -04:00
7c37eababd Add Settings nav link to sidebar 2026-05-20 14:42:46 -04:00
6d371beda9 Add Settings nav link to sidebar 2026-05-20 14:39:36 -04:00
53805f2c59 Add Settings nav link to sidebar 2026-05-20 14:37:38 -04:00
74e87359e2 Add Settings nav link to sidebar 2026-05-20 14:36:03 -04:00
5e2683aba7 Add Settings nav link to sidebar 2026-05-20 14:32:34 -04:00
fe921d0444 Add Settings nav link to sidebar 2026-05-20 14:29:41 -04:00
12a52c40c9 feat: settings.html — GPU transcoding, SDI capture routing, AMPP integration 2026-05-20 14:21:18 -04:00
7486539b32 feat: worker compose adds capture profile (BMD DeckLink) and GPU env vars 2026-05-20 14:19:21 -04:00
a55c182be9 feat: docker-compose.gpu.yml overlay — NVIDIA GPU pass-through + NVENC worker 2026-05-20 14:19:02 -04:00
76281b7564 feat: GPU worker Dockerfile using CUDA base with ffmpeg NVENC support 2026-05-20 14:18:55 -04:00
1725ec1de9 feat: settings routes for hardware inventory, GPU transcoding, capture service URL 2026-05-20 14:18:43 -04:00
dd3c2894f6 feat: cluster heartbeat stores capabilities (GPU/BMD hardware detection) 2026-05-20 14:18:22 -04:00
a941f609f0 feat: node-agent detects NVIDIA GPUs and Blackmagic DeckLink cards, reports in heartbeat 2026-05-20 14:18:07 -04:00
86d2960b60 feat: add capabilities column to cluster_nodes (migration 005) 2026-05-20 14:17:44 -04:00
28a97e2ba3 fix(deploy): test-api.sh — skip capture 404 (sidecar/idle mode is normal) 2026-05-20 13:56:26 -04:00
5161644205 fix(capture): handle non-JSON responses from capture service gracefully 2026-05-20 13:55:06 -04:00
96ef569720 fix(deploy): test-api.sh — fix login POST, sequences 400, settings/ampp path 2026-05-20 13:54:30 -04:00
115c7340ee fix(deploy): test-api.sh — fix curl -f flag swallowing 4xx, fgrep for literal [, correct /auth/me path 2026-05-20 13:52:59 -04:00
4dd377e28d feat(cluster): add GET /:id/ping to probe node agent reachability and latency 2026-05-20 13:49:56 -04:00
3128ab43b3 feat(deploy): test-api.sh — API smoke test covering all major endpoints 2026-05-20 13:49:35 -04:00
0b49f28a80 feat(deploy): onboard-node.sh — one-command cluster node provisioning 2026-05-20 13:49:05 -04:00
0b255e063f feat(deploy): docker-compose.worker.yml for cluster worker nodes 2026-05-20 13:48:27 -04:00
c5a358888b feat(node-agent): heartbeat agent — CPU/mem stats, health endpoint, bearer token auth 2026-05-20 13:48:18 -04:00
0bc1ac9161 feat(node-agent): add Dockerfile 2026-05-20 13:47:57 -04:00
feb78b8bcb feat(node-agent): add package.json for cluster heartbeat agent 2026-05-20 13:47:53 -04:00
86b80e043e fix: correct sidebar logo alt text in projects.html (Z-AMPP → Wild Dragon) 2026-05-20 09:17:05 -04:00
398ee8b932 fix: standardize sidebar icons in containers.html (containers/cluster/logout) 2026-05-20 09:15:14 -04:00
44277bced6 fix: standardize sidebar icons in cluster.html (containers/cluster/logout) 2026-05-20 09:14:11 -04:00
ea04b8f9e1 fix: standardize sidebar icons in editor.html (containers/cluster/logout) 2026-05-20 09:12:02 -04:00
ede55a8a5f feat(plugin): add seq info bar, export panel, and 2-row action bar styles 2026-05-20 00:38:59 -04:00
9ba3bf6f83 feat(plugin): add seq info bar, hi-res button, export panel to panel UI
- Active sequence info bar shows current Premiere sequence name
- Import Proxy / Hi-Res split buttons replace single Import button
- Export panel (hidden) slides in with seq name, project picker, clip count
- Export Timeline button in second action row triggers panel
2026-05-20 00:38:09 -04:00
16888d62e2 feat(plugin): proxy URL fix, hi-res import, import path tracking, timeline export
- Fix: /stream returns relative URL — prepend serverUrl before Node.js download
- Add: importAssetHires() calls /assets/:id/hires for original file
- Add: saveImportMapping() stores tempPath→assetId in localStorage so
  timeline export can match Premiere clips back to MAM assets
- Add: startExportTimeline() reads active sequence via exportTimelineData(),
  shows export panel with seq name + clip count
- Add: confirmExportTimeline() resolves paths→assetIds, upserts sequence,
  PUT /sequences/:id/clips
- Add: refreshCurrentSequenceInfo() shows active sequence name in info bar
2026-05-20 00:37:34 -04:00
5bb22c17c8 feat(plugin): add exportTimelineData() and getProjectItems() to ExtendScript
exportTimelineData() walks all video tracks in the active sequence and
returns clip source/timeline frame positions + file paths so the panel JS
can map them back to MAM asset IDs for timeline export.

getProjectItems() enumerates all ProjectItems with paths — useful for
rebuilding the import mapping after a Premiere restart.
2026-05-20 00:35:18 -04:00
a855ea7885 feat(api): add GET /assets/:id/hires endpoint for original file download
Returns presigned S3 URL + filename/ext/file_size for the original
hi-res source so the Premiere plugin can download and import it.
2026-05-20 00:34:18 -04:00
f7aedb1936 fix(ui): normalize sidebar — add Containers + Cluster to all 8 remaining pages 2026-05-20 00:22:57 -04:00
879c547e08 home: add Containers + Cluster cards, fix Editor link, extend loadStats 2026-05-20 00:02:48 -04:00
0c761d553c feat(ui): cluster node registry page — health, CPU, memory, deregister 2026-05-19 23:58:17 -04:00
e3cdf70883 feat(ui): Docker container management page — restart, stop, start 2026-05-19 23:57:23 -04:00
1e9710ce0c feat(editor): thumbnail images in media panel; Del=ripple, Shift+Del=lift 2026-05-19 23:56:23 -04:00
090452969c feat(api): register system + cluster routes; add self-heartbeat on startup 2026-05-19 23:50:19 -04:00
66844b93d3 feat(cluster): node registry API — heartbeat, list, deregister 2026-05-19 23:46:16 -04:00
bd8b492ff6 feat(db): cluster_nodes table for multi-server registry 2026-05-19 23:46:06 -04:00
910a906600 feat(system): Docker container management via Unix socket 2026-05-19 23:46:03 -04:00
89771a2380 feat(timeline): ripple delete on Del, extract/lift on Shift+Del 2026-05-19 23:45:41 -04:00
a5823effe9 feat(assets): add ?redirect=1 to thumbnail endpoint for img src use 2026-05-19 23:44:17 -04:00
36e668455f feat(editor): media-panel search, sequence duration badge, parseFloat guard
- Media panel gains a search input that filters the clip list in real time
  (case-insensitive match on display_name / filename)
- Timeline toolbar shows total sequence duration (e.g. 00:05:23;14) and
  frame rate, updated whenever clips change or a sequence is opened
- parseFloat() guard on state.seq.frame_rate so a NUMERIC string from
  Postgres never leaks into Timeline.render() / applyHistory()
2026-05-19 23:27:25 -04:00
4d0e715982 fix(sequences): coerce NUMERIC frame_rate to float in all API responses
node-postgres returns NUMERIC columns as strings by default.  Add a
mapSeq() helper that parses frame_rate to a JS float before any response
is sent.  Affected routes: GET /, POST /, PUT /:id, GET /:id.
2026-05-19 23:24:16 -04:00
bfc2649909 feat(editor): fps-aware render, FPS selector in new-seq dialog, keyboard help overlay
- openSequence() and applyHistory() now pass state.seq.frame_rate to
  Timeline.render() instead of hardcoded 59.94 — clips render on the
  correct frame grid for every sequence
- New-sequence panel gains a frame-rate selector (23.976 / 24 / 25 /
  29.97 / 30 / 50 / 59.94 / 60); createNewSequence() posts frame_rate
  to the API
- Press ? to open a keyboard shortcut help overlay; Escape to close
2026-05-19 23:20:10 -04:00
81c771a7be feat(jobs): replace polling with SSE EventSource for live job updates
- Drop setTimeout/scheduleRefresh loop in favour of EventSource on
  /api/v1/jobs/events (pushes every 2 s from the server)
- Refresh dot turns green on open, goes grey + "Reconnecting…" on error
  (EventSource auto-reconnects natively)
- Type-filter is now applied client-side against the full SSE payload so
  the dropdown change no longer triggers an HTTP round-trip
- killJob / retryJob / clearCompleted no longer call loadJobs(); the next
  SSE push (≤2 s) reflects the change automatically
2026-05-19 23:17:18 -04:00
16b8530d43 fix: include filename in search; add POST /cleanup-live to recover stuck live assets 2026-05-19 23:10:51 -04:00
8a2ef38326 fix: bulk-fetch jobs by state (no N+1 getState()); add GET /events SSE stream 2026-05-19 23:09:47 -04:00
d382c6b559 fix: EDL export uses sequence frame_rate for timecode (29.97/59.94 DF, others non-drop) 2026-05-19 23:09:17 -04:00
d21c61a8b2 fix: addClip uses s.fps instead of hardcoded TC.secondsToFrames (59.94) 2026-05-19 23:08:13 -04:00
b175eaf54c fix: clean up temp segment directory after conform job finishes 2026-05-19 23:06:54 -04:00
90bb0769e5 fix: correct editor service port typo (47435 → 7435) 2026-05-19 23:06:35 -04:00
07ded22f8e feat: video proxy streaming endpoint + editor drag-and-drop to timeline
- mam-api: add GET /api/v1/assets/:id/video streaming proxy that fetches
  from RustFS/S3 and pipes to browser with range-request support, bypassing
  direct S3 access from Chrome
- mam-api: fix /stream route to return /video proxy URL for both proxy and
  original-mp4 assets; return null cleanly for non-playable sources
- s3/client: set requestChecksumCalculation/responseChecksumValidation to
  WHEN_REQUIRED to suppress x-amz-checksum-mode header on signed URLs
- editor: fix loadSourceAsset to set state.sourceAsset even when no proxy
  exists (info toast instead of bail-out) so Insert/Overwrite still work
- editor: add drag-and-drop from media panel to timeline — items are now
  draggable, timeline container accepts drops and calls Timeline.addClip
  with the asset at playhead position
- editor: add tl-drag-over CSS highlight on timeline during drag
2026-05-19 22:47:33 -04:00
5019563c38 fix: override user-select:none on draggable media items to fix drag initiation
EditorInterface root div has select-none (user-select:none) applied globally
to prevent text selection during editing. Chrome/Safari refuse to start HTML5
drag-and-drop on elements that inherit user-select:none, which is why no
ghost image appeared, cursor never changed, and no dragstart events fired.

Fix: add select-text (user-select:text) to both draggable divs in
MediaThumbnail (list view and grid view). This overrides the inherited none
specifically on the elements that need to be dragged, without changing the
global UX behavior of the editor.
2026-05-19 14:45:47 -04:00
fd6693ee17 fix: remove ContextMenuTrigger asChild from draggable elements to fix drag initiation
With asChild, Radix merges its pointer event handlers directly onto the
draggable div. This interferes with browser drag gesture initiation,
resulting in no ghost image and no drag events firing.

Fix: remove asChild so ContextMenuTrigger renders its own span (with
display:contents to preserve layout). Radix handlers now live on the
ancestor span, not the draggable div. Right-click still bubbles up to
trigger the context menu correctly.

Also add draggable={false} to <img> elements inside draggable divs
to prevent browser native image drag from competing with the parent.
2026-05-19 13:00:09 -04:00
18c4779f58 fix: add onDragEnd to AssetsPanel to clear isDragging state
- Import endDrag from useUIStore
- Add handleItemDragEnd callback that calls endDrag()
- Add onDragEnd? prop to MediaThumbnail interface
- Wire onDragEnd={onDragEnd} to both draggable divs (list & grid views)
- Pass onDragEnd={handleItemDragEnd} when rendering MediaThumbnail
- Without this, isDragging was permanently stuck at true after every drag
2026-05-19 11:20:29 -04:00
aec55fea83 fix: await onDropMedia, fix stale closure deps, surface errors in TrackLane
- Import ActionResult type from @openreel/core
- onDropMedia prop type now returns Promise<ActionResult> | void
- handleDrop now awaits onDropMedia so failures are visible
- Replace silent catch with console.error + toast.error on failure
- Add allTracks, playheadPosition, snapSettings to handleDrop useCallback deps
  to fix stale closure bug (calculateSnap was using stale snap/track state)
2026-05-19 11:12:09 -04:00
76e6568ac6 fix: await handleDropMedia and surface clip-add errors in Timeline
- handleDropMedia now returns the ActionResult from addClip/addClipToNewTrack
- The tracksRef onDrop handler now awaits handleDropMedia so errors aren't silently lost
- Replaces the swallowed catch block with a toast.error + console.error on failure
- This makes clip-add failures visible instead of silently doing nothing
2026-05-19 11:11:17 -04:00
43a17ecd14 feat(jobs): add Retry button for failed jobs with an associated asset 2026-05-19 00:54:47 -04:00
de4cb1b6a0 fix(tokens): add version cache-busters to api.js and topbar-strip.js 2026-05-19 00:51:47 -04:00
4407e8ce6d fix(edit): add version cache-busters to api.js and topbar-strip.js 2026-05-19 00:48:50 -04:00
36f165807a fix(topbar-strip): escape pageName() output before innerHTML insertion 2026-05-19 00:46:48 -04:00
76b0a5e05e fix(recorders): escape d.error in renderProbeResult to prevent XSS 2026-05-19 00:46:12 -04:00
9c83698b81 feat: inline rename on double-click in library asset cards
Double-clicking a clip name in the library shows an in-place text input.
Enter/blur commits the new display_name via PATCH; Escape cancels.
Clicking the card body or action buttons still work normally.
2026-05-19 00:41:43 -04:00
f39d086bc8 fix: add cache-buster version strings to api.js and topbar-strip.js in home.html 2026-05-19 00:39:24 -04:00
1e4fcb62f5 feat: add status filter chips and sort controls to library
Adds an "All / Ready / Processing / Error / Live" pill filter row and
a "Newest / Oldest / Name / Duration / Size" sort selector to the asset
toolbar. Both operate client-side on the loaded asset list so there is
no additional API overhead. State resets to "All / Newest" whenever a
different project or bin is selected.
2026-05-19 00:35:23 -04:00
08e8377309 fix: bump api.js cache-buster to v=6 in upload.html 2026-05-19 00:33:11 -04:00
280fc9dff2 fix: XSS in renderTags and stale api.js version in player.html
Tag values were inserted into innerHTML unsanitized — a tag containing
HTML would execute as markup. Switch to DOM-only construction for the
tag badges. Also bump api.js cache-buster to v=6.
2026-05-19 00:30:54 -04:00
f1e0453b0a fix: bump api.js cache-buster to v=6 in capture.html 2026-05-19 00:28:50 -04:00
9f7cb91cc2 fix: prevent JS injection via token name in confirmRevoke onclick
Token names containing single quotes (e.g. "O'Brien's key") broke the
onclick attribute string by closing the JS string literal early.
Apply JSON.stringify+esc pattern so name is safely embedded as a
JSON string literal instead of a raw single-quoted string.
2026-05-19 00:27:31 -04:00
f8e42b886d fix(sequences): apply correct 59.94 DF framesToTC to EDL export
sequences.js had the same `if (rem >= DROP)` bug as timecode.js — any
frame ≥ 4 in the first non-drop minute of each 10-minute group would
produce a timecode offset by one minute. EDL files exported from the
editor would have wrong in/out points for nearly every event.

Applies the FRAMES_FIRST_MIN (3600) boundary check fix, matching the
correction already made to services/web-ui/public/js/timecode.js.
2026-05-19 00:22:17 -04:00
d18fa2f761 feat(library): add Retry button for error-status assets in library grid
Error assets now show an amber circular-arrow action button on hover.
Clicking it calls POST /api/v1/assets/:id/retry, resets status to
'processing', and refreshes the grid — no manual DB intervention needed
when a proxy job fails.
2026-05-19 00:20:19 -04:00
130906ef42 feat(api.js): add retryAsset() helper for POST /assets/:id/retry 2026-05-19 00:17:39 -04:00
d3e12deb18 feat(assets): add POST /:id/retry to re-queue errored assets
Assets stuck in status='error' had no recovery path without manual DB
edits. Adds a retry endpoint that re-dispatches the proxy job, which
chains into thumbnail generation automatically and restores the asset
to 'processing' → 'ready' without operator intervention.
2026-05-19 00:17:00 -04:00
2bb731c7fc fix(users): prevent JS injection in delete onclick handlers for users/groups
confirmDeleteUser and confirmDeleteGroup were building onclick handlers
like onclick="confirmDeleteUser('id','NAME')" using esc() which doesn't
escape single quotes.  Usernames or group names containing ' would break
the JS string; a crafted value like `'; alert(1)//` is stored XSS.

Fix: use JSON.stringify(value) to produce a properly-escaped double-quoted
JS string literal, then esc() to HTML-encode the surrounding quotes for
safe embedding in the HTML attribute.  Same technique now used in both
renderUsers() and renderGroups().
2026-05-19 00:11:06 -04:00
1e8cde81be fix(projects): prevent JS injection via bin names in onclick handlers
binCard() was building onclick="renameBinPrompt('id', 'NAME')" by
calling esc() then .replace(/'/g, "\\'").  The problem: esc() converts
' to &#39;, so the replace never fires on raw single quotes.  When the
HTML parser evaluates the attribute it decodes &#39; back to ', breaking
the JS string — and for injected payloads like `'; alert(1)//` this is
stored XSS.

Fix: use JSON.stringify(b.name) to produce a properly-escaped double-
quoted JS string literal, then esc() to HTML-encode the surrounding
double-quotes for safe embedding in the HTML attribute.
2026-05-19 00:09:49 -04:00
58e2e539f8 fix(upload): scope original S3 keys under assetId to prevent collisions
Both /init and /simple were keying originals as
`originals/${projectId}/${filename}`.  Two uploads of the same filename
into the same project would share a key — the second upload would silently
overwrite the first file in S3 while both assets remained in the DB with
the same original_s3_key.

Changed to `originals/${assetId}/${filename}` (matching the proxies/
convention) so every asset has its own unique S3 prefix.
2026-05-19 00:08:13 -04:00
4f8964e807 fix(tokens): add requireAuth middleware to token routes
Token CRUD endpoints had no authentication guard.  Without it,
unauthenticated requests could reach the handler — GET would return
empty results silently, and POST could attempt to insert a token with
user_id = NULL.  All other route files in this codebase apply
requireAuth explicitly; tokens.js was simply missing it.
2026-05-19 00:07:41 -04:00
0ea8d7ce33 fix(timeline): cap right-trim at source asset boundary
When duration_ms is known, dragging the right-trim handle past the end
of the source clip could push timeline_out_frames beyond what the source
material covers.  Cap the delta so neither timeline_out_frames nor
source_out_frames can extend past the available source frames.

Also changed assetFrames fallback from origSrcOut (prevents any extension
when duration is unknown) to null, so the guard is simply skipped when
we don't have duration metadata.
2026-05-19 00:02:34 -04:00
3c689ccddf fix(timecode): correct framesToTC for all frames beyond position 3
The previous algorithm used `if (rem >= DROP)` (i.e. rem >= 4) to decide
whether to advance to the next minute group.  This fired immediately at
frame 4, still inside minute 0 of the 10-minute non-drop group, producing
00:01:00;00 for what should be 00:00:00;04.  Every timecode display in
the editor was wrong for any position past the first four frames.

Each 10-minute block has one 3600-frame non-drop minute followed by nine
3596-frame drop minutes.  The fix checks `rem < FRAMES_FIRST_MIN` (3600)
to identify the non-drop minute, then subtracts it before dividing into
drop-minute slots.  Frame labels within drop minutes are shifted by DROP
(+4) so the first usable label is :00;04 as per SMPTE 12M.
2026-05-19 00:01:18 -04:00
b23700f30a fix(recorders): use already-imported uuidv4 instead of dynamic import
Dynamic `(await import('uuid')).v4()` inside the /start route handler
re-imports the module every call (though Node caches it). uuidv4 is
already imported at the top of the file.
2026-05-18 23:56:00 -04:00
0f37d01b2d fix(editor): keyboard tool shortcuts now actually switch the active tool
V/C/H key shortcuts called updateToolbarActive() which only updated button
visual state — Timeline.setTool() was never called so the cursor stayed on
the previous tool. Fix by calling Timeline.setTool() inside updateToolbarActive.

Also bump api.js reference to ?v=6 to match other pages.
2026-05-18 23:53:38 -04:00
fb3b998cfd fix(worker/thumbnail): mark asset ready even when thumbnail extraction fails
If the thumbnail job throws (network blip, ffmpeg error, short clip), the
asset was left stuck in status='processing' indefinitely. Since the proxy
already exists and the asset is playable, set status='ready' in the catch
block before re-throwing so BullMQ can still record the failure.
2026-05-18 23:51:04 -04:00
ff892a1ad5 fix(capture): use duration_ms field for recent captures duration display
The asset schema stores duration as duration_ms (milliseconds).
renderRecent() was checking c.duration (always undefined) so duration
always showed as '—'. Fix to use c.duration_ms / 1000.
2026-05-18 23:50:05 -04:00
08e5ba6298 fix(jobs): fetchJobs → loadJobs, add credentials to inline api helper
killJob() referenced fetchJobs() which is undefined — the correct name is
loadJobs(). Also the inline api() wrapper was missing credentials:'include'
so any API call on the jobs page would fail with a 401 in prod.
2026-05-18 23:48:56 -04:00
e472075087 fix(library): evict stale thumb URL on image load error, re-observe for retry
When a signed S3 URL expires the img fires onerror. Previously the stale URL
stayed in thumbCache so the broken image would persist. Now we delete the cache
entry, clear the loaded class, and re-add the element to the IntersectionObserver
so the next time it scrolls into view a fresh signed URL is fetched.
2026-05-18 23:46:12 -04:00
e6314be92d fix(assets): strip internal full_count column from list response
The window function COUNT(*) OVER() leaks `full_count` on every row.
Strip it before sending so callers only see actual asset fields.
2026-05-18 23:44:14 -04:00
660afb94bb feat(editor): show fps/codec/resolution/duration in media panel asset list
- Add two-line layout to media panel items: name on top, metadata below
- fmtMs() converts duration_ms to MM:SS or HH:MM:SS for display
- Meta line shows resolution · codec · fps · duration, skipping null fields
- Assets with no extracted metadata (no proxy yet) show name only
- Active item meta line inherits accent color at reduced opacity
2026-05-18 23:37:56 -04:00
508cf8d41b feat(recorders): add Edit Recorder panel with PATCH support
- Edit (pencil) button appears on idle recorder cards; hidden while recording
- openEditPanel() pre-populates all form fields from existing recorder state
- openPanel() resets editingId and restores "New recorder" defaults
- closePanel() clears editingId and removes any stale probe result
- handleSaveRecorder() dispatches PATCH /recorders/:id in edit mode, POST otherwise
- Fix field name bugs in create path: codec→recording_codec, resolution→recording_resolution,
  proxy_config object→proxy_enabled/proxy_codec/proxy_resolution flat fields
- Badge in card now reads rec.recording_codec (correct DB field) instead of rec.codec
- Bump api.js cache-buster to v=6
2026-05-18 23:35:16 -04:00
79d44826fe feat(api.js): add patchRecorder() helper for PATCH /recorders/:id 2026-05-18 23:32:33 -04:00
7260b188c5 fix: remove dead DB UPDATE calls in conform worker
The jobs table row no longer exists for conform jobs (POST /jobs/conform
now goes directly to BullMQ). The UPDATE queries were no-ops (WHERE id = NULL)
so they're safe to remove. BullMQ tracks completed/failed status itself.
2026-05-18 23:28:13 -04:00
e895a2f2df fix: show duration overlay on asset cards using duration_ms
asset.duration is not a DB field — it's duration_ms (milliseconds).
Divide by 1000 before passing to formatDuration() which expects seconds.
2026-05-18 23:27:03 -04:00
a9ca7be1d5 feat: add PATCH /recorders/:id endpoint to edit recorder settings
Allows updating name, source_type, source_config, recording_codec,
recording_resolution, proxy_enabled, proxy_codec, proxy_resolution,
and project_id. Blocked while the recorder is actively recording.
2026-05-18 23:24:27 -04:00
29b5910fff feat: migrate editor sequences schema into auto-run migrations directory
Moved from schema_patch_editor.sql. All statements are idempotent
(IF NOT EXISTS / DO $$ BEGIN blocks) so safe to re-apply.
2026-05-18 23:23:33 -04:00
ffad0051f9 feat: migrate groups/tokens schema into auto-run migrations directory
Moved from schema_patch_groups_tokens.sql. All statements are idempotent
(IF NOT EXISTS / CREATE INDEX IF NOT EXISTS) so safe to re-apply.
2026-05-18 23:23:23 -04:00
717fdcd611 feat: extract and store fps/codec/resolution/duration_ms from source file
Uses getMediaInfo (ffprobe) on the downloaded original before transcoding.
Populates the asset record so the library can display accurate metadata.
2026-05-18 23:22:56 -04:00
817eaff8b1 feat: add getMediaInfo to executor.js using ffprobe JSON output
Exposes video stream fps/codec/resolution and container duration/size
so the proxy worker can populate asset metadata after transcoding.
2026-05-18 23:22:26 -04:00
48b69879cb fix: conform route broken SQL — remove dead DB insert, use BullMQ directly
The POST /conform route was inserting into the jobs table with non-existent
columns (project_id, metadata) and an invalid enum value ('pending'). Since
GET /jobs reads entirely from BullMQ, the DB insert was both incorrect and
redundant. Now we just enqueue the BullMQ job and return its ID.
2026-05-18 23:22:14 -04:00
596f755a6c fix: remove stray Wild Dragon brand remnant from editor.html 2026-05-18 23:14:14 -04:00
656c820638 feat: wire editor.html as primary editor, fix its sidebar/branding
- All pages: Editor nav link now points to editor.html (in-house NLE)
- Removes the :47435 OpenReel resolver script from all pages
- editor.html: canonical Z-AMPP sidebar (all 10 nav items, correct icons)
- editor.html: Z-AMPP brand logo, removes Wild Dragon SVG mark
- editor.html: removes Google Fonts import
- editor.html: adds auth-guard.js
2026-05-18 23:11:53 -04:00
910bbf8d3f merge: bring NLE editor pages (editor.html, timeline.js, timecode.js) from main 2026-05-18 23:02:51 -04:00
e8e26dd4d8 fix: remove Google Fonts, fix editor link to :47435, fix page titles
- Remove @import Google Fonts from common.css (was blocking CSS on LAN)
- Update Editor nav link on all pages to dynamically resolve to :47435
  (OpenReel SPA) using inline script so it works on any hostname
- Fix page titles from Wild Dragon -> Z-AMPP across all pages
- Resolver: <a href="#" id="editor-nav-link"> + IIFE sets href at load
2026-05-18 22:56:51 -04:00
1f31d1037d merge: bring sequences/auth/admin backend + auth-guard frontend into fix/library-and-signal-indicator 2026-05-18 21:25:36 -04:00
6bd97a2a03 feat(meme): Token Pricing page with usage chart + AMPP-style Z-AMPP SVG wordmark on home + Tokens tile/nav everywhere 2026-05-18 11:05:30 -04:00
1f4750a1b4 feat(meme): Token Pricing page — gentle ribbing of metered-compute broadcast platforms 2026-05-18 10:56:55 -04:00
c781a469f3 feat(recorders): align with home/projects aesthetic — brand-blue gradient, refreshed cards, tile selectors, slide-panel polish 2026-05-18 10:49:46 -04:00
32bce2e263 feat(editor): splice tool (B/S key + Split button), thumbnail hydration via signed URL, enable Export (draft for now) 2026-05-18 10:25:53 -04:00
3ae150ad53 feat(editor-native): repoint Editor links from openreel (:47435) to in-house /edit.html 2026-05-18 10:18:14 -04:00
2e1bcd655f feat(editor-native): Phase A — single-track editor logic (asset library, preview, in/out markers, drafts) 2026-05-18 10:17:31 -04:00
beb8f31674 feat(editor-native): Phase A — single-track editor shell (HTML scaffold) 2026-05-18 10:16:12 -04:00
a3596265eb feat(brand+home): swap sidebar to Wild Dragon logo, add favicon everywhere, fix home counters (status= not state=) 2026-05-18 10:13:08 -04:00
0e48a8d70f feat(brand): add Wild Dragon logo + favicon 2026-05-18 14:11:29 +00:00
5b557418f8 feat(home): drop Settings tile (not workspace nav; access via topbar gear) 2026-05-18 10:07:53 -04:00
81257b5201 feat(nav): add Home + Projects to sidebar across all pages; redirect login to home.html; bump image cache to v=hardhat3 2026-05-18 10:03:32 -04:00
623e38ae27 feat(home): redesign in AMPP layout — wide preview cards on brand-blue gradient, hardhat avatar centerpiece 2026-05-18 10:01:37 -04:00
1c7329ef35 feat(brand): cleaned hardhat photo (stray sketch lines removed via blob isolation) 2026-05-18 09:59:53 -04:00
efebf38271 feat(projects): project + bin management page (CRUD on /api/v1/projects + /api/v1/bins) 2026-05-18 09:58:34 -04:00
b9879d76b7 feat(home): add Home landing page modeled on AMPP — hardhat hero + workspace tiles 2026-05-18 09:56:20 -04:00
230944fc4b fix(recorders): kill the timer/status flap by computing live values inline + skipping unchanged DOM rebuilds 2026-05-18 09:47:03 -04:00
57116dde42 feat(recorders): stable elapsed timer + live HLS preview on the card; optimistic signal default 2026-05-18 09:40:42 -04:00
57c3871cc1 feat(brand): hardhat photo + Z-AMPP name on every page (library, upload, capture, jobs, recorders, settings) 2026-05-18 09:28:49 -04:00
a9c16d9509 fix(capture): wire bootstrapAutoStart() + add missing captureManager/MAM_API_URL/server (regression from earlier conflict resolution) 2026-05-18 09:25:55 -04:00
d8229e6f3f feat(probe): pre-flight reachability + actionable SRT/RTMP error messages 2026-05-18 07:57:48 -04:00
f181eb6d34 fix(splash): bust image cache + correct aspect ratio so the hardhat photo loads after redeploy 2026-05-18 07:45:59 -04:00
7d76f9c549 feat(growing-files): Phase 1 - live HLS preview during recording
While a recorder is running, the capture container tees an HLS
stream into /live/<assetId>/ alongside the ProRes master upload.
The asset row is pre-created at recorder start with status='live'
so the clip appears in the library immediately. /api/v1/assets/:id/stream
returns the HLS playlist URL until recording stops, then proxy.

* docker-compose: shared wild-dragon-live mount on api/capture/web-ui
* migration 001-add-live-status: idempotent ALTER TYPE for asset_status
* mam-api: runMigrations() on boot; recorders.js pre-creates live asset
  + passes ASSET_ID; assets.js POST upserts on existing live row instead
  of inserting a duplicate, and stream route returns HLS for live assets
* capture: parallel HLS ffmpeg into /live/<assetId>/; ASSET_ID env
* web-ui: nginx serves /live/, preview.js loads hls.js, LIVE badge added
2026-05-18 07:29:50 -04:00
Zac
6a8e4ac250 fix(editor): show loading banner during auto-import so Edit feels responsive
Clicking Edit on the preview modal worked, but the user only saw an empty editor for ~25s while the recovery + format-chooser cycle ran and the bridge waited for a stable project. Looked broken. Now: a centered top banner appears the moment the bridge detects ?asset=, reads Loading clip from Z-AMPP MAM, switches to Clip ready in media bin on success, or surfaces the failure. Project-stability gate tightened from 1500ms to 600ms so the import lands sooner.
2026-05-17 22:44:08 -04:00
Zac
e390f0efab fix(editor): asset auto-import now lands cleanly into the media bin
Three problems were blocking the round-trip. Each fixed.

* MediaBridge.importFromURL went through the file-import service but not the Zustand store, so the media bin stayed empty. mam-bridge now calls window.__projectStore.getState().importMedia(file) which is what the actual UI uses. project-store.ts exposes useProjectStore on window for that hook.
* rustfs serves the proxy with content-type application/octet-stream; the editor rejects with DECODE_ERROR on that mime. Bridge now forces video/mp4 (or audio/wav, video/webm, etc.) based on the asset filename.
* The Recover Your Work modal and the Welcome tour blocked editor initialization. Bridge now auto-clicks Start Fresh and Skip Tour (alongside the format chooser), and waits 1.5s of project-id stability before calling importMedia so it does not get clobbered by the project-replacement cycle. One-shot guard prevents duplicate imports.
2026-05-17 22:20:49 -04:00
Zac
b68f0c6aba feat(editor): integrate openreel-video as services/editor with MAM hooks
Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
2026-05-17 21:44:37 -04:00
Zac
562881f0db fix(jobs): stall detection + manual kill button so 5h-stuck actives can't happen
A thumbnail job from earlier stayed 'active' for 6+ hours: worker was restarted at 70% progress, BullMQ left it in the active set, and there was no stall reaper because the worker was created with only the default options.

Worker now passes stalledInterval: 30000, lockDuration: 60000, lockRenewTime: 15000, maxStalledCount: 1 to the Worker constructor. If a run dies, BullMQ reclaims the job back to waiting within 30s and a 'stalled' event is logged. Otherwise the lock is renewed mid-job.

Jobs UI gains a 'Kill' button per row next to Details. Calls DELETE /api/v1/jobs/:id which already removes the job from Redis. Use it on any row that looks stuck.
2026-05-17 19:10:19 -04:00
Zac
e441176961 feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.

Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).

New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.

Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.

Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:05:22 -04:00
Zac
bab24e156a feat(recorders): probe sources + reflect real signal in main status
Two things that together stop bogus URLs from masquerading as a recording:

PROBE BUTTON in the New Recorder panel. Before you commit to record, hit Probe Source - the capture container runs ffprobe with a 10s timeout against the URL and returns the parsed streams. UI shows green Signal Detected with codec/resolution/fps/audio, or red No Signal Detected with the actual ffprobe error message. For SDI it lists DeckLink devices. Listener-mode sources cannot be probed standalone (would block waiting for a publisher) and the UI says so.

MAIN STATUS LABEL ON THE RECORDING CARD now mirrors the live signal instead of hardcoding Recording. So a recorder pointed at a dead URL goes Connecting... -> Connection error (red) instead of looking like everything is fine. When frames actually start arriving the label flips to Recording (blue) and the dot turns blue. If a previously-good stream drops the label switches to Signal lost (red).

API:
* capture: POST /capture/probe runs ffprobe and returns { ok, streams, format, error? }
* mam-api: POST /api/v1/recorders/probe proxies through to the capture sidecar with a 15s outer timeout
2026-05-17 18:39:21 -04:00
Zac
f2b8d5dc4b feat(splash): transparent PNG so the subject composites cleanly
The source image had a black border baked in. Knocked out the dark pixels into an alpha channel so the figure now floats on whatever surface is behind it — the dark gradient on the splash, the panel surface on the loading indicator, anywhere.

Pipeline: source -> resize 1200w -> python/PIL alpha-from-luminance with soft 22-55 luminance ramp -> 8-bit RGBA PNG (267KB).
2026-05-17 18:39:21 -04:00
Zac
349bc5a41d feat: multi-select + bulk move/copy/delete, brand blue, hardhat loader
* Library cards now show a checkbox on hover (and persistent when selected). Click checkbox = toggle, shift-click = range. Plain click on a card with an active selection extends/shrinks the selection instead of opening preview. Floating pill at the bottom shows count + Move / Copy / Delete / Clear. Move + Copy open a tiny bin picker (current project, default to current bin).

* mam-api/routes/assets.js: PATCH /:id now also accepts bin_id (null = move out of bin). New POST /:id/copy makes a reference-copy of the asset row (same S3 keys, new id) into the target bin/project.

* api.js: moveAsset(id, binId) and copyAsset(id, {binId, projectId}) helpers.

* All accent tokens swapped from the amber oklch(76% 0.178 52) to the Wild Dragon signature blue oklch(55% 0.20 266) = #1f3ad0 ish. Login splash + first-load splash + signal-receiving + button primary all picked it up automatically through common.css.

* Loading indicator across the app uses the AMPP Safe hardhat photo gently pulsing with a tiny blue dot underneath. .ampp-loading component lives in common.css with --sm / --xs / --inline variants. Replaces the plain "Loading assets…" empty state in index.html.
2026-05-17 14:48:34 -04:00
Zac
f99f07e0e7 feat: AMPP Safe splash on login + first-visit overlay
Adds the BMG-branded "AMPP Safe" hardhat photo as the visual identity for the auth + first-load surfaces.

* services/web-ui/public/img/ampp-safe.jpg (52 KB, 1200w optimized JPEG)
* services/web-ui/public/login.html: full redesign as a two-column hero + sign-in panel. Hero shows the hardhat photo full-bleed with a subtle AMPP Safe pill badge and broadcast-safe caption. Login + first-run admin setup forms unchanged functionally.
* services/web-ui/public/index.html: brief first-visit splash overlay (~1.4s) using the same image. Dismisses to the library and uses sessionStorage so it only shows once per session.
2026-05-17 13:10:47 -04:00
Zac
72545126c4 fix: delete asset actually deletes
Trash icon in the library was firing PATCH /assets/:id with {status:"deleted"}. The PATCH route only accepts display_name/tags/notes so it returned "No fields to update" and the asset stayed put.

* api.js: add deleteAsset(id, {hard}) helper hitting the real DELETE route.
* index.html: deleteAssetPrompt now calls deleteAsset (soft archive). Confirm dialog reworded to match.
* mam-api/routes/assets.js: list endpoint hides status=archived by default. Pass ?include_archived=true to see them in a future restore-from-trash view. Filtering by ?status=archived still works for power users.
* All HTML: bump api.js cache-buster v=4 -> v=5 so the new helper is fetched.
2026-05-17 12:55:55 -04:00
Zac
ea28c5189d feat: in-library asset preview + Premiere plugin installer
Click any asset card to open a modal with the H.264 proxy playing inline (or audio/image, per media_type). Esc or click outside closes. Sidebar shows status/codec/resolution/fps/duration/size/created plus tags and notes.

Plugin install side: added install-windows.ps1 that copies the CEP panel to %APPDATA%\Adobe\CEP\extensions, flips PlayerDebugMode=1 across the CSXS.8-13 hives, and prints the next steps. Plugin already wired against the current API.

* services/web-ui/public/js/preview.js: standalone IIFE that lazy-injects the modal markup + CSS on first use. Renders <video controls> (or <audio>, <img>) sourced from /api/v1/assets/:id/stream, with sidebar from /api/v1/assets/:id. Falls back to a clear empty state when proxy is still processing.
* services/web-ui/public/index.html: loads preview.js, wires asset-card click to window.openAssetPreview(asset.id), guards against delete-button clicks bubbling.
* services/premiere-plugin/install-windows.ps1: one-shot Windows installer for the CEP extension.
2026-05-17 08:55:14 -04:00
Zac
3ea896c368 fix(web-ui): bust JS cache so api.js fix actually reaches the browser
The api.js library-list fix from the previous commit never reached the browser because nginx served all .js with `Cache-Control: public, immutable; max-age=31536000`. The HTML referenced api.js with no version query, so the browser kept its year-cached buggy copy.

* nginx.conf: drop .js from the immutable long-cache block, add a no-cache must-revalidate block so future redeploys are picked up immediately.
* All HTML files: tag api.js refs with ?v=4 so already-running browsers fetch the new version on next page load.
2026-05-17 08:31:00 -04:00
Zac
ac1878452f fix: library + caller-only recorders + live signal indicator
Three problems blocked the end-to-end flow:

1) Library always rendered empty because /assets returns {assets,total} but
   index.html (and capture.html) assumed r.data was an array. Fixed in
   api.js by unwrapping r.data.assets centrally; total is kept on r.total.

2) SRT/RTMP caller mode pulled audio only. ffmpeg opened the network input
   before the H264 SPS arrived, marked the video stream as pix_fmt=none,
   and silently dropped it from the stream map. Added -probesize 32M
   -analyzeduration 10M -fflags +genpts and explicit -map 0✌️0?/0🅰️0? so
   each track survives independently of when it appears.

3) Hitting Record gave no feedback about whether a stream was actually
   arriving. capture-manager now parses ffmpeg progress lines (frame=...
   fps=...) and tracks framesReceived, currentFps, lastFrameAt, lastError.
   getStatus() returns a derived signal enum (connecting | receiving |
   lost | error | stopped). The recorder controller gives each spawned
   container a stable network alias `recorder-<id>` and the GET
   /recorders/:id/status endpoint proxies the live capture status through.
   recorders.html polls that every 2s and renders the badge under each
   active card with the running frame/fps counter or the ffmpeg error.

Also:
* recorders.html: dropped the listener-mode UI entirely. All new recorders
  are caller-mode (pull). The MAM is no longer offered as an RTMP/SRT
  server. Legacy listener records still render but read-only.
2026-05-17 07:39:58 -04:00
283 changed files with 54633 additions and 8977 deletions

View file

@ -22,5 +22,51 @@ SESSION_SECRET=changeme
# MAM API Configuration
MAM_API_URL=http://mam-api:3000
# Auth (set to 'true' to require login; false for open/dev mode)
AUTH_ENABLED=false
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
#
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
# user with the access they need — an 'admin' user (full access), or a user with
# the right project grants. A non-admin service token with no grants will get
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
# user is admin, so this only matters once auth is on.
AUTH_ENABLED=true
# CORS allowlist — comma-separated origins that may carry credentials to the API.
# Same-origin requests via the nginx reverse proxy do not need to be listed here.
# Leave empty to allow any origin (DEV ONLY).
ALLOWED_ORIGINS=
# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS,
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
TRUST_PROXY=false
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
# disable; the "Sign in with Google" button and the /auth/google routes only
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Must exactly match a redirect URI on the OAuth client, e.g.
# https://dragonflight.live/api/v1/auth/google/callback
OAUTH_REDIRECT_URL=
# Restrict sign-in to one Google Workspace domain (recommended). First login from
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
# by Google's stable subject id, never by email — so a Google login can never
# seize a pre-existing local account). An admin then grants project access.
# Leave blank to allow any verified Google account to self-provision (NOT advised).
GOOGLE_ALLOWED_DOMAIN=
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires
# the authenticator code (Google is treated as the first factor). Accounts without
# TOTP complete sign-in in one Google step.
# Playout / Master Control (MCR)
# Image tag the mam-api spawns when a channel starts. Build with:
# docker compose --profile build-only build playout
PLAYOUT_IMAGE=wild-dragon-playout:latest
# Base AMCP port — each channel binds to BASE + channel_id (in CasparCG terms).
PLAYOUT_AMCP_BASE_PORT=5250

16
.gitignore vendored
View file

@ -14,8 +14,24 @@ yarn-error.log*
# Build output
dist/
build/
# ...but the Premiere panel's packaging pipeline lives at build/ — keep it tracked.
!services/premiere-plugin/build/
!services/premiere-plugin/build/**
# OS
.DS_Store
.env.swp
.env.swo
services/editor/node_modules
services/editor/**/node_modules
services/editor/**/dist
services/editor/.pnpm-store
# Blackmagic DeckLink SDK + runtime libs (operator-supplied; see services/capture/build-with-decklink.sh)
services/capture/sdk/
services/capture/lib/
# Editor backups
*.bak
*.bak2
.env.bak.*

153
DESIGN.md Normal file
View file

@ -0,0 +1,153 @@
# Design system
Documented from the live tokens in `services/web-ui/public/styles.css` and the patterns used across `screens-*.jsx`. Treat this as the source of truth for shared primitives; pages may override locally but should not invent new color or type scales.
## Color
Dark theme only. Tokens live in `:root` in `styles.css`. Tinted neutrals — no `#000`, no `#fff`.
### Surfaces
| Token | Hex | Use |
|---|---|---|
| `--bg-0` | `#0B0D11` | Page background, deepest surface |
| `--bg-1` | `#14171E` | Panels, sidebars, primary chrome |
| `--bg-2` | `#1B1F27` | Panel headers, hover state |
| `--bg-3` | `#232833` | Inputs, raised buttons, badges |
| `--bg-4` | `#2D3340` | Strongly raised elements (rare) |
### Borders + overlays
| Token | Use |
|---|---|
| `--border` (rgba white 6%) | Default 1px separator |
| `--border-strong` (rgba white 10%) | Emphasized separator |
| `--border-stronger` (rgba white 14%) | Hover/active border |
| `--hover` (rgba white 4%) | Subtle hover fill |
| `--hover-strong` (rgba white 7%) | Stronger hover fill |
### Text
| Token | Hex | Use |
|---|---|---|
| `--text-1` | `#F2F3F6` | Primary content |
| `--text-2` | `#A8AEBC` | Secondary content |
| `--text-3` | `#6B7280` | Labels, metadata |
| `--text-4` | `#4B5260` | Section labels, off-month, disabled |
### Accent
`--accent: #5B7CFA` (Frame.io-ish blue). Soft variants `--accent-soft` (14%) and `--accent-soft-2` (22%) for fills. `--accent-text: #B4C3FF` for high-contrast accent text.
**Restrained strategy.** Accent is the only saturated color in chrome. Status colors below appear only where they reflect actual state. Project colors appear only on project chips.
### Status
| Token | Hex | Use |
|---|---|---|
| `--success` | `#2DD4A8` | Done, healthy, ready |
| `--warning` | `#F5A623` | Processing, attention-needed |
| `--danger` | `#FF5B5B` | Failed, error |
| `--live` | `#FF3B30` | Currently recording (broadcast red, intentionally hotter than `--danger`) |
| `--purple` | `#B57CFA` | Editor / dev tags |
Each has a `*-soft` variant at ~14% for background fills.
### Project palette
Six fixed colors cycled by index in `data.jsx` (`PROJECT_COLORS`):
`#5B7CFA · #2DD4A8 · #FF5B5B · #F5A623 · #B57CFA · #6B7280`
Used only on the project chip / rail dot. Do not reuse for status meaning.
## Typography
- **Sans:** Geist (with `cv11`, `ss01` features enabled). All UI text by default.
- **Mono:** Geist Mono. URLs, IDs, timestamps, durations, technical metadata.
Body scale runs small for density:
| Size | Use |
|---|---|
| 10.5px, 600, uppercase, 0.060.08em letterspacing | Column heads, section labels |
| 1111.5px | Metadata, secondary rows |
| 1212.5px | Body in lists/tables |
| 13px, 500600 | Row labels, button text |
| 14px | Default body |
| 15px, 600 | Modal titles, panel heads |
| 2228px, 600+ | Page H1 (`.page-header h1`) |
Never use `gradient text` (impeccable absolute ban). Emphasis via weight and size only.
## Layout
- Sidebar: 232px fixed (`--sidebar-w`).
- Topbar: 56px (`--topbar-h`).
- Row height: 44px default, 36px compact (`--row-h`, `data-density="compact"`).
- Gap unit: 16px default, 12px compact (`--gap`).
- Border radius scale: 4 / 6 / 8 / 12 / 16 px (`--r-xs` → `--r-xl`).
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
- Do NOT nest panels.
### Page header
Standard screens use `.page > .page-header > h1`. Three screens are documented exceptions because they need full-bleed layouts and their own top-chrome:
- **Home** uses `.launcher` (lobby pattern: hero logo + tile grid + status pip).
- **Library** uses `.library-layout` (dual-pane rail + main). The h1 sits inside `.library-toolbar` as `.toolbar-title`.
- **Editor** uses `.editor-shell` (NLE with timeline + monitors). The beta banner doubles as its top chrome.
All other screens should render `<div className="page"><div className="page-header"><h1>…</h1>…</div>…</div>` for consistent IA and screen-reader hierarchy.
## Shadow
Two tokens, used sparingly:
- `--shadow-card`: subtle inset highlight + soft outer. Default for raised inputs.
- `--shadow-pop`: modal / popover / context menu.
No drop shadows on flat surfaces. No glow effects (except: the EPG now-line uses a tight `box-shadow` for the broadcast-red glow, and live status dots have a pulse halo; these are state cues, not decoration).
## Motion
- Default transition: 80120ms on background/border (`transition: background 80ms, border 80ms`).
- Heavier reveals: 200ms.
- Easing: prefer ease-out (no bounce, no elastic).
- Don't animate layout (width/height/top); animate transforms and opacity.
## Patterns
### Status badges (`.badge`)
Variants: `live` (red, animated dot), `success`, `danger`, `warning`, `accent`, `neutral`, `outline`. Tiny — 910px font, ~2px vertical padding. Reserved for state, not labels.
### Row tables (`.user-row`, `.token-row`, `.job-row`, `.schedule-row`, etc.)
CSS-grid with explicit columns. Header row uses `.head` (uppercase 10.5px). No card stacking — these are dense data lists.
### Schedule EPG (`.epg-*`)
Broadcast timeline pattern. Recorder rows × time-of-day axis. Single scrolling container with sticky left gutter (220px) and sticky top ruler (32px). Hour rhythm via `repeating-linear-gradient`. Now-line is a 1px hot-red vertical bar with `--live` glow, animated by re-rendering the line component every second (transform-only positioning, no layout thrash). Event blocks are absolute-positioned within each row, colored via `--epg-block-color` set per recorder's project color. Live events get a red gradient + pulse; failures get a glyph + full red border; past events fade to 0.55 opacity.
### Inputs
`.field-input``--bg-3` fill, 1px `--border`, `--r-sm`, 12.5px font. Focus: `--border-strong`.
### Status dot (`.signal-dot`, `.rec-dot`, etc.)
Small (~68px) circle, used inline with text. Recording dots pulse with a keyframe animation.
## Impeccable absolute bans (apply project-wide)
- No `border-left` / `border-right` greater than 1px as a colored accent (rewrite with full borders, leading icons, or background tint).
- No `background-clip: text` gradient text.
- No glassmorphism (blur + translucent) decoratively.
- No hero-metric template (big number, small label, gradient accent, supporting stats).
- No identical card grids.
- Modal as last resort — exhaust inline alternatives first.
- No em dashes in code or copy. Use commas, colons, parentheses, periods.
## To extend
When a new design need arises, prefer adding a variant to an existing primitive over inventing a new token. New tokens land in `styles.css`. New components land in the relevant `screens-*.jsx` only if reused; otherwise keep them local.

61
PRODUCT.md Normal file
View file

@ -0,0 +1,61 @@
# Product
## What it is
Dragonflight (codebase name; UI brand: "Dragonflight · Wild Dragon Broadcast") is an on-prem broadcast media asset manager and live ingest controller. Operators capture from SRT, RTMP, and SDI sources, schedule windowed recordings against named recorders, transcode/proxy in the background, browse and clip in a library, import from YouTube, and hand off to Premiere or an in-house editor.
The deployable surface is a React (Babel-in-browser) single-page app served by nginx, talking to a Node/Express API backed by Postgres + Redis + BullMQ, with capture and worker containers handling the actual media.
Stack lives at `services/web-ui` (UI), `services/mam-api` (API), `services/capture`, `services/worker`, `services/editor`.
## Register
**Product.** This is operator UI for a working broadcast tool, not a brand site or marketing surface. Design serves the operator's job, not the brand's identity.
## Users
Primary: broadcast operators and engineers running live productions. They schedule and supervise back-to-back recordings across multiple recorders in a single shift. They care about: what's recording right now, what's about to start, what failed, and which recorder is bound to what source.
Secondary: editors and producers who consume the resulting library, comment on assets, request proxy regeneration. They mostly live in the Library and Asset detail screens, not the scheduler.
Tertiary: admins managing recorders, users, cluster nodes, and storage. Live in the Admin screens.
## Product purpose
Replace a stack of one-trick tools (NewBlue scheduler, vMix capture, ad-hoc Premiere ingest, manual S3 syncs) with a single operator surface that supervises recorders, owns the asset catalog through proxy generation, and stays out of the editor's way once footage lands.
## Tone
Function-first. Dense. Operations-room. Mono fonts where data lives. Small type. Restrained chrome. The operator should be able to glance at any screen and read the state of the system in under a second; they should never wonder "is this still happening" or "did that finish."
Not: marketing-warm, conversational, gamified, congratulatory.
## Strategic principles
1. **Glance-readable status.** Every list, every cell, every badge must answer "what is the state of this thing right now" without a hover.
2. **Trust the operator.** No confirmation modals for reversible actions. No nag, no toasts for routine success. Errors stay visible until acknowledged.
3. **Time is the spine.** This product is about time-based events (recordings, schedules, jobs). UIs should privilege time as a primary axis, not bury it under categorical filters.
4. **Density over whitespace.** Operators run multi-monitor setups and want maximum signal per pixel. Generous whitespace is a brand-site reflex; reject it here.
5. **No half-states.** Pending UIs disable controls; live UIs show live data; failed UIs show the failure inline, not in a separate notification feed.
## Anti-references
Steer away from:
- **Linear-pastel SaaS aesthetics.** Purples, mint accents, friendly empty states with cartoon line illustrations.
- **Google-Calendar generic.** A neutral month grid with rounded event chips, no operational signal, optimized for "is Friday free" rather than "is recorder A in conflict at 14:00."
- **Gantt-chart project-management feel.** Implies long-horizon planning of tasks with dependencies; this product is hour-scale broadcast operations.
- **Cards-for-everything.** Identical card grids of icon+label+value. Particularly the SaaS hero-metric template.
- **Decorative blur / glassmorphism / gradient text.** Read as decorative AI slop in a broadcast-ops context.
- **NewBlue / Wirecast skinning.** Heavy bevels, gradient buttons, drop shadows. Read as outdated broadcast software.
## Decision context (Schedule v2)
The Schedule screen was rebuilt as an EPG (electronic program guide) timeline. Operator confirmed:
- Density: **heavy / back-to-back, many recorders all day** — month grid was the wrong primary.
- At-a-glance signals: now/next, recorder bookings (conflicts), project context, failure history.
- Aesthetic: **studio / cinematic — dark, type-led, accent moments.** DaVinci-Resolve-panel territory.
- Scope: full rethink — replace the primary view.
Implementation: recorder rows × time-of-day horizontal axis, sticky gutter + ruler, vertical hot-red now-line, event blocks colored by project, status pills in a top strip. Today / Week / List views.

264
README.md
View file

@ -1,60 +1,258 @@
# Wild Dragon
# Dragonflight
Self-hosted Media Asset Management platform built to replace Grass Valley AMPP FramelightX.
Self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. Handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
## Services
> Repo renamed from `wild-dragon``dragonflight` (2026-05-23). The old URL still redirects.
| Service | Port | Description |
|---------|------|-------------|
| **web-ui** | 8080 | Browser-based MAM interface + capture controls |
| **mam-api** | 3000 | REST API — assets, projects, bins, jobs |
| **capture** | 3001 | SDI capture daemon (Blackmagic DeckLink + FFmpeg) |
| **worker** | — | Async job processor (proxy gen, thumbnails, conform) |
| **db** | 5432 | PostgreSQL 16 metadata store |
| **queue** | 6379 | Redis 7 job queue (BullMQ) |
## Home Dashboard
<img src="docs/screenshots/01-home.png" alt="Home Dashboard" width="800" />
The home screen provides quick access to all major features and displays system status at a glance:
- **Library** — Browse projects, bins, and assets with hover-scrub previews
- **Recorders** — View configured capture devices and their status
- **Editor** — Timeline editor with cross-clip preview and render queue
- **Jobs** — Proxy and thumbnail queue with retry controls
- **Settings** — Configure storage, encoder, growing files, and capture SDK
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
---
## Core Features
### 1. Live Ingest & Capture
**Multi-protocol source capture with per-recorder codec settings**
Dragonflight ingests from multiple sources simultaneously:
- **SRT** (Secure Reliable Transport) — caller and listener modes
- **RTMP** — standard streaming protocol
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
Each recorder can be configured with independent codec settings:
- ProRes (hi-res masters)
- H.264 / H.265 (proxies)
- DNxHR (Avid compatibility)
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
### 2. Growing-File Editing
**Live editing in Premiere Pro while capture is still writing**
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
- Real-time clip detection and frame-accurate trimming
- One-click relink to final S3 master after promotion
- No waiting for capture to finish before editorial begins
### 3. Recorder Scheduler
**Time-windowed recording automation**
Schedule recordings with:
- One-shot, daily, or weekly recurrence
- Automatic start/stop via 15-second tick loop
- Conflict detection across recorders
- Project and bin assignment at schedule time
### 4. Library & Asset Management
**Browse, search, and organize captured footage**
The Library screen provides:
- Project and bin hierarchy
- Asset detail view with frame-anchored persistent comments
- Right-click context menu (move-to-bin, rename, delete)
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
- Hover-scrub preview with HLS playback
### 5. Jobs Queue
**BullMQ-backed proxy and thumbnail generation**
Automated background processing:
- Per-job retry logic with exponential backoff
- Bulk "retry all failed" for batch recovery
- Inline error messages with actionable diagnostics
- Status tracking: ingesting → processing → ready
Proxy encoder options:
- CPU-based: libx264 (H.264)
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
### 6. Timeline Conform & Export
**FCP XML export with server-side FFmpeg rendering**
The Premiere Pro panel exports FCP XML with:
- Server-side conform via FFmpeg
- Multiple output formats: H.264, H.265, ProRes
- Resolution presets: Broadcast, Web, Archive
- Batch processing with job queue integration
### 7. Hi-Res Auto-Relink
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
After editing on proxies:
- Select clips in Premiere
- Trigger relink from the CEP panel
- Server trims hi-res segments to exact in/out points
- Concurrent trim worker pool for speed
- 24-hour TTL with automatic cleanup
### 8. Settings & Configuration
**Centralized control for storage, encoding, and capture**
Configure:
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
- **Growing Files** — SMB path, retention, auto-promotion
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
### 9. Cluster & Distributed Capture
**Primary + worker topology with remote DeckLink nodes**
- Primary node runs API, scheduler, and web UI
- Worker nodes handle proxy/thumbnail jobs
- Remote capture nodes run DeckLink cards off-host
- Heartbeat health monitoring
- Automatic failover and recovery
### 10. Admin & User Management
**Role-based access, token auth, and cluster monitoring**
- User creation and role assignment
- API token generation for integrations
- Container and cluster node status
- System health dashboard
---
## Quick Start
```bash
# Clone
git clone https://forge.wilddragon.net/zgaetano/wild-dragon.git
cd wild-dragon
# Clone (repo renamed; old URL still redirects)
git clone https://forge.wilddragon.net/zgaetano/dragonflight.git
cd dragonflight
# Configure
cp .env.example .env
# Edit .env with your S3 credentials and secrets
# Edit .env — S3 credentials + SESSION_SECRET at minimum
# Launch
docker compose up -d
# Open
open http://localhost:8080
open http://localhost:47434
```
## Architecture
```
SDI Input (DeckLink) → capture service → dual FFmpeg streams
├─ HiRes (ProRes) → S3
└─ Proxy (H.264) → S3
web-ui ← mam-api ← PostgreSQL ← worker (BullMQ)
├─ proxy_gen
├─ thumbnail
└─ conform (EDL → FFmpeg → export)
SDI / SRT / RTMP ──► capture (FFmpeg)
├─ HLS preview tee ──► /live/<assetId>/index.m3u8
└─ master output
├─ growing_enabled=true:
│ /growing/<projectId>/<clip>.mov
│ (Premiere mounts SMB, edits live)
│ └─► promotion worker uploads to S3
└─ growing_enabled=false:
multipart stream → S3
assets POST ──► proxy job ──► worker
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
├─ thumbnail job
└─ status: ingesting → processing → ready
```
## Tech Stack
- **Backend:** Node.js / Express
- **Frontend:** Vanilla HTML/CSS/JS
- **Database:** PostgreSQL 16
- **Queue:** Redis 7 + BullMQ
- **Storage:** S3-compatible (RustFS)
- **Media Processing:** FFmpeg
- **Capture:** Blackmagic DeckLink SDK
- **Deployment:** Docker Compose
- **Runtime:** Node.js 22, Docker Compose
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
## Services
| Service | Port | Purpose |
|---------|------|---------|
| **web-ui** | 47434 | Browser SPA + capture controls |
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
| **worker** | — | BullMQ proxy + thumbnail workers |
| **db** | 5432 | PostgreSQL 16 |
| **queue** | 6379 | Redis 7 |
---
## Workflow Example: Live-to-Edit
1. **Operator** schedules a recording on Recorder A for 14:0015:30, assigns to "News/Segment-A" project
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
4. **Editor** trims and marks in/out points while capture is still writing
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
6. **Editor** clicks "Relink to Master" in CEP panel
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
8. **Premiere** relinks proxy clips to trimmed master
9. **Editor** exports final timeline via FCP XML conform
Total time from end of capture to relinked master: ~2 minutes.
---
## Operations
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
- `deploy/onboard-node.sh` — provision a remote worker host
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
## Authentication
Dragonflight uses local username/password authentication with two transports:
- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout.
- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`.
### First-run setup
On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser.
With no users in the database, the login screen renders a "First-run setup" form
instead — fill it in to create the first admin and you are logged in immediately.
Subsequent users are created from `Settings → Users` (any signed-in user can
create others — flat access).
### Dev mode
Setting `AUTH_ENABLED=false` disables all auth checks; a synthetic `dev` user
is attached to every request. **Never deploy this way.** The dev user row is
seeded with a hash that no real password can match, so flipping
`AUTH_ENABLED=true` later does not expose the dev account.
### Recovering a forgotten admin password
Any signed-in user can reset another user's password from `Settings → Users`.
If no one can sign in (all admins forgot their passwords), reset directly in
Postgres:
```sql
-- generate a fresh bcrypt hash with:
-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here'
UPDATE users SET password_hash = '<bcrypt-hash>', password_updated_at = NOW()
WHERE username = 'admin';
```
### `AUTH_ENABLED` transition
When flipping `AUTH_ENABLED=false``true` on an existing install:
1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out).
2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI.
3. Set `TRUST_PROXY=true` when behind nginx (required for rate-limit accuracy).
4. Restart `mam-api`.
5. Visit the UI — first-run setup will appear if no real users exist yet.
---
## License
MIT
Proprietary — Wild Dragon LLC, all rights reserved.

101
WORK_LOG_PLAYOUT.md Normal file
View file

@ -0,0 +1,101 @@
# Playout / Master Control — Implementation Work Log
**Branch:** `feat/playout-mcr` (off `main`)
**Started:** 2026-05-30
**Status:** Code complete, awaiting runtime validation
Tracks the build of the playout (MCR) subsystem against the design at
`docs/superpowers/specs/2026-05-30-playout-mcr-design.md`.
---
## Commit sequence
| # | Commit | Scope |
|---|--------|-------|
| 1 | `docs(playout)` | Design spec, §7 questions answered |
| 2 | `feat(mam-api): migration 029` | Six tables, failover columns, audio_normalized flag |
| 3 | `feat(worker): playout-stage` | S3 → /media + EBU R128 loudnorm + index.js wiring |
| 4 | `feat(playout): sidecar` | CasparCG image + AMCP shim, HLS preview consumer, fps-aware frame math |
| 5 | `feat(mam-api): /playout control plane + auto-failover` | Routes + scheduler health tick + restartChannel helper |
| 6 | `feat(web-ui): MCR page` | screens-playout, styles, app/shell/index.html wiring |
| 7 | `build(playout): compose wiring + .env knobs` | /media volume, queue addition, build-only service |
| 8 | `docs(playout): work log` | This file |
## Resolved §7 decisions (2026-05-30)
- **Audio loudness:** pre-normalize at stage time. ffmpeg `loudnorm` two-pass
(I=-23 LUFS, TP=-1 dBTP, LRA=11), linear mode preserves dynamics. Output
AAC 192k @ 48 kHz, video stream copied. Per-item `audio_normalized` flag
so re-stages of the same asset skip the pass.
- **Frame rate:** `1080p5994` default (was `1080i5994`). Per-channel
override allowed via `video_format`. `fpsFor(videoFormat)` helper in
the sidecar drives SEEK / LENGTH / transition-frames math.
- **Preview latency:** HLS v1. CasparCG runs a second FFMPEG consumer
alongside the primary output, writing `/media/live/<channel_id>/index.m3u8`
(~600 kbps, 2s segments, 6-window list). Web UI plays via the existing
HLS plumbing.
- **Failover:** auto-restart on healthy node for NDI/SRT/RTMP. Alert-only
for DeckLink (device-index pinning makes blind re-placement risky).
Scheduler tick (PG advisory lock, same lock as recorder schedules) polls
sidecar `/status`; ~3 missed checks → `restartChannel(id)` picks the most
recently-seen-online other node, bumps `restart_count`, calls `/start`.
## Architecture notes
**Sidecar model.** One CasparCG container per channel. Spawned by mam-api
via local Docker socket (primary node) or remote node-agent
`/sidecar/start`. Tracked in `playout_sidecars` plus `playout_channels.container_id`.
Killed on `/stop` or by `restartChannel` during failover.
**Media flow.**
```
S3 master/proxy → playout-stage worker → /media/playout/<assetId>.<ext>
(loudnormed, AAC@-23 LUFS)
CasparCG channel #1
primary consumer HLS consumer
(DeckLink/NDI/ ↓
SRT/RTMP) /media/live/<ch_id>/*.m3u8
```
**Port contention.** `assertDeckLinkFree()` blocks starting a SDI channel
when a recorder or another channel on the same node+device_index is active.
**Failover scope.** NDI/SRT/RTMP have no hardware tie, so any healthy
cluster_node is eligible. DeckLink channels surface an alert in the UI
(`status='error'` + `error_message`) and require operator intervention.
## Testing checklist
- [ ] Apply migration 029 on dev DB
- [ ] Build playout image: `docker compose --profile build-only build playout`
- [ ] Build web-ui (`screens-playout` joins the esbuild list automatically)
- [ ] Create channel via POST /api/v1/playout/channels (SRT first, no HW)
- [ ] Stage 2-3 assets to a playlist, verify loudnorm metadata in stderr
- [ ] Start channel → sidecar container appears in `docker ps`
- [ ] AMCP smoke: `telnet <host> 5250`, `VERSION`, `INFO`
- [ ] Play playlist; verify HLS at /media/live/<id>/index.m3u8
- [ ] Skip / pause / resume / stop
- [ ] As-run log: GET /api/v1/playout/channels/:id/asrun
- [ ] Kill sidecar container → scheduler should restart on another node
within ~3 ticks (~45s), restart_count increments
- [ ] DeckLink channel kill: status flips to 'error', NO restart attempt
- [ ] Try starting a decklink channel on a device_index already held by a
recorder → 409
- [ ] MCR UI smoke: nav entry visible, page renders, drag-drop adds items,
transport buttons hit the API
## Known gaps (deferred)
- No WebRTC preview (HLS-only v1 — 4-6s lag, fine for confidence monitor).
- No graphics/CG overlay layer in Phase A (templates land in Phase B).
- No Phase B scheduler / 24/7 wall-clock channel (schema is in place,
scheduler tick is not).
- No multi-channel grid view (one channel at a time per page).
- No timecode / remaining-duration overlay (would need CasparCG INFO poll).
- No audio level meters on the UI.
- `restartChannel` updates DB state and triggers `/start`; if the new node
also fails repeatedly, there's no exponential backoff yet — bounded only
by the manual stop button.

104
deploy/api-smoke.sh Executable file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Dragonflight MAM API smoke test
#
# Hits every read-only endpoint and a handful of safe write endpoints
# against a running mam-api. Reports per-endpoint HTTP code + a one-line
# pass/fail. Exits non-zero on any failure.
#
# Usage:
# deploy/api-smoke.sh # against http://localhost:47432
# API=http://10.0.0.25:47432 deploy/api-smoke.sh
set -u
API="${API:-http://localhost:47432}"
PASS=0
FAIL=0
# Per-endpoint check. Args: METHOD PATH EXPECTED_HTTP_CODE [BODY]
# Treats anything < 500 as OK by default; auth-gated endpoints typically
# return 401 with AUTH_ENABLED, also acceptable.
hit() {
local method="$1" path="$2" expect="${3:-2..}" body="${4:-}"
local args=(-s -o /dev/null -w '%{http_code}' -X "$method" "${API}${path}")
if [ -n "$body" ]; then args+=(-H 'Content-Type: application/json' -d "$body"); fi
local code
code=$(curl "${args[@]}" 2>/dev/null || echo "000")
if [[ "$code" =~ ^(2|3|401|400)[0-9][0-9]$ ]]; then
printf " %s %-40s %s OK\n" "$method" "$path" "$code"
PASS=$((PASS + 1))
else
printf " %s %-40s %s FAIL\n" "$method" "$path" "$code"
FAIL=$((FAIL + 1))
fi
}
echo "Dragonflight API smoke test — target ${API}"
echo ""
echo "── auth ──────────────────────────────────────────"
hit GET /api/v1/auth/me
echo ""
echo "── core lists ────────────────────────────────────"
hit GET /api/v1/projects
hit GET /api/v1/assets
hit GET /api/v1/assets?limit=5
hit GET /api/v1/recorders
hit GET /api/v1/jobs
hit GET /api/v1/bins
hit GET /api/v1/users
hit GET /api/v1/groups
hit GET /api/v1/cluster
hit GET /api/v1/cluster/containers
hit GET /api/v1/cluster/devices/blackmagic
echo ""
echo "── settings ──────────────────────────────────────"
hit GET /api/v1/settings/s3
hit GET /api/v1/settings/transcoding
hit GET /api/v1/settings/growing
hit GET /api/v1/settings/ampp
hit GET /api/v1/settings/hardware
hit GET /api/v1/settings/capture-service
echo ""
echo "── feature endpoints ─────────────────────────────"
hit GET /api/v1/metrics/home
hit GET /api/v1/metrics/home?hours=1
hit GET /api/v1/schedules
hit GET /api/v1/schedules?status=upcoming
hit GET /api/v1/sdk
echo ""
echo "── deep-link sanity (one asset) ──────────────────"
ASSET_ID=$(curl -s "${API}/api/v1/assets?limit=1" 2>/dev/null \
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
if [ -n "$ASSET_ID" ]; then
echo " using asset_id=$ASSET_ID"
hit GET "/api/v1/assets/$ASSET_ID"
hit GET "/api/v1/assets/$ASSET_ID/comments"
hit GET "/api/v1/assets/$ASSET_ID/stream"
hit GET "/api/v1/assets/$ASSET_ID/thumbnail"
else
echo " (no assets to deep-link; skipping per-asset endpoints)"
fi
echo ""
echo "── deep-link sanity (one recorder) ───────────────"
REC_ID=$(curl -s "${API}/api/v1/recorders" 2>/dev/null \
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
if [ -n "$REC_ID" ]; then
echo " using recorder_id=$REC_ID"
hit GET "/api/v1/recorders/$REC_ID"
hit GET "/api/v1/recorders/$REC_ID/status"
else
echo " (no recorders to deep-link)"
fi
echo ""
echo "── summary ───────────────────────────────────────"
echo " PASS: $PASS"
echo " FAIL: $FAIL"
[ "$FAIL" -eq 0 ]

197
deploy/onboard-node.sh Normal file
View file

@ -0,0 +1,197 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — Cluster Node Onboarding
# =============================================================================
#
# Provisions a Linux machine as a cluster worker node in one command.
#
# Quick-start (pipe to bash):
# export MAM_API_URL=http://10.0.0.25:47432
# export NODE_TOKEN=wd_xxxx # create via Z-AMPP → Admin → Tokens
# curl -sL https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh | bash
#
# Or run from a cloned repo:
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/onboard-node.sh
#
# Environment variables:
# MAM_API_URL REQUIRED Primary MAM API base URL
# NODE_TOKEN API bearer token (required if AUTH_ENABLED=true)
# NODE_ROLE Role tag reported to the cluster (default: worker)
# NODE_IP Override the LAN IP reported back (default: auto-detect)
# AGENT_PORT Host port for the node agent (default: 7436)
# INSTALL_DIR Where to clone/find the repo (default: /opt/wild-dragon)
# PROFILES Extra compose profiles, space-sep e.g. "worker capture"
# BMD_MODEL DeckLink card model name (e.g. "DeckLink Duo 2")
# REPO_URL Override the Forgejo clone URL
# =============================================================================
set -euo pipefail
# ── Config ───────────────────────────────────────────────────────────────────
REPO_URL="${REPO_URL:-https://forge.wilddragon.net/zgaetano/wild-dragon.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/wild-dragon}"
MAM_API_URL="${MAM_API_URL:-}"
NODE_TOKEN="${NODE_TOKEN:-}"
NODE_ROLE="${NODE_ROLE:-worker}"
NODE_IP="${NODE_IP:-}"
AGENT_PORT="${AGENT_PORT:-7436}"
PROFILES="${PROFILES:-}"
BMD_MODEL="${BMD_MODEL:-}"
PROJECT_NAME="wild-dragon-worker"
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'
BLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GRN}${NC} $*"; }
info() { echo -e "${CYN}${NC} $*"; }
warn() { echo -e "${YEL}${NC} $*"; }
header() { echo -e "\n${BLD}${CYN}── $* ──────────────────────────────────────${NC}"; }
die() { echo -e "${RED} ✗ ERROR:${NC} $*" >&2; exit 1; }
# ── Auto-detect LAN IP ───────────────────────────────────────────────────────
# Node-agent runs in a container; os.networkInterfaces() inside the container
# returns the docker-bridge IP unless we pass NODE_IP through. We resolve the
# host's primary LAN IP here so the cluster page shows the right address.
detect_lan_ip() {
local ip=""
if command -v ip &>/dev/null; then
ip=$(ip -4 route get 1.1.1.1 2>/dev/null \
| awk '/src/ {for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' \
|| true)
fi
if [[ -z "$ip" ]] && command -v hostname &>/dev/null; then
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
fi
echo "$ip"
}
# ── Preflight ────────────────────────────────────────────────────────────────
echo -e "\n${BLD}${CYN}Wild Dragon MAM — Cluster Node Onboarding${NC}\n"
[[ -z "$MAM_API_URL" ]] && die "MAM_API_URL is required.\n\n Example:\n export MAM_API_URL=http://10.0.0.25:47432\n export NODE_TOKEN=wd_xxxx\n ./deploy/onboard-node.sh"
if [[ -z "$NODE_IP" ]]; then
NODE_IP="$(detect_lan_ip)"
if [[ -n "$NODE_IP" ]]; then
info "Auto-detected LAN IP: $NODE_IP"
else
warn "Could not auto-detect LAN IP — agent will fall back to interface heuristics."
fi
fi
info "Primary API : $MAM_API_URL"
info "Role : $NODE_ROLE"
info "Agent port : $AGENT_PORT"
info "Install dir : $INSTALL_DIR"
[[ -n "$NODE_IP" ]] && info "Node IP : $NODE_IP"
[[ -n "$BMD_MODEL" ]] && info "DeckLink : $BMD_MODEL"
[[ -n "$PROFILES" ]] && info "Profiles : $PROFILES"
if [[ -z "$NODE_TOKEN" ]]; then
warn "NODE_TOKEN is not set."
warn "If AUTH_ENABLED=true on the primary, heartbeats will be rejected."
warn "Create a token: Z-AMPP web UI → Admin → Tokens → New Token"
fi
# ── Step 1: Docker ───────────────────────────────────────────────────────────
header "1/4 Docker"
if ! command -v docker &>/dev/null; then
warn "Docker not found — installing via get.docker.com"
curl -fsSL https://get.docker.com | bash
systemctl enable --now docker 2>/dev/null || true
usermod -aG docker "${SUDO_USER:-$USER}" 2>/dev/null || true
log "Docker installed"
else
log "Docker $(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1) already installed"
fi
if ! docker info &>/dev/null; then
die "Docker daemon not accessible.\n Try: sudo systemctl start docker\n Or add your user to the docker group and re-login."
fi
# ── Step 2: Repository ───────────────────────────────────────────────────────
header "2/4 Repository"
if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Updating $INSTALL_DIR"
git -C "$INSTALL_DIR" pull --ff-only
log "Repository up to date ($(git -C "$INSTALL_DIR" rev-parse --short HEAD))"
else
info "Cloning $REPO_URL$INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone "$REPO_URL" "$INSTALL_DIR"
log "Repository cloned"
fi
# ── Step 3: Environment ──────────────────────────────────────────────────────
header "3/4 Configuration"
ENV_FILE="$INSTALL_DIR/.env.worker"
info "Writing $ENV_FILE"
{
echo "# Wild Dragon worker node — generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by onboard-node.sh"
echo "MAM_API_URL=$MAM_API_URL"
echo "NODE_TOKEN=$NODE_TOKEN"
echo "NODE_ROLE=$NODE_ROLE"
echo "NODE_IP=$NODE_IP"
echo "AGENT_PORT=$AGENT_PORT"
echo "HEARTBEAT_MS=30000"
[[ -n "$BMD_MODEL" ]] && echo "BMD_MODEL=$BMD_MODEL"
for v in REDIS_URL DATABASE_URL S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY S3_SECRET_KEY S3_REGION; do
val="${!v:-}"
[[ -n "$val" ]] && echo "$v=$val"
done
} > "$ENV_FILE"
log "Env file written"
# ── Step 4: Start services ───────────────────────────────────────────────────
header "4/4 Starting services"
COMPOSE="docker compose -f $INSTALL_DIR/docker-compose.worker.yml --env-file $ENV_FILE --project-name $PROJECT_NAME"
PROFILE_FLAGS=""
for p in $PROFILES; do
PROFILE_FLAGS="$PROFILE_FLAGS --profile $p"
done
info "Building images (this may take a minute on first run)…"
$COMPOSE build
info "Starting containers…"
# shellcheck disable=SC2086
$COMPOSE $PROFILE_FLAGS up -d
# ── Verify ───────────────────────────────────────────────────────────────────
echo ""
info "Waiting 6 seconds for agent to initialise…"
sleep 6
HEALTH_URL="http://localhost:$AGENT_PORT/health"
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
log "Node agent healthy at $HEALTH_URL"
REPORTED_IP=$(curl -sf "$HEALTH_URL" | sed -nE 's/.*"ip":"([^"]+)".*/\1/p')
[[ -n "$REPORTED_IP" ]] && log "Reporting IP: $REPORTED_IP"
else
warn "Could not reach $HEALTH_URL — check logs:"
warn " $COMPOSE logs node-agent"
fi
# ── Done ─────────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}${GRN}Onboarding complete!${NC}"
echo ""
echo -e " Node agent ${BLD}:$AGENT_PORT${NC} (heartbeating every 30s)"
echo -e " Primary API ${BLD}$MAM_API_URL${NC}"
echo -e " Role ${BLD}$NODE_ROLE${NC}"
[[ -n "$NODE_IP" ]] && echo -e " Node IP ${BLD}$NODE_IP${NC}"
echo ""
echo -e " ${CYN}Useful commands:${NC}"
echo -e " Status : $COMPOSE ps"
echo -e " Logs : $COMPOSE logs -f"
echo -e " Stop : $COMPOSE down"
echo -e " Update : git -C $INSTALL_DIR pull && $COMPOSE build && $COMPOSE up -d"
echo ""
echo -e " Open the Z-AMPP web UI → ${BLD}Admin → Cluster${NC} to see this node."

186
deploy/test-api.sh Normal file
View file

@ -0,0 +1,186 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — API Smoke Test
# =============================================================================
# Hits every major endpoint and reports pass/fail.
#
# Usage:
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-api.sh
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/test-api.sh
# =============================================================================
set -euo pipefail
BASE="${MAM_API_URL:-http://localhost:47432}"
TOKEN="${NODE_TOKEN:-}"
PASS=0; FAIL=0; SKIP=0
GRN='\033[0;32m'; RED='\033[0;31m'; YEL='\033[1;33m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
pass() { PASS=$((PASS+1)); echo -e " ${GRN}PASS${NC} $1"; }
fail() { FAIL=$((FAIL+1)); echo -e " ${RED}FAIL${NC} $1 ${RED}$2${NC}"; }
skip() { SKIP=$((SKIP+1)); echo -e " ${YEL}SKIP${NC} $1 ${YEL}($2)${NC}"; }
header() { echo -e "\n${BLD}$1${NC}"; }
AUTH_ARGS=()
[[ -n "$TOKEN" ]] && AUTH_ARGS+=(-H "Authorization: Bearer $TOKEN")
# GET — check HTTP status code (no -f so 4xx/5xx are visible as their real code)
check_status() {
local label="$1" path="$2" want="$3"
local got
got=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null)
[[ -z "$got" ]] && got="000"
if [[ "$got" == "$want" ]]; then
pass "$label [HTTP $got]"
else
fail "$label [HTTP $got]" "expected $want"
fi
}
# GET — check response body contains literal string (fgrep avoids regex interpretation)
check_body() {
local label="$1" path="$2" needle="$3"
local body
body=$(curl -s "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null) || { fail "$label" "request failed"; return; }
if echo "$body" | grep -qF "$needle"; then
pass "$label"
else
fail "$label" "'$needle' not in response"
fi
}
# POST — check HTTP status code
check_post() {
local label="$1" path="$2" data="$3" want="$4"
local got
got=$(curl -s -o /dev/null -w "%{http_code}" \
"${AUTH_ARGS[@]}" \
-H "Content-Type: application/json" \
-X POST -d "$data" \
"$BASE$path" 2>/dev/null)
[[ -z "$got" ]] && got="000"
if [[ "$got" == "$want" ]]; then
pass "$label [HTTP $got]"
else
fail "$label [HTTP $got]" "expected $want"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}${CYN}Wild Dragon MAM — API Smoke Test${NC}"
echo -e " Base URL : ${BLD}$BASE${NC}"
[[ -n "$TOKEN" ]] && echo -e " Auth : Bearer token" || echo -e " Auth : none"
echo ""
# ── Connectivity ─────────────────────────────────────────────────────────────
header "Connectivity"
CONNECT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null)
if [[ "$CONNECT" == "200" ]]; then
pass "API server reachable [/health → 200]"
else
fail "API server reachable [HTTP $CONNECT]" "cannot reach $BASE"
echo -e "\n ${RED}Cannot reach the server — aborting.${NC}"
exit 1
fi
# ── Auth ─────────────────────────────────────────────────────────────────────
header "Auth"
check_status "GET /auth/me" "/api/v1/auth/me" 200
check_body "GET /auth/me returns username" "/api/v1/auth/me" '"username"'
check_post "POST /auth/login (missing body → 400)" "/api/v1/auth/login" '{}' 400
# ── Assets ───────────────────────────────────────────────────────────────────
header "Assets"
check_status "GET /assets" "/api/v1/assets" 200
check_body "GET /assets returns assets key" "/api/v1/assets" '"assets"'
check_status "GET /assets bogus id → 404" "/api/v1/assets/00000000-0000-0000-0000-000000000000" 404
# ── Projects ─────────────────────────────────────────────────────────────────
header "Projects"
check_status "GET /projects" "/api/v1/projects" 200
check_body "GET /projects returns array" "/api/v1/projects" '['
# ── Jobs ─────────────────────────────────────────────────────────────────────
header "Jobs"
check_status "GET /jobs" "/api/v1/jobs" 200
check_body "GET /jobs returns array" "/api/v1/jobs" '['
# ── Recorders ────────────────────────────────────────────────────────────────
header "Recorders"
check_status "GET /recorders" "/api/v1/recorders" 200
# ── Sequences (requires project_id param) ────────────────────────────────────
header "Sequences"
check_status "GET /sequences (no project_id → 400)" "/api/v1/sequences" 400
check_status "GET /sequences bogus project_id → 200" "/api/v1/sequences?project_id=00000000-0000-0000-0000-000000000000" 200
# ── Settings ─────────────────────────────────────────────────────────────────
header "Settings"
check_status "GET /settings/ampp" "/api/v1/settings/ampp" 200
# ── Cluster ──────────────────────────────────────────────────────────────────
header "Cluster"
check_status "GET /cluster" "/api/v1/cluster" 200
check_body "GET /cluster returns array" "/api/v1/cluster" '['
# Heartbeat: register a temporary smoke-test node, verify it appears, remove it
TEST_HOST="smoke-test-$(date +%s)"
check_post "POST /cluster/heartbeat" "/api/v1/cluster/heartbeat" \
"{\"hostname\":\"$TEST_HOST\",\"role\":\"smoketest\",\"cpu_usage\":0,\"mem_used_mb\":512,\"mem_total_mb\":4096}" \
200
NODE_ID=$(curl -s "${AUTH_ARGS[@]}" "$BASE/api/v1/cluster" 2>/dev/null \
| grep -o '"id":"[^"]*"' | head -1 | grep -o '[0-9a-f-]\{36\}' || true)
if [[ -n "$NODE_ID" ]]; then
pass "Cluster node visible in registry"
DEL=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
-X DELETE "$BASE/api/v1/cluster/$NODE_ID" 2>/dev/null)
[[ "$DEL" == "200" ]] && pass "DELETE /cluster/:id (cleanup) [HTTP $DEL]" \
|| fail "DELETE /cluster/:id (cleanup)" "HTTP $DEL"
else
skip "Cluster node visible in registry" "could not parse node id from response"
fi
# ── System / Containers ───────────────────────────────────────────────────────
header "System"
check_status "GET /system/containers" "/api/v1/system/containers" 200
check_body "Containers returns array" "/api/v1/system/containers" '['
# ── Capture (proxies to capture service) ─────────────────────────────────────
header "Capture"
# 200 = capture active and responding
# 404 = capture in sidecar/idle mode (no active recorder — expected in dev)
# 5xx = capture container unreachable
CAPTURE_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
"$BASE/api/v1/capture/status" 2>/dev/null)
if [[ "$CAPTURE_CODE" == "200" ]]; then
pass "GET /capture/status [HTTP 200 — capture active]"
elif [[ "$CAPTURE_CODE" == "404" ]]; then
skip "GET /capture/status [HTTP 404]" "capture in idle/sidecar mode (normal when not recording)"
elif [[ "$CAPTURE_CODE" =~ ^5 ]]; then
skip "GET /capture/status [HTTP $CAPTURE_CODE]" "capture container unreachable"
else
fail "GET /capture/status [HTTP $CAPTURE_CODE]" "unexpected status"
fi
# ── Users / Tokens ───────────────────────────────────────────────────────────
header "Users / Tokens"
check_status "GET /users" "/api/v1/users" 200
check_status "GET /tokens" "/api/v1/tokens" 200
# ── Summary ───────────────────────────────────────────────────────────────────
TOTAL=$((PASS + FAIL + SKIP))
echo ""
echo -e "${BLD}Results:${NC} ${GRN}${PASS} passed${NC} / ${RED}${FAIL} failed${NC} / ${YEL}${SKIP} skipped${NC} / $TOTAL total"
echo ""
if [[ $FAIL -gt 0 ]]; then
echo -e "${RED}Some tests failed — check output above.${NC}"
exit 1
else
echo -e "${GRN}All tests passed.${NC}"
fi

183
deploy/test-cluster.sh Executable file
View file

@ -0,0 +1,183 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — Cluster Smoke Test
# =============================================================================
#
# Validates the cluster end-to-end from any node that can reach the primary.
# Designed to be run after `onboard-node.sh` finishes on every worker.
#
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-cluster.sh
# MAM_API_URL=... AUTH_TOKEN=wd_xxxx ./deploy/test-cluster.sh
#
# Checks:
# 1. Primary API health
# 2. Cluster registry (no duplicate hostnames, IPs are real LAN addresses)
# 3. Each worker's /health endpoint
# 4. GPU detection (nvidia-smi exits clean on nodes that report GPUs)
# 5. NVENC encode probe (5s of synthetic h264_nvenc → /tmp)
# 6. Blackmagic device enumeration
#
# Exit 0 = all pass, 1 = any failure. Failures are logged inline.
# =============================================================================
set -uo pipefail
MAM_API_URL="${MAM_API_URL:-}"
AUTH_TOKEN="${AUTH_TOKEN:-}"
if [[ -z "$MAM_API_URL" ]]; then
echo "✗ MAM_API_URL is required" >&2
exit 1
fi
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
PASS=0; FAIL=0
pass() { echo -e "${GRN}${NC} $*"; PASS=$((PASS+1)); }
fail() { echo -e "${RED}${NC} $*"; FAIL=$((FAIL+1)); }
note() { echo -e "${CYN}${NC} $*"; }
warn() { echo -e "${YEL} !${NC} $*"; }
api() {
local method="${1:-GET}"; shift
local path="$1"; shift
local args=(-sS -X "$method" -H 'Content-Type: application/json')
[[ -n "$AUTH_TOKEN" ]] && args+=(-H "Authorization: Bearer $AUTH_TOKEN")
curl "${args[@]}" "$@" "${MAM_API_URL}${path}"
}
echo -e "${BLD}${CYN}Wild Dragon — Cluster Smoke Test${NC}"
echo -e "Primary: $MAM_API_URL"
echo ""
# ── 1. Primary API health ───────────────────────────────────────────────
echo -e "${BLD}1. Primary API health${NC}"
if api GET /health | grep -q '"status":"ok"'; then
pass "primary /health responds"
else
fail "primary /health did not return ok"
fi
echo ""
# ── 2. Cluster registry ─────────────────────────────────────────────────
echo -e "${BLD}2. Cluster registry${NC}"
NODES_JSON=$(api GET /api/v1/cluster || echo '[]')
TOTAL=$(echo "$NODES_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
note "$TOTAL nodes registered"
if [[ "$TOTAL" -gt 0 ]]; then
# No duplicate hostnames
DUP=$(echo "$NODES_JSON" | python3 -c '
import sys, json
nodes = json.load(sys.stdin)
seen = {}
dups = []
for n in nodes:
h = n.get('hostname')
if h in seen: dups.append(h)
seen[h] = True
print(",".join(sorted(set(dups))))' 2>/dev/null)
if [[ -z "$DUP" ]]; then
pass "no duplicate hostnames"
else
fail "duplicate hostnames: $DUP — run migration 007"
fi
# No private docker IPs (172.16.0.0/12 reserved for docker bridges)
BAD_IPS=""
while IFS=$'\t' read -r host ip; do
[[ -z "$ip" ]] && continue
first="${ip%%.*}"; rest="${ip#*.}"; second="${rest%%.*}"
if [[ "$first" == "172" && "$second" == "17" ]]; then
BAD_IPS+="${host}=${ip},"
fi
done < <(echo "$NODES_JSON" | jq -r '.[] | [.hostname, (.ip_address // "")] | @tsv')
BAD_IPS="${BAD_IPS%,}"
if [[ -z "$BAD_IPS" ]]; then
pass "all node IPs are real LAN addresses"
else
fail "nodes still reporting docker bridge IPs: $BAD_IPS"
warn " → set NODE_IP in .env.worker and restart the node-agent"
fi
# All nodes recently seen
STALE=$(echo "$NODES_JSON" | python3 -c '
import sys, json
nodes = json.load(sys.stdin)
stale = [n["hostname"] for n in nodes if float(n.get("stale_seconds") or 9999) > 120]
print(",".join(stale))' 2>/dev/null)
if [[ -z "$STALE" ]]; then
pass "all nodes heartbeated within 2 min"
else
warn "stale nodes (>2 min since heartbeat): $STALE"
fi
fi
echo ""
# ── 3. Per-node /health probes ──────────────────────────────────────────
echo -e "${BLD}3. Worker agent /health endpoints${NC}"
echo "$NODES_JSON" | python3 -c '
import sys, json
for n in json.load(sys.stdin):
if n.get("role") == "primary": continue
print(n["id"], n["hostname"], n.get("api_url") or "")
' 2>/dev/null | while read -r ID HOST URL; do
[[ -z "$URL" ]] && { warn "$HOST: no api_url registered"; continue; }
if curl -sf --max-time 4 "$URL/health" >/dev/null 2>&1; then
pass "$HOST ($URL/health)"
else
fail "$HOST agent unreachable at $URL/health"
fi
done
echo ""
# ── 4. Local GPU + NVENC probe (when run on a GPU node) ─────────────────
echo -e "${BLD}4. Local GPU + NVENC${NC}"
if command -v nvidia-smi >/dev/null 2>&1; then
GPU_COUNT=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | wc -l)
if [[ "$GPU_COUNT" -gt 0 ]]; then
pass "$GPU_COUNT NVIDIA GPU(s) visible to host"
if command -v ffmpeg >/dev/null 2>&1; then
if ffmpeg -hide_banner -loglevel error \
-f lavfi -i testsrc=duration=5:size=1280x720:rate=30 \
-c:v h264_nvenc -preset p1 -b:v 4M \
-t 5 -f null - 2>/tmp/wd-nvenc.log; then
pass "NVENC encode test succeeded"
else
fail "NVENC encode failed — see /tmp/wd-nvenc.log"
fi
else
warn "ffmpeg not installed locally — skipping NVENC encode test"
fi
else
warn "nvidia-smi found but reports 0 GPUs"
fi
else
warn "nvidia-smi not present (not a GPU node)"
fi
echo ""
# ── 5. Blackmagic device enumeration ────────────────────────────────────
echo -e "${BLD}5. Blackmagic devices (cluster-wide)${NC}"
BMD_JSON=$(api GET /api/v1/cluster/devices/blackmagic || echo '[]')
BMD_COUNT=$(echo "$BMD_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
if [[ "$BMD_COUNT" -gt 0 ]]; then
pass "$BMD_COUNT DeckLink port(s) registered"
echo "$BMD_JSON" | jq -r '.[] | " \(.hostname) port=\(.index) model=\(.model // "unknown") online=\(.online)"'
else
warn "no DeckLink devices reported by any node"
fi
echo ""
# ── 6. Local Blackmagic device files ────────────────────────────────────
echo -e "${BLD}6. Local /dev/blackmagic${NC}"
if [[ -d /dev/blackmagic ]]; then
ls /dev/blackmagic/ | sed 's/^/ /'
pass "$(ls /dev/blackmagic/ | wc -l) device node(s) under /dev/blackmagic"
else
warn "no /dev/blackmagic on this machine"
fi
echo ""
# ── Summary ─────────────────────────────────────────────────────────────
echo -e "${BLD}Summary:${NC} ${GRN}$PASS pass${NC} ${RED}$FAIL fail${NC}"
[[ "$FAIL" -gt 0 ]] && exit 1 || exit 0

33
docker-compose.gpu.yml Normal file
View file

@ -0,0 +1,33 @@
# Wild Dragon MAM — GPU overlay
# Apply on top of docker-compose.yml on nodes with NVIDIA GPUs.
#
# Prerequisites: NVIDIA Container Toolkit installed on the host.
# Install: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/
#
# Usage (core MAM node with GPUs):
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
#
# Usage (worker node with GPUs):
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
#
# This overlay:
# - Rebuilds worker from Dockerfile.gpu (CUDA base + ffmpeg NVENC)
# - Passes all NVIDIA GPUs into the worker container
# - Sets NVENC_ENABLED=true so the worker prioritises h264_nvenc/hevc_nvenc
services:
worker:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
NVENC_ENABLED: "true"
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility

130
docker-compose.worker.yml Normal file
View file

@ -0,0 +1,130 @@
# Wild Dragon MAM — Worker Node Stack
# ─────────────────────────────────────
# Deploy on any machine you want to join the cluster as a worker.
# The primary stack (mam-api, db, redis) continues running on TrueNAS.
#
# Required env vars (set in .env.worker or export before running):
# MAM_API_URL URL of the primary MAM API e.g. http://10.0.0.25:47432
# NODE_TOKEN Bearer token from the primary's Tokens page
# NODE_IP Host LAN IP to report (set by onboard-node.sh)
#
# Optional hardware overrides (if Docker can't see /dev directly):
# GPU_COUNT Number of NVIDIA GPUs on this node (default: auto-detect from /dev/nvidia*)
# BMD_COUNT Number of Blackmagic DeckLink cards (default: auto-detect from /dev/blackmagic/)
# BMD_MODEL Marketed card name (e.g. "DeckLink Duo 2") — drives the port-diagram UI
#
# Optional env vars (needed only if starting the worker or capture profiles):
# REDIS_URL, DATABASE_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY
# BMD_DEVICE_0 DeckLink device path (default: /dev/blackmagic/dv0)
# (DeckLink IO / Quad cards expose /dev/blackmagic/io* instead — set BMD_DEVICE_PREFIX=io)
# BMD_DEVICE_1 DeckLink device path (default: /dev/blackmagic/dv1)
# BMD_DEVICE_PREFIX Naming prefix for synthesized BMD_COUNT-based devices (default: dv). Use 'io' for IO/Quad.
# LIVE_DIR Host path for HLS live segments (default: /mnt/NVME/MAM/wild-dragon-live)
#
# Profiles:
# (default) node-agent only — cluster visibility + hardware heartbeat
# --profile worker + CPU/GPU job worker (proxy generation, transcoding)
# --profile capture + SDI capture service (requires Blackmagic DeckLink card)
#
# To enable GPU transcoding, also apply docker-compose.gpu.yml:
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
#
# NOTE: The node-agent mounts /var/run/docker.sock to spawn on-demand SDI
# capture sidecars when the primary mam-api routes a recorder to this node.
# Build the capture image before first use:
# docker compose -f docker-compose.worker.yml build capture
services:
# node-agent runs in host network mode so it can see the real host
# interfaces, GPU devices and DeckLink cards without bridging tricks.
# The reported IP / hostname will be the host's, not the container's.
node-agent:
build: ./services/node-agent
restart: unless-stopped
network_mode: host
environment:
MAM_API_URL: ${MAM_API_URL}
NODE_TOKEN: ${NODE_TOKEN:-}
NODE_ROLE: ${NODE_ROLE:-worker}
NODE_IP: ${NODE_IP:-}
AGENT_PORT: ${AGENT_PORT:-7436}
HEARTBEAT_MS: ${HEARTBEAT_MS:-30000}
GPU_COUNT: ${GPU_COUNT:--1}
BMD_COUNT: ${BMD_COUNT:--1}
BMD_MODEL: ${BMD_MODEL:-}
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /dev:/dev:ro
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro
devices:
- /dev/blackmagic:/dev/blackmagic
worker:
build: ./services/worker
profiles: [worker]
restart: unless-stopped
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
NVENC_ENABLED: ${NVENC_ENABLED:-false}
networks:
- wild-dragon-worker
# SDI capture service — only start on nodes with Blackmagic DeckLink cards
# Set BMD_DEVICE_0 in .env.worker to the actual device path, e.g. /dev/blackmagic/dv0
capture:
build: ./services/capture
profiles: [capture]
restart: unless-stopped
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
CAPTURE_PORT: 3001
devices:
- ${BMD_DEVICE_0:-/dev/blackmagic/dv0}:/dev/blackmagic/dv0
- ${BMD_DEVICE_1:-/dev/blackmagic/dv1}:/dev/blackmagic/dv1
ports:
- "${CAPTURE_PORT:-7437}:3001"
networks:
- wild-dragon-worker
# 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:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
image: wild-dragon-worker-gpu:latest
runtime: nvidia
restart: unless-stopped
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: proxy,conform,trim
PROXY_CONCURRENCY: "3"
NVIDIA_VISIBLE_DEVICES: GPU-13acf439-8bf4-a5e0-7804-c1071bca547a
WORKER_LABEL: "zampp2 / L4"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks:
- wild-dragon-worker
networks:
wild-dragon-worker:
driver: bridge

View file

@ -5,6 +5,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${PORT_DB:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
@ -18,6 +20,8 @@ services:
queue:
image: redis:7-alpine
ports:
- "${PORT_REDIS:-6379}:6379"
volumes:
- redis_data:/data
networks:
@ -34,6 +38,14 @@ services:
- "${PORT_MAM_API:-7432}:3000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /mnt/NVME/MAM/wild-dragon-live:/live
- /mnt/NVME/MAM/wild-dragon-growing:/growing
- /mnt/NVME/MAM/wild-dragon-media:/media
- /mnt/NVME/MAM/sdk:/sdk
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /run/systemd:/run/systemd
- /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
@ -44,7 +56,21 @@ services:
S3_REGION: ${S3_REGION:-us-east-1}
SESSION_SECRET: ${SESSION_SECRET}
AUTH_ENABLED: ${AUTH_ENABLED:-false}
TRUST_PROXY: ${TRUST_PROXY:-false}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
DOCKER_NETWORK: wild-dragon_wild-dragon
NODE_IP: ${NODE_IP}
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
CAPTURE_TOKEN: ${CAPTURE_TOKEN}
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
networks:
- wild-dragon
@ -64,11 +90,23 @@ services:
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /run/systemd:/run/systemd
networks:
- wild-dragon
worker:
build: ./services/worker
# ── GPU worker pool (capability-routed) ──────────────────────────────
# worker-p4: HEAVY tier (proxy/conform/trim) on the Tesla P4 (NVENC).
# Also runs the promotion scanner (RUN_PROMOTION) — exactly one worker must.
worker-p4:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on:
- queue
- db
@ -80,6 +118,60 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
GROWING_PATH: /growing
# Includes `import` (YouTube importer): the import queue had no consumer
# after the capability-routing split, so import jobs sat unprocessed and
# assets stayed `ingesting` forever. import is concurrency-1 + network-
# bound, so one consumer (this heavy/primary worker) is sufficient.
WORKER_QUEUES: proxy,conform,trim,import,playout-stage
RUN_PROMOTION: "true"
PROXY_CONCURRENCY: "2"
PLAYOUT_MEDIA_DIR: /media
NVIDIA_VISIBLE_DEVICES: GPU-79afca3e-2ab2-a6ea-1c44-706c1f0a26d6
WORKER_LABEL: "zampp1 / Tesla P4"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
volumes:
- /mnt/NVME/MAM/wild-dragon-growing:/growing
- /mnt/NVME/MAM/wild-dragon-media:/media
networks:
- wild-dragon
# worker-p400a/b: LIGHT tier (thumbnail/filmstrip) on the two Quadro P400s.
worker-p400a:
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: [queue, db, worker-p4]
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: thumbnail,filmstrip
NVIDIA_VISIBLE_DEVICES: GPU-331c53ea-2ed9-0007-e364-c1451775948f
WORKER_LABEL: "zampp1 / P400 #1"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks:
- wild-dragon
worker-p400b:
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: [queue, db, worker-p4]
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: thumbnail,filmstrip
NVIDIA_VISIBLE_DEVICES: GPU-b514a592-9077-44bd-d9e8-9efa0591ef88
WORKER_LABEL: "zampp1 / P400 #2"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks:
- wild-dragon
@ -87,9 +179,24 @@ services:
build: ./services/web-ui
ports:
- "${PORT_WEB_UI:-7434}:80"
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /run/systemd:/run/systemd
networks:
- wild-dragon
# Build-only: the CasparCG sidecar image. mam-api spawns these on-demand per
# channel (one container per playout channel), so this service is never up'd —
# it exists so `docker compose build playout` produces the image the API tags
# via PLAYOUT_IMAGE. Profile excludes it from default `up`.
playout:
profiles: ["build-only"]
build: ./services/playout
image: wild-dragon-playout:latest
volumes:
postgres_data:
redis_data:

186
docs/DESCRIPTION.md Normal file
View file

@ -0,0 +1,186 @@
# Dragonflight · Feature Overview
Dragonflight is a self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. It handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
## Home Dashboard
![Home Dashboard](./screenshots/01-home.png)
The home screen provides quick access to all major features and displays system status at a glance:
- **Library** — Browse projects, bins, and assets with hover-scrub previews
- **Recorders** — View configured capture devices and their status
- **Editor** — Timeline editor with cross-clip preview and render queue
- **Jobs** — Proxy and thumbnail queue with retry controls
- **Settings** — Configure storage, encoder, growing files, and capture SDK
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
---
## Core Features
### 1. Live Ingest & Capture
**Multi-protocol source capture with per-recorder codec settings**
Dragonflight ingests from multiple sources simultaneously:
- **SRT** (Secure Reliable Transport) — caller and listener modes
- **RTMP** — standard streaming protocol
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
Each recorder can be configured with independent codec settings:
- ProRes (hi-res masters)
- H.264 / H.265 (proxies)
- DNxHR (Avid compatibility)
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
### 2. Growing-File Editing
**Live editing in Premiere Pro while capture is still writing**
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
- Real-time clip detection and frame-accurate trimming
- One-click relink to final S3 master after promotion
- No waiting for capture to finish before editorial begins
### 3. Recorder Scheduler
**Time-windowed recording automation**
Schedule recordings with:
- One-shot, daily, or weekly recurrence
- Automatic start/stop via 15-second tick loop
- Conflict detection across recorders
- Project and bin assignment at schedule time
### 4. Library & Asset Management
**Browse, search, and organize captured footage**
The Library screen provides:
- Project and bin hierarchy
- Asset detail view with frame-anchored persistent comments
- Right-click context menu (move-to-bin, rename, delete)
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
- Hover-scrub preview with HLS playback
### 5. Jobs Queue
**BullMQ-backed proxy and thumbnail generation**
Automated background processing:
- Per-job retry logic with exponential backoff
- Bulk "retry all failed" for batch recovery
- Inline error messages with actionable diagnostics
- Status tracking: ingesting → processing → ready
Proxy encoder options:
- CPU-based: libx264 (H.264)
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
### 6. Timeline Conform & Export
**FCP XML export with server-side FFmpeg rendering**
The Premiere Pro panel exports FCP XML with:
- Server-side conform via FFmpeg
- Multiple output formats: H.264, H.265, ProRes
- Resolution presets: Broadcast, Web, Archive
- Batch processing with job queue integration
### 7. Hi-Res Auto-Relink
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
After editing on proxies:
- Select clips in Premiere
- Trigger relink from the CEP panel
- Server trims hi-res segments to exact in/out points
- Concurrent trim worker pool for speed
- 24-hour TTL with automatic cleanup
### 8. Settings & Configuration
**Centralized control for storage, encoding, and capture**
Configure:
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
- **Growing Files** — SMB path, retention, auto-promotion
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
### 9. Cluster & Distributed Capture
**Primary + worker topology with remote DeckLink nodes**
- Primary node runs API, scheduler, and web UI
- Worker nodes handle proxy/thumbnail jobs
- Remote capture nodes run DeckLink cards off-host
- Heartbeat health monitoring
- Automatic failover and recovery
### 10. Admin & User Management
**Role-based access, token auth, and cluster monitoring**
- User creation and role assignment
- API token generation for integrations
- Container and cluster node status
- System health dashboard
---
## Architecture
```
SDI / SRT / RTMP ──► capture (FFmpeg)
├─ HLS preview tee ──► /live/<assetId>/index.m3u8
└─ master output
├─ growing_enabled=true:
│ /growing/<projectId>/<clip>.mov
│ (Premiere mounts SMB, edits live)
│ └─► promotion worker uploads to S3
└─ growing_enabled=false:
multipart stream → S3
assets POST ──► proxy job ──► worker
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
├─ thumbnail job
└─ status: ingesting → processing → ready
```
## Tech Stack
- **Runtime:** Node.js 22, Docker Compose
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
## Services
| Service | Port | Purpose |
|---------|------|---------|
| **web-ui** | 47434 | Browser SPA + capture controls |
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
| **worker** | — | BullMQ proxy + thumbnail workers |
| **db** | 5432 | PostgreSQL 16 |
| **queue** | 6379 | Redis 7 |
---
## Workflow Example: Live-to-Edit
1. **Operator** schedules a recording on Recorder A for 14:0015:30, assigns to "News/Segment-A" project
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
4. **Editor** trims and marks in/out points while capture is still writing
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
6. **Editor** clicks "Relink to Master" in CEP panel
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
8. **Premiere** relinks proxy clips to trimmed master
9. **Editor** exports final timeline via FCP XML conform
Total time from end of capture to relinked master: ~2 minutes.
---
## Operations
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
- `deploy/onboard-node.sh` — provision a remote worker host
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow

View file

@ -0,0 +1,99 @@
# Growing Files + Premiere Panel — Test Plan
A local SMB landing zone for capture so Premiere can edit the master while
it is still recording. The promotion worker uploads the finalized file to S3
and the panel relinks Premiere to the hi-res original.
## Cluster state (deployed 2026-05-22)
- TrueNAS dataset: `NVME/MAM-growing` (LZ4, 0777)
- TrueNAS SMB share: `mam-growing``/mnt/NVME/MAM-growing`
- Host symlink for docker compose: `/mnt/NVME/MAM/wild-dragon-growing` → the dataset
- mam-api + worker containers mount it at `/growing`
- Settings (live): `growing_enabled=true`, `growing_smb_url=smb://10.0.0.25/mam-growing`
## Capture flow (when growing_enabled=true)
1. Recorder starts. mam-api spawns a capture sidecar with `GROWING_ENABLED=true`
and binds `/mnt/NVME/MAM/wild-dragon-growing:/growing`.
2. FFmpeg writes the hi-res master directly to
`/growing/<projectId>/<clipName>.<ext>` (no S3 stream).
3. The HLS tee continues to publish `/live/<assetId>/index.m3u8`, so the
Recorders + Monitors pages get a real video preview.
4. On stop — or when the file's mtime is idle for
`growing_promote_after_seconds` — the promotion worker:
- uploads the local file to S3 at `projects/<projectId>/masters/<clipName>.<ext>`
- queues a proxy job
- flips the asset to `status=ready`
- deletes the local copy.
## Premiere panel install
Grab the latest release artifact and run it — the installer handles the file
copy, registry/plist debug-mode flip, and removes any legacy
`com.wilddragon.mam.panel` install:
- **Windows:** `dragonflight-premiere-panel-<version>-windows-setup.exe`
- **macOS / Win:** `dragonflight-premiere-panel-<version>.zxp` — install via
[Anastasiy's ZXP Installer](https://install.anastasiy.com/) (free GUI)
Releases live at
<https://forge.wilddragon.net/zgaetano/dragonflight/releases>.
Building locally (requires Windows for the `.exe`, any OS for the `.zxp`):
```
cd services/premiere-plugin/build
npm install
powershell -File build-all.ps1 # or: node build-zxp.mjs
```
The Windows installer takes care of `PlayerDebugMode`. If you installed the
ZXP and the panel does not appear in **Window → Extensions**, enable debug
mode manually:
```
# macOS
defaults write com.adobe.CSXS.11 PlayerDebugMode 1
# Windows
reg add "HKCU\Software\Adobe\CSXS.11" /v PlayerDebugMode /t REG_SZ /d 1 /f
```
Mount the SMB share once at OS level: `smb://10.0.0.25/mam-growing`.
In Premiere: Window → Extensions → Wild Dragon MAM.
## Test the live → finalized swap
1. Start a recorder (Ingest → Recorders → Record).
2. The Recorder row's preview becomes a real HLS `<video>` element.
3. In Premiere, with the growing asset selected (status=live), click
**Mount Live**. The panel calls `GET /api/v1/assets/:id/live-path`,
resolves the SMB UNC path, and `app.project.importFiles()` it. Premiere
imports the still-growing MOV.
4. Stop the recorder. After `growing_promote_after_seconds` of mtime
inactivity, the promotion worker uploads to S3 and flips status.
5. The panel polls every 5 s. When it sees `status=ready` it surfaces
**Relink to Hi-Res** — clicking that downloads the finalized hi-res
and calls `ProjectItem.changeMediaPath()` to relink in place. Timeline
cuts are preserved.
## Knobs (Settings → Growing files (SMB))
- `growing_enabled` — master switch
- `growing_path` — container mount path (default `/growing`)
- `growing_smb_url` — what the Premiere panel hands to the editor
- `growing_promote_after_seconds` — idle threshold for promotion
## What's NOT yet here
- Auth on the SMB share — currently passwordless. Add Samba auth via
`midclt call sharing.smb.update` and put creds in the editor's keychain
before exposing this beyond the LAN.
- Concurrent S3 backup of the growing file. Today's MVP writes to SMB only;
S3 happens at promotion. If you need belt-and-suspenders, add `-f tee` in
capture-manager to fan out to both destinations.
- Cleanup for stranded files (e.g. recorder crashes mid-capture). The idle
threshold will eventually promote them, but a stale-file sweeper would be
more graceful.

116
docs/WORK_LOG_2026_05.md Normal file
View file

@ -0,0 +1,116 @@
# Work Log — May 2026
## Summary
Session focused on auth system architecture, dashboard redesign, and audio track inspector. Multiple iterations on auth approach; settled on simplified local-account model with RBAC. Dashboard rebuilt as control-room status board. Audio tab completed with full metering and fader controls.
## Auth System Work
### Commits
- `002e5ac` — auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
- `d1f9557` — auth: park login flow — circle back
- `9726dbb` — Revert "auth: top-to-bottom rework..."
- `4172b0d` — rip out entire auth/login flow
### What Happened
Attempted comprehensive auth rewrite including:
- Local user account system with bcrypt hashing
- Role-based access control (admin/editor/viewer)
- Client tagging for audit trails
- Environment-based bootstrap (AUTH_ENABLED flag)
- Session management with PostgreSQL backing
**Decision**: Reverted entire auth work. Reason: complexity vs. current product stage. System was over-engineered for self-hosted use case where auth is optional.
**Current State**: Auth disabled by default (AUTH_ENABLED=false). When enabled, system returns synthetic /auth/me endpoint. No persistent user management yet.
### Related Fixes
- `cfcbec0` — fix(auth): make AUTH_ENABLED=true workable end-to-end
- `e71c330` — fix(auth): remove manual session.save() — was suppressing Set-Cookie header
- `65684aa` — fix(auth): ensure sessions table exists + log session.save errors
## Dashboard Redesign
### Commits
- `a48e1d9` — dashboard: rebuild as control-room status board (on air / up next / attention / work)
- `e5e0656` — dashboard: redesign stat cards, compress header, improve density
- `5de1e3d` — dashboard: add dense stat cards, cluster bars, job rows, sparkline fixes
- `48d54a3` — dashboard: add missing dash-* CSS classes; cluster: add stat-row/stat-card CSS
### What Changed
Transformed dashboard from generic metrics view to broadcast control-room interface:
- **On Air** section: live stream status, bitrate, duration
- **Up Next** section: queued clips/segments
- **Attention** section: warnings, errors, resource alerts
- **Work** section: active jobs, encoding progress
Added visual components:
- Dense stat cards with icon + value + trend
- Cluster health bars (CPU, memory, disk per node)
- Job progress rows with ETA
- Sparkline charts for trend visualization
CSS infrastructure added for consistent spacing/sizing across dashboard components.
## Audio Tab Implementation
### Commit
- `c48c7e6` — feat(audio-tab): full audio track inspector with meters, mute/solo, faders
### Features
- Per-track audio meters (VU-style, real-time)
- Mute/solo buttons per track
- Fader controls (0-100 dB range)
- Master output meter
- Track naming/labeling
- Visual feedback for clipping/peaks
## Other Work
### Storage & Admin
- `64d739b` — feat(admin): unified Storage settings page with mount/bucket health diagnostics
- `a44d8bd` — feat(admin): live video-presence indicators on cluster DeckLink ports
### Player & Streaming
- `a86c1c7` — fix(player): stitch S3 ranges around RustFS empty-body bug (#143)
- `d257a19` — fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
- `37247fd` — fix(video): direct S3 signed URL for streaming + proxy bitrate 1.5Mbps
- `e4d4c00` — feat(proxy): VBR 500k-1M encoding for proxy generation
### Cluster & Hardware
- `55ff2e7` — feat(cluster): full hardware breakdown per node
- `5ff507b` — fix(node-agent): use nsenter to run nvidia-smi in host mount namespace
- `558c18e` — fix(node-agent): detect GPUs via docker run --gpus all ubuntu:22.04
- `a6f045b` — fix(node-agent): probe GPU via Docker API async at startup, cache result
### Release & Cleanup
- `04ce096` — chore: 1.2 ship-prep sweep — close 38 issues
- `f0f6156` — release: add v1.1.0 ZXP artifact (Growing tab + visual system alignment)
## Blockers / Open Questions
### Auth System
- **Decision needed**: Should auth be mandatory for production? Current design assumes optional.
- **API endpoints missing**: `/users`, `/auth/me`, `/groups` routes not yet implemented in mam-api
- **Frontend expects**: Users list, groups management, role-based UI filtering
### Dashboard
- Real data integration needed (currently mock data)
- Cluster stats endpoint integration
- Job queue polling/WebSocket updates
### Audio
- Backend audio processing pipeline not yet connected
- Metering data source undefined
- Fader changes need routing to encoder
## Next Steps (Recommended)
1. **Clarify auth requirements**: Is user management needed for v1.2? If yes, implement `/users` and `/groups` endpoints.
2. **Connect dashboard to live data**: Wire cluster stats, job queue, stream status to real endpoints.
3. **Audio backend integration**: Define audio processing pipeline and metering data flow.
4. **Testing**: Add integration tests for auth flow, dashboard data binding, audio control.
---
**Session ended**: 2026-05-27 06:31 CDT
**Status**: Work logged, auth decision documented, next steps identified

View file

@ -0,0 +1,197 @@
# All-Intra HEVC (NVENC) Growing-File Ingest
Date: 2026-05-29 | Status: design, pending validation gate (see §8)
Authors: Zac + Claude
## 1. Purpose
Replace the CPU-bound ProRes capture encode with **All-Intra HEVC on NVENC**
as the growing-file master codec, so we can:
- **Offload ingest encode from CPU to GPU** (the current scaling wall), and
- **Keep edit-while-record** (all-intra => growing file stays editable), and
- **Scale to up to 8 simultaneous signals per machine**, across Blackmagic
today and Deltacast + AJA later.
This doc captures the target design AND the current working system it builds on,
so it is self-contained for whoever implements it.
## 2. Why this codec
Growing-file editing (Premiere/Avid mounting a still-recording file over SMB)
requires two things: **intra-frame** (every frame a keyframe, so a partial file
is decodable to the last whole frame) and a **container whose index is not
deferred to EOF**. ProRes/DNxHR satisfy this but are CPU-only (NVIDIA has no
ProRes encoder). Long-GOP H.264/HEVC/AV1 do NOT work for edit-while-record.
**All-Intra HEVC (`-g 1 -bf 0`) via `hevc_nvenc`** is the one path that is both
GPU-accelerated AND all-intra: it breaks the "ProRes must be CPU" constraint
without losing edit-while-record. Trade-off: All-Intra bitrate approaches
ProRes, so the win is **CPU offload, not storage**. AV1 is rejected (no NLE
edit support; av1_nvenc absent from our ffmpeg builds).
## 3. Current working system (what we build on)
### Topology
- **zampp1** (172.18.91.200): primary. Runs db (postgres), queue (redis),
mam-api (:47432), web-ui (:47434), and the GPU worker pool. GPUs: Tesla P4 +
2x Quadro P400. Repo at /opt/wild-dragon (its own clone).
- **zampp2** (172.18.91.216): worker/capture node. 12-vCPU QEMU VM, NVIDIA L4,
4x Blackmagic DeckLink (exposed as /dev/blackmagic/io0..io3). Runs node-agent
(:7436). Repo at /opt/wild-dragon (separate clone).
- The repo is checked out independently on BOTH nodes; node-specific files
(node-agent, capture, worker overlay) are edited on the node that runs them.
### Capture (current)
mam-api `POST /recorders/:id/start` pre-creates a `live` asset and dispatches
`POST /sidecar/start` to the recorder's node-agent, which spawns a
`wild-dragon-capture:latest` container (host network, privileged,
/dev/blackmagic bound). The capture ffmpeg:
- input: `-f decklink -i "DeckLink Duo (N)"`
- filter: `yadif` (CPU deinterlace)
- output 0 (master): `prores_ks` (CPU) -> S3 (pipe) or growing SMB file
- output 1 (preview): `libx264` veryfast HLS -> /live/{assetId} (CPU)
DeckLink does capture (cheap); BOTH encodes are CPU. ~5 vCPU per 1080i signal
=> ~2 signals saturate the 12-vCPU VM. GPUs are idle during capture.
### Stop / finalize (working)
node-agent stops the sidecar with a **180s grace** (was 10s -> SIGKILL bug).
Capture's SIGTERM handler finalises the session and calls
`POST /assets/:id/finalize` (the live asset id passed as ASSET_ID), which flips
the asset out of `live`, records duration + S3 keys, and kicks the
proxy -> thumbnail -> filmstrip chain. (Earlier 409 bug: it used to POST a new
asset and collide with the live row.)
### Live monitor (working)
SDI HLS preview is a 2nd output of the capture ffmpeg (one DeckLink read ->
split -> ProRes + H.264 HLS), written to /live/{assetId} on the capture node.
node-agent serves GET /live/* over HTTP; mam-api proxies
GET /api/v1/recorders/:id/live/* to the recorder's node-agent; the web-ui
HlsPreview loads the proxied URL. Browser auth is the session cookie
(same-origin).
### GPU worker pool (working, post-capture)
BullMQ on shared Redis; queues are type-named (proxy/thumbnail/filmstrip/
conform/trim). Workers are capability-routed by `WORKER_QUEUES`, one GPU-pinned
container per card (`NVIDIA_VISIBLE_DEVICES` by UUID):
- HEAVY (proxy/conform/trim): Tesla P4 (zampp1) + L4 (zampp2), `h264_nvenc`.
- LIGHT (thumbnail/filmstrip): 2x Quadro P400 (zampp1).
DB setting `gpu_transcode_enabled=true` + `gpu_codec=h264_nvenc` enable NVENC.
Each worker stamps `WORKER_LABEL` onto job data -> Jobs UI "Node" column.
`RUN_PROMOTION=true` on exactly one worker runs the growing-files->S3 scan.
The worker GPU image is built from services/worker/Dockerfile.gpu (CUDA base +
Ubuntu ffmpeg with h264/hevc_nvenc; NO av1_nvenc).
### Deploy gotchas (learned)
- Service source is BAKED into images; edits need rebuild + recreate (or the
GPU image rebuild reuses cached layers so only final COPY changes -> fast).
- The capture image can only build on zampp2 (DeckLink SDK present there).
- Per-node `.env`: zampp2's REDIS_URL/DATABASE_URL/S3_* now point at zampp1
(.200); secrets live only in .env, never in committed compose.
- Clear all containers on both nodes before a full redeploy (user preference).
## 4. Target design
### 4.1 Capture ffmpeg gains NVENC
The capture image's custom FFmpeg 7.1 is currently built WITHOUT nvenc (only
prores_ks/dnxhd/libx264). Rebuild `services/capture/Dockerfile` ffmpeg with:
`--enable-cuda-nvcc --enable-libnpp --enable-nvenc --enable-cuvid` plus
nv-codec-headers (ffnvcodec) installed before configure. Keep `--enable-decklink`
and the existing codecs (ProRes stays available as a selectable fallback).
Verify `ffmpeg -encoders | grep nvenc` shows hevc_nvenc/h264_nvenc afterwards.
### 4.2 Capture sidecar gets a GPU
node-agent `handleSidecarStart` currently spawns the capture container with no
GPU. Add NVIDIA runtime + device pinning to the sidecar create spec:
`HostConfig.Runtime='nvidia'` (or DeviceRequests with the node's GPU) and env
`NVIDIA_VISIBLE_DEVICES=<uuid>` + `NVIDIA_DRIVER_CAPABILITIES=video,compute,utility`.
The capture node's GPU is shared with its worker-l4 (see capacity, §5).
### 4.3 Encode parameters (master)
All-Intra HEVC on NVENC:
`-c:v hevc_nvenc -preset p4 -rc vbr -g 1 -bf 0 -profile:v main10 -pix_fmt p010le` (10-bit 4:2:2 is not NVENC-native; NVENC HEVC is 4:2:0 8/10-bit.
If 4:2:2 mezzanine is required, that is a HARD blocker for NVENC and we stay on
ProRes for those feeds — see §8). Bitrate target tuned per format (1080i59.94
~100-160 Mbps to rival ProRes HQ). `-g 1 -bf 0` => every frame IDR (all-intra).
### 4.4 Container (growing-file)
Write the master to a growing file on the SMB share (GROWING_PATH), same path
the promotion worker already uploads on EOF. Container candidates, in order of
preference for Premiere growing-file mounts:
1. **MXF OP1a** (`-f mxf`) — broadcast standard, designed for growing/edit-while-
ingest; best Avid/Premiere support. HEVC-in-MXF support in Premiere is the
key unknown to validate (§8).
2. **Fragmented MOV/MP4** (`-movflags +frag_keyframe+empty_moov+default_base_moof`)
— no moov-at-EOF, readable while growing; fallback if MXF+HEVC is unsupported.
The HLS preview path is unchanged except it can also move to h264_nvenc now that
capture has NVENC (frees the last libx264 CPU cost).
## 5. Capacity & scaling (8 signals/machine)
After the move, per-signal CPU is just: DeckLink capture + yadif + mux + frame
upload to the GPU. The heavy HEVC encode is on NVENC. The constraint shifts from
CPU to **NVENC throughput + GPU memory + PCIe/host bandwidth**:
- The **L4 is a datacenter card => unlimited NVENC sessions** (no consumer
3-session cap). 8x 1080i HEVC-I encode sessions are well within an L4.
- GPU memory: ~8 concurrent 1080 NVENC sessions + frame buffers fit in 24 GB.
- The capture node's L4 is shared between capture (per-signal HEVC-I) and the
worker-l4 proxy jobs. Under 8-signal load, give capture priority; consider
moving worker-l4 (post-record proxies) to zampp1's P4 only, or gate worker-l4
intake while signals are live.
- yadif on CPU is still ~0.5-1 vCPU/signal; consider `yadif_cuda`/`bwdif_cuda`
(GPU deinterlace) once frames are uploaded to the GPU, keeping CPU near-idle.
**Node sizing:** a 12-vCPU VM was the ProRes wall; with GPU encode the same VM
should carry many more signals, but for 8x SDI + GPU + card passthrough prefer a
larger VM or bare metal with proper PCIe passthrough. Or spread signals across
multiple capture nodes (the node-agent model already supports N nodes; mam-api
routes each recorder to its node).
## 6. Multi-vendor capture (Blackmagic / Deltacast / AJA)
Today capture is hard-wired to `-f decklink`. Before three vendors accrue
special-cases, introduce a **source-backend abstraction** in capture-manager:
each backend returns ffmpeg input args + device discovery.
- **Blackmagic**: `-f decklink -i "<name>"` (current). Devices via
`ffmpeg -sources decklink`.
- **Deltacast**: VideoMaster SDK. No native ffmpeg demuxer upstream — needs an
SDK-backed capture (their SDK -> pipe to ffmpeg, or a small grabber). Plan a
`deltacast` backend that shells their tool into ffmpeg stdin (rawvideo).
- **AJA**: libajantv2. Also no upstream ffmpeg input; AJA ships `ntv2` capture
tools. Plan an `aja` backend feeding rawvideo into ffmpeg.
All backends converge on the SAME encode/output stage (HEVC-I NVENC + HLS), so
only the input differs. node-agent already binds the right /dev nodes per
sourceType (decklink/deltacast); extend for AJA.
## 7. Risks
- **4:2:2 / 10-bit chroma:** NVENC HEVC is 4:2:0 (8/10-bit). ProRes HQ is 4:2:2
10-bit. If a workflow REQUIRES 4:2:2 mezzanine, NVENC HEVC cannot match it and
those feeds stay on ProRes (CPU). Decide per-workflow.
- **Premiere growing HEVC support:** edit-while-record for HEVC-in-MXF (or frag
MOV) is unproven in our stack — this is the make-or-break validation (§8).
- **GPU contention** between live capture and post-record proxies on the same
L4; mitigate by prioritising capture / relocating proxy load.
- **Storage:** All-Intra HEVC bitrate ~ ProRes; expect similar disk usage.
- **Editor performance:** HEVC-I decode in Premiere is heavier than ProRes on
the edit workstation (decode cost moves to the editor). Validate scrubbing.
- **NVENC quality at all-intra** vs ProRes for archival; tune bitrate/preset.
## 8. Validation gate (do FIRST, before building the pipeline)
Prove the editor story on ONE channel before wiring 8:
1. Rebuild capture ffmpeg with NVENC; give the sidecar the L4.
2. Capture one DeckLink feed to All-Intra HEVC, writing a GROWING file to the
SMB share in (a) MXF OP1a, then (b) fragmented MOV.
3. While still recording, mount it in Premiere over SMB and confirm:
edit-while-record works, scrubbing is acceptable, audio in sync, file remains
valid after stop. Pick the container that works; if neither does, HEVC-I is
capture-only (no growing edit) and we keep ProRes for growing workflows.
## 9. Rollout
1. Validation gate (§8) on one channel.
2. Make capture codec/container a recorder setting; default growing feeds to
HEVC-I NVENC, keep ProRes selectable.
3. Move HLS preview to h264_nvenc.
4. Source-backend abstraction (§6) — land before Deltacast/AJA hardware.
5. GPU deinterlace + capacity test to 8 signals; finalise node sizing.

Binary file not shown.

View file

@ -0,0 +1,57 @@
# Cluster Hardening + Codec Settings Revamp
> Status: **mostly shipped 2026-05-21**. One follow-up remains: the recorders.html UI rewrite. See "Pending" below.
## Goal
Four user-driven asks from 2026-05-20:
1. Fix cluster page: workers were registering with docker bridge IPs, and three duplicate "zampp2" rows kept appearing.
2. Expand recorder codec settings: per-recorder control over bitrate, framerate, audio channels, container format.
3. Better DeckLink port picker: "BM1/BM2" dropdown was unusable -- diagram the card so operators pick a port visually.
4. Validate the cluster end-to-end now that GPUs are in place.
## What shipped (commit list)
| Commit | Area | Summary |
|---|---|---|
| `a39c983` | mam-api | Migration 007 -- dedupe `cluster_nodes` rows + unique index on `hostname`. |
| `049beb8` | mam-api | Migration 008 -- expanded codec columns on `recorders` (video/audio bitrate, framerate, audio channels, container, plus `node_id` / `device_index` pinning). |
| `3b4af6e` | node-agent | Prefer `NODE_IP` env override; skip docker bridge / veth / cni interfaces when auto-detecting. Version bumped to 1.1.0. |
| `0efef0d` | mam-api | `routes/cluster.js`: `pickIp()` fallback to request source IP. New `GET /api/v1/cluster/devices/blackmagic` flattens every node's DeckLink capabilities. |
| `40a66ba` | compose | `docker-compose.worker.yml`: `network_mode: host` for node-agent so it inherits host hostname + LAN IP. |
| `0ebb3cf` | deploy | `onboard-node.sh`: auto-detect host LAN IP and write `NODE_IP` + `BMD_MODEL` to `.env.worker`. |
| `f4a83ee` | capture | `capture-manager.js`: dynamic ffmpeg args. Exports `VIDEO_CODECS`, `AUDIO_CODECS`, `CONTAINER_FMT`, `CONTAINER_EXT`. |
| `485af25` | capture | `index.js` bootstrap forwards every codec env var to `captureManager.start()`. |
| `4c65753` | mam-api | `routes/recorders.js`: full codec field whitelist; `/start` passes settings to the capture sidecar. |
| `d39f86d` | web-ui | `services/web-ui/public/js/bmd-card.js` -- SVG renderer for DeckLink port selection. Models: Duo 2, Quad 2, Mini Recorder 4K, Mini Monitor 4K, UltraStudio 4K Mini. |
| `8aa3783` | deploy | `deploy/test-cluster.sh` cluster smoke test. |
| `4a3a672` | cluster | `mam-api` self-heartbeat reads `NODE_HOSTNAME` (otherwise every restart spawns a new primary row). Smoke test rewritten with `jq` after Python f-strings were found to silently false-pass the docker-bridge check. Bridge alarm narrowed to 172.17.x since this LAN occupies 172.18.0.0/16. |
## Verified cluster state (post-deploy, 2026-05-21)
```
$ MAM_API_URL=http://localhost:47432 bash deploy/test-cluster.sh
6 pass 0 fail
```
Two nodes registered, no duplicate hostnames, real LAN IPs (zampp1=172.18.91.216 primary, zampp2=172.18.91.217 worker), fresh heartbeats, 3 NVIDIA GPUs visible on zampp1, DeckLink Duo 2 reporting all 4 ports on zampp2.
## Deploy state
- **zampp1**: at `4a3a672`, rebuilt `mam-api`/`web-ui`/`worker`/`capture`, migrations 007+008 applied at startup. `.env` has `NODE_HOSTNAME=zampp1`, `NODE_IP=172.18.91.216`.
- **zampp2**: at `4a3a672`, rebuilt `node-agent` + `worker`. `.env` has `NODE_IP=172.18.91.217`, `BMD_COUNT=4`, `BMD_MODEL="DeckLink Duo 2"`, `BMD_DEVICE_0..3` populated.
- **Forgejo PAT** is at `/root/.git-credentials` on zampp1 (mode 600). Pushes from zampp1 need `HOME=/root`.
## LAN topology gotcha
The user's LAN is **172.18.91.0/24** -- inside Docker's reserved 172.16.0.0/12 range. Any heuristic that flags all of 172.16-172.31 as "docker bridge" will produce false positives. The smoke test now alarms only on 172.17.x (default docker0). The server-side `pickIp()` in `routes/cluster.js` has the same vulnerability but the node-agent's `NODE_IP` env-var override masks it in practice.
## Pending
- [ ] **`services/web-ui/public/recorders.html` rewrite.** The supporting pieces are in `main` but the HTML wiring was lost to a context-compaction event mid-session. Required UI:
- Tabbed codec settings (Video / Audio / Container) for both master and proxy.
- SDI source picker: node dropdown + inline `BMDCards.render(...)` SVG with click-to-select.
- Load BMD card data from `GET /api/v1/cluster/devices/blackmagic`.
- `<script src="js/bmd-card.js?v=1"></script>` in the head.
- SVG styles (`.bmd-card-svg`, `.bmd-port-ring`, `.bmd-port-group.is-selected`, ...) inlined or split into a CSS file.
- [ ] **Visual polish pass** with flyonui MCP -- the user noted the current UI "still looks AI-designed". Should happen AFTER the recorders.html rewrite.
## How to pick this up
1. `cd /opt/wild-dragon && git pull` on zampp1 (or zampp2).
2. Read this file end-to-end. Then `services/web-ui/public/js/bmd-card.js` (top JSDoc explains the API) and `services/capture/src/capture-manager.js` (codec catalogs).
3. Inspect `recorders.html` -- it still has the pre-revamp "BM1/BM2" dropdown and flat codec fields. Compare against the `recorders` table columns in `008-codec-settings.sql` for the full field set the UI should drive.
4. Iterate against a live deployment: `bash deploy/test-cluster.sh` for regression check, plus the actual `/recorders.html` page in a browser (web-ui on port 8080, mam-api on 47432).
5. Commit through Forgejo MCP if the diff is small; otherwise push from zampp1 (see Deploy state above for creds location). **Cloudflare WAF blocks large MCP uploads** (the blocked domain is `anthropic.com`, not Forgejo) -- pushing from a host with creds is faster for anything over ~3 KB.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,502 @@
# UI Shell Rework — Wave 2 (Low-risk page migrations) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or executing-plans. Steps use `- [ ]` checkboxes.
**Goal:** Migrate the 6 lowest-risk shell pages (login, home, settings, tokens, users, containers) from `css/common.css` + bespoke per-page CSS to the new `/dist/app.css` primitive bundle from wave 1. Each page goes from "old look" to "new look" with the same functionality. Also fold in the deferred token cleanups from the wave-1 code review.
**Architecture:** Each page migration is a self-contained markup rewrite. Pattern: swap the `<link>` to `/dist/app.css`, replace the sidebar + topbar markup with `wd-*` primitives, restyle page-specific content with `wd-card-asset` / `wd-card-op` / `wd-list-row` / `wd-form-*` / `wd-btn` / etc. Preserve every existing `<script>` and `id` so JS keeps working. Deploy after each page; check.
**Tech Stack:** Tailwind+flyon-ui bundle from wave 1 (already live), nginx static, no JS changes expected.
**Reference spec:** `docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md`
**Wave 1 plan:** `docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md`
---
## File structure
**Files this wave modifies:**
```
services/web-ui/
├── public/
│ ├── login.html (REWRITE: small, no sidebar, just form)
│ ├── home.html (REWRITE: hero + stat tiles, has sidebar)
│ ├── settings.html (REWRITE: tabbed settings forms, has sidebar)
│ ├── tokens.html (REWRITE: list of tokens + create panel, has sidebar)
│ ├── users.html (REWRITE: user list + edit slide-panel, has sidebar)
│ └── containers.html (REWRITE: docker container list + logs, has sidebar)
└── src/css/components/
└── tokens.css (MODIFY: add deferred token cleanups)
```
**Files this wave does NOT touch:** the other 9 pages (index, projects, upload, jobs, api-tokens, recorders, cluster, capture, edit, editor, player). They're wave 3 / 4 / excluded.
---
## Tasks
### Task 0: Fold deferred token cleanups into tokens.css
Address items 1, 2, 4, 6 from the wave-1 code review BEFORE the page migrations multiply duplication of raw oklch values.
**Files:**
- Modify: `services/web-ui/src/css/components/tokens.css`
- Modify: `services/web-ui/src/css/components/button.css` (use new tokens)
- Modify: `services/web-ui/src/css/components/card-operational.css` (use new tokens)
- Modify: `services/web-ui/src/css/components/sidebar.css`, `topbar.css`, `slide-panel.css`, `card-asset.css`, `form-controls.css`, `field-group.css`, `list-row.css`, `toast.css` (use shared --ease / --dur tokens)
- [ ] **Step 1: Extend tokens.css with the missing tokens**
Append to `services/web-ui/src/css/components/tokens.css` inside `:root`:
```css
/* Hover-darker variants of accent + signals — promoted from
* inline oklch() arithmetic that was duplicated across button.css
* and card-operational.css */
--accent-hover: oklch(52% 0.20 266);
--accent-bright: oklch(70% 0.18 266);
--signal-bad-hover: oklch(68% 0.22 25);
--signal-good-hover: oklch(74% 0.18 148);
--signal-warn-hover: oklch(84% 0.16 90);
/* Pure-black-ish tinted toward brand hue for thumbnails & overlays.
* Numerically still ~black but the hue channel is set so future
* derivations stay on-brand. */
--thumb-black: oklch(0% 0 266);
--overlay: oklch(8% 0.010 266 / 0.65);
--shadow: oklch(0% 0 266 / 0.5);
/* Motion + ease tokens — promoted from raw cubic-bezier strings
* that appeared in 8 of 12 primitive files */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--dur-fast: 120ms;
--dur-normal: 180ms;
--dur-slide: 240ms;
/* Z layers — promoted from sidebar/topbar where 30 was hard-coded */
--z-topbar: 30;
```
- [ ] **Step 2: Find-and-replace raw oklch hover values across primitives**
For each of these files, replace the literal oklch string with the new token. Use `sed -i` for the substitutions, but verify each file afterward.
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
# button.css
sed -i 's|background: oklch(52% 0.20 266);|background: var(--accent-hover);|' button.css
sed -i 's|background: oklch(68% 0.22 25);|background: var(--signal-bad-hover);|' button.css
# card-operational.css — gradient stop in signal-strip-fill
sed -i 's|oklch(70% 0.18 266)|var(--accent-bright)|' card-operational.css
# card-asset.css — pure-black thumb background
sed -i 's|background: oklch(0% 0 0);|background: var(--thumb-black);|' card-asset.css
# slide-panel.css — overlay color
sed -i 's|oklch(8% 0.010 266 / 0.65)|var(--overlay)|' slide-panel.css
# toast.css — shadow
sed -i 's|oklch(0% 0 0 / 0.7)|var(--shadow)|' toast.css
```
- [ ] **Step 3: Replace raw cubic-bezier strings with --ease + --dur tokens**
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
# Replace exact "120ms cubic-bezier(0.25, 1, 0.5, 1)" with the tokens
for f in sidebar.css topbar.css slide-panel.css card-asset.css card-operational.css form-controls.css field-group.css list-row.css button.css; do
sed -i 's|120ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-fast) var(--ease-out-quart)|g' "$f"
done
# slide-panel slide-in (240ms ease-out-expo)
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' slide-panel.css
# Tab indicator
sed -i 's|240ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-slide) var(--ease-out-quart)|' slide-panel.css
sed -i 's|200ms cubic-bezier(0\.25, 1, 0\.5, 1)|200ms var(--ease-out-quart)|g' form-controls.css
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' card-operational.css
```
- [ ] **Step 4: Replace hard-coded z-index 30 with --z-topbar**
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
sed -i 's|z-index: 30;|z-index: var(--z-topbar);|' topbar.css
```
- [ ] **Step 5: Rebuild + verify primitives still ship correctly**
```bash
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
docker exec wild-dragon-web-ui-1 grep -c '.wd-' /usr/share/nginx/html/dist/app.css
# Expect: same large number (~116+) — no rules dropped
docker exec wild-dragon-web-ui-1 grep -c '\-\-accent-hover\|\-\-ease-out-quart\|\-\-z-topbar' /usr/share/nginx/html/dist/app.css
# Expect: at least 3 hits (tokens now defined + referenced)
```
- [ ] **Step 6: Visual regression check on smoke page**
```bash
curl -sk -o /dev/null -w 'smoke=%{http_code}/%{size_download}\n' http://localhost:47434/_primitives-smoke.html
# Expect: HTTP 200, ~12 KB (unchanged from wave 1)
```
Manually load the smoke page in a browser; everything should look identical to wave 1. If anything changed visually, the sed substitutions introduced a regression.
- [ ] **Step 7: Commit + push**
```bash
cd /opt/wild-dragon
HOME=/root git add services/web-ui/src/css/components/
HOME=/root git diff --cached --stat
HOME=/root git -c user.email=zgaetano@wilddragon.net -c user.name='Zac Gaetano' commit -m 'web-ui: token cleanups from wave-1 code review
- Promote --accent-hover, --signal-bad-hover, --signal-good-hover,
--signal-warn-hover, --accent-bright tokens (were duplicated raw
oklch arithmetic in button.css / card-operational.css)
- Promote --thumb-black, --overlay, --shadow tokens (tinted toward
brand hue 266 so future derivations stay on-brand)
- Promote --ease-out-quart, --ease-out-expo, --dur-fast/normal/slide
tokens (cubic-bezier strings appeared in 8 of 12 primitive files)
- Promote --z-topbar (was hard-coded 30 in topbar.css while every
other layer was tokenized)
- Replace all usages across the 12 primitive files via sed.
Bundle byte count unchanged (~138 KB); visual regression on smoke
page = zero. Code-review concerns from wave 1 now resolved before
wave 2 page migrations begin.'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 1: Migrate login.html
Smallest page. No sidebar, no topbar — just a centered card with email/password. Migrating first because if it breaks nothing else does.
**Files:**
- Modify: `services/web-ui/public/login.html`
- [ ] **Step 1: Read the current page to see what's there**
```bash
cd /opt/wild-dragon/services/web-ui/public
cat login.html | head -80
```
Note: form ids, input names, any inline JS handlers. Preserve all of them.
- [ ] **Step 2: Write the new login.html**
The new structure:
- `<link rel="stylesheet" href="/dist/app.css">` instead of the old `common.css`
- Centered `<main>` with a single `.wd-card-op`-shaped panel (operational card primitive, sized small)
- Inside: brand logo + "Z-AMPP" wordmark at top, then `<form>` with two `.wd-form-group` (email + password), then `.wd-btn.wd-btn--primary.wd-btn--md` submit
- Keep every existing `id`, `name`, `type`, and `<script>` tag from the old file
- If there's an "error message" div, replace its class with `.wd-toast.wd-toast--error` (inline, not floating)
Replace the entire `<head>` and `<body>` with the new shell. JS at the bottom stays as-is.
- [ ] **Step 3: Deploy on zampp1 (no Docker rebuild needed — HTML is static)**
```bash
# Actually nginx serves from the image's filesystem, not the host's
# /opt/wild-dragon/services/web-ui/public/. So we DO need a rebuild.
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'login=%{http_code}/%{size_download}\n' http://localhost:47434/login.html
# Confirm new bundle is referenced
curl -sk http://localhost:47434/login.html | grep -E 'dist/app.css|common.css'
# Expect: dist/app.css present, common.css absent
```
- [ ] **Step 4: Visual + functional check**
Open `http://172.18.91.216:47434/login.html` in a browser. Verify:
- Page renders with new brand styling
- Email + password fields look like the wd-input primitive
- Submit button looks like wd-btn--primary
- Logging in still actually works (POST to /api/v1/auth/login)
- [ ] **Step 5: Commit + push**
```bash
HOME=/root git add services/web-ui/public/login.html
HOME=/root git commit -m 'web-ui(wave 2): migrate login.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 2: Migrate home.html
Has sidebar + topbar + dashboard stat tiles. The first page that exercises the full shell.
**Files:**
- Modify: `services/web-ui/public/home.html`
- [ ] **Step 1: Read the current page**
```bash
cd /opt/wild-dragon/services/web-ui/public
wc -l home.html
head -80 home.html
```
Identify: page title, what's in the topbar right side, the stat tile structure, any chart libraries, and the bottom `<script>` blocks. Preserve all script references and JS state.
- [ ] **Step 2: Migrate the markup**
The migration recipe for every shell page:
1. **`<head>`**: replace `<link rel=stylesheet href=css/common.css>` with `<link rel=stylesheet href=/dist/app.css>`. Keep favicon, viewport meta.
2. **`<body>` root**: wrap in `<div class="wd-shell">` (style inline: `display:flex;min-height:100vh`).
3. **Sidebar**: copy verbatim from the smoke page's `<nav class="wd-sidebar">` block. Mark the active nav item with `is-active` on Home.
4. **Right column**: `<div style="flex:1;display:flex;flex-direction:column;">`
5. **Topbar**: `<header class="wd-topbar">` with breadcrumb in `.wd-topbar-left` containing just "Home", any existing right-side button as `.wd-btn.wd-btn--primary.wd-btn--sm`.
6. **Main content**: `<main style="padding:20px 20px 32px;">`
7. **Stat tiles**: replace with `.wd-card-op-grid` containing `.wd-card-op` (small, content-only — no footer needed if there's no action).
8. **Auth-guard script** at the bottom — stays exactly as-is.
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'home=%{http_code}/%{size_download}\n' http://localhost:47434/home.html
curl -sk http://localhost:47434/home.html | grep -c 'wd-sidebar\|wd-topbar\|wd-card'
# Expect: 8+ matches
```
Load `http://172.18.91.216:47434/home.html` in a browser. Sidebar should be the new one, breadcrumb shows "Home", stat tiles render as operational cards.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/home.html
HOME=/root git commit -m 'web-ui(wave 2): migrate home.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 3: Migrate settings.html
System settings form. Lots of form-groups, possibly tabbed.
**Files:**
- Modify: `services/web-ui/public/settings.html`
- [ ] **Step 1: Read current page + identify all form sections**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E '<h[12]|form-group|form-section-label' settings.html | head -30
```
- [ ] **Step 2: Migrate using the standard recipe**
Same recipe as task 2, except for the form content:
- Replace each form section with a `.wd-field-group` (header + body, no tabs unless the section is genuinely tabbed)
- Replace every `<input>` with `class="wd-input"`, every `<select>` with `class="wd-select"`, every `<label>` with `class="wd-label"`
- Replace every `<button>` with `class="wd-btn wd-btn--primary wd-btn--md"` (or `--secondary` / `--ghost` / `--danger` as appropriate)
- Wrap rows of inputs in `.wd-form-row`
- Preserve every `id`, `name`, `type`, and JS handler
Set `.wd-nav-item.is-active` on Settings.
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'settings=%{http_code}/%{size_download}\n' http://localhost:47434/settings.html
```
Load in browser. Verify the form actually saves (test by changing one value and clicking save).
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/settings.html
HOME=/root git commit -m 'web-ui(wave 2): migrate settings.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 4: Migrate tokens.html
Lists API tokens, allows creation of new ones with a slide-panel. First page that exercises the slide-panel primitive in the migrated context.
**Files:**
- Modify: `services/web-ui/public/tokens.html`
- [ ] **Step 1: Read + identify the slide-panel structure**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E 'slide-panel|slide-overlay|wd-list-row' tokens.html | head -20
```
- [ ] **Step 2: Migrate**
- Standard shell recipe (sidebar with `is-active` on Tokens, topbar with "Tokens" breadcrumb and "New token" primary button)
- Token list → `.wd-list` containing `.wd-list-row` for each token: name (cell--name), created date (cell--meta), badge for scope, action buttons in cell--actions
- Create-token form moves into `.wd-slide-panel` (with overlay, header, body, footer pattern exactly as in the smoke page's field-group)
- Preserve every JS handler — especially the copy-to-clipboard one for the newly-generated token
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'tokens=%{http_code}/%{size_download}\n' http://localhost:47434/tokens.html
```
Load + verify: clicking "New token" opens the slide-panel (codec-clipping bug fix from wave 1 applies — body should scroll if it overflows), creating a token shows the copy-once display.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/tokens.html
HOME=/root git commit -m 'web-ui(wave 2): migrate tokens.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 5: Migrate users.html
User management. List + edit slide-panel. Pattern matches tokens.html closely.
**Files:**
- Modify: `services/web-ui/public/users.html`
- [ ] **Step 1: Read + identify**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E '<h[12]|wd-list|slide-panel' users.html | head
```
- [ ] **Step 2: Migrate using the tokens.html recipe**
Identical pattern to task 4: shell + list + slide-panel for create/edit. Mark Users active. Preserve every JS handler (role dropdown, password reset, etc.).
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'users=%{http_code}/%{size_download}\n' http://localhost:47434/users.html
```
Verify: list renders, edit panel opens, save works.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/users.html
HOME=/root git commit -m 'web-ui(wave 2): migrate users.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 6: Migrate containers.html
Docker container list. List rows with status badges + action buttons (logs / restart). No slide-panel (logs typically opens in a separate tab or inline).
**Files:**
- Modify: `services/web-ui/public/containers.html`
- [ ] **Step 1: Read + identify**
```bash
cd /opt/wild-dragon/services/web-ui/public
head -80 containers.html
```
- [ ] **Step 2: Migrate**
- Standard shell recipe (Containers active)
- Container list → `.wd-list` with `.wd-list-row` per container:
- cell--name: container name
- cell with image: cell--meta
- cell with status: `.wd-badge.wd-badge--good` (Up) / `.wd-badge--bad` (Down) / `.wd-badge--warn` (Restarting)
- cell--actions: ghost buttons for Logs / Restart / Stop
- Auto-refresh polling JS stays unchanged
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'containers=%{http_code}/%{size_download}\n' http://localhost:47434/containers.html
```
Load + verify: all containers visible, status badges color-coded, Logs button still opens logs.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/containers.html
HOME=/root git commit -m 'web-ui(wave 2): migrate containers.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 7: Wave-2 user QA gate
- [ ] **Step 1: Verify all 6 migrated pages serve correctly**
```bash
for p in login home settings tokens users containers; do
printf ' %s.html: HTTP=%s\n' "$p" "$(curl -sk -o /dev/null -w '%{http_code}' http://localhost:47434/$p.html)"
done
```
Expected: all 200.
- [ ] **Step 2: Verify all 6 pages reference /dist/app.css and NOT common.css**
```bash
for p in login home settings tokens users containers; do
CNT_NEW=$(curl -sk http://localhost:47434/$p.html | grep -c dist/app.css)
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
printf ' %s.html: new=%s old=%s\n' "$p" "$CNT_NEW" "$CNT_OLD"
done
```
Expected: new=1 old=0 for every page.
- [ ] **Step 3: Verify wave-3 / wave-4 pages are STILL on the old CSS (no accidental change)**
```bash
for p in index projects upload jobs api-tokens recorders cluster capture editor; do
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
printf ' %s.html: still-on-old=%s\n' "$p" "$CNT_OLD"
done
```
Expected: still-on-old=1 for every page (none of them migrated yet).
- [ ] **Step 4: User visual QA**
Stop. Ask user to load each of the 6 migrated pages and confirm the new look is correct, navigation still works, forms still save, lists still poll. If anything looks wrong, fix it before wave 3 starts.
---
## Self-review notes
- **Spec coverage**: Every page in the wave-2 list from the design spec is in the plan. Token cleanups from wave-1 review are folded in as task 0.
- **Placeholders**: none. Every step has the actual command / file change.
- **Type consistency**: every migrated page uses `.wd-shell` / `.wd-sidebar` / `.wd-topbar` / `.wd-nav-item.is-active` / `.wd-card-op` / `.wd-list` / `.wd-list-row` / `.wd-btn` / `.wd-input` / `.wd-select` / `.wd-label` / `.wd-form-row` / `.wd-field-group` — exact class names from the wave-1 bundle.
- **Open risk**: each page migration is a manual markup rewrite. The implementer subagent needs to actually read each existing page before rewriting, not work from the description alone, because each page has page-specific JS handlers that must be preserved verbatim.

View file

@ -0,0 +1,73 @@
# NLE Editor: React SPA Polish — Phases 13 Implementation Plan
> **Date:** 2026-05-24
> **Status:** Phase 1 IN PROGRESS
> **Progress:** Tasks 1.11.6 code-complete, pending test/deploy
---
## Phase 1 — Core Editor (IN PROGRESS)
### Task 1.1: Sequence API helpers in data.jsx ✅
- Added `getSequences`, `createSequence`, `getSequence`, `updateSequence`, `deleteSequence`, `syncSequenceClips`, `exportSequenceEDL` to `data.jsx`
- All exported on `window.ZAMPP_API`
### Task 1.2: Timecode.js wired into SPA ✅
- Added `<script src="js/timecode.js">` and `<script src="js/timeline.js">` to `index.html` before `screens-editor.jsx`
- `window.TC` available globally for 59.94 DF timecode math
### Task 1.3: TimelinePanel React component ✅
- `tlRef` container div in editor layout
- `useEffect` mounts `window.Timeline.init()` on first render
- `useEffect` pushes scale changes via `window.Timeline.setScale()`
- `onClipsChanged` / `onPlayheadMoved` callbacks connect timeline engine to React state
### Task 1.4: screens-editor.jsx rewrite ✅ (455 lines, was 162)
Full rewrite with:
- **App state**: `projectId`, `sequences`, `currentSeq`, `assets`, `sourceAsset`, `srcIn`/`srcOut`, `playheadFrames`, `history` (undo stack), `scale`, `tool`, `saveStatus`, `isDirty`
- **Source monitor**: `<video>` + `apiFetch('/assets/:id/stream')` + Mark In/Out + Insert button
- **Program monitor**: Virtual clip-by-clip playback — loads V1 clips sorted by `timeline_in_frames`, advances on `timeUpdate`/`ended` events
- **Media panel**: Asset list from `ZAMPP_DATA.ASSETS`, filter by bin, `AssetThumb` thumbnails, double-click loads source
- **Sequence management**: Picker `<select>`, "New sequence" modal, `openSequence()` loads via API
- **Auto-save**: `markDirty()` → debounce 2s → `syncSequenceClips()` → status updates
- **Undo/redo**: 50-step history stack, Ctrl+Z / Ctrl+Shift+Z
- **EDL export**: Button triggers `window.ZAMPP_API.exportSequenceEDL()`
- **Tool toolbar**: V/C/H buttons synced with `Timeline.setTool()`
- **Zoom slider**: Range input driving `window.Timeline.setScale()`
- **Keyboard handler**: I/O, V/C/H, Ctrl+Z/Shift+Z, Ctrl+S
### Task 1.5: "In Development" overlay removed ✅
- Deleted the `position: absolute; inset: 0; backdropFilter: blur(6px)` overlay div (was lines 98-117)
- Removed `FauxFrame` component reference
- All buttons are now functional
### Task 1.6: Editor nav badge removed ✅
- `shell.jsx`: `{ id: "editor", label: "Editor", icon: "editor" }` — no more `badge: { kind: "dev", text: "DEV" }`
### NEXT: Task 1.7 — Test & Deploy
1. `docker compose up -d --build web-ui`
2. Navigate to Editor route in browser
3. Verify: source monitor loads video, timeline renders with 4 track rows, Insert places clip, auto-save fires, EDL export downloads
---
## Phase 2 — UX Polish & Growing File (PENDING)
- [ ] 2.1 Multi-track refinements (ripple delete, snap, locking, overlap prevention)
- [ ] 2.2 Zoom slider + adaptive ruler
- [ ] 2.3 JKL transport + frame stepping
- [ ] 2.4 Waveform display on audio tracks
- [ ] 2.5 Inspector panel wiring
- [ ] 2.6 Style migration to Tailwind primitives
- [ ] 2.7 HLS live preview during capture
## Phase 3 — Export, Conform & Features (PENDING)
- [ ] 3.1 FCP XML export + conform queue
- [ ] 3.2 Hi-Res Auto-Relink
- [ ] 3.3 Timecoded Comments
- [ ] 3.4 Player Rebuild (P1)
- [ ] 3.5 Subclips (P2)
- [ ] 3.6 Multi-select & Bulk Ops (P3)
- [ ] 3.7 Smart Bins (P6)
- [ ] 3.8 Metadata Templates (P7)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,198 @@
# UI Shell Rework — Design Spec
> Status: **design approved 2026-05-21**, awaiting user review of the spec before the implementation plan is written.
## Context
The Wild Dragon MAM web-ui currently ships 15 static HTML pages served by nginx, sharing a single hand-written `common.css`. The token system is already strong (oklch palette, brand hue 266, 5-step depth surfaces, semantic signal tokens, 4pt spacing). What does not work is the gap between tokens and execution: cards across pages have undifferentiated spacing, generic chrome, weak hierarchy, and identical shape regardless of role. The user feedback that prompted this work was "the UI still looks AI-designed."
The rework adopts flyon-ui (Tailwind plugin) as the component primitive layer, ports the oklch palette into a custom flyon-ui theme so brand identity is preserved, and rebuilds every page that uses the standard shell against the new primitives. The personality target is *quiet pro tool* — closer to Sony Media Cloud and DaVinci Resolve than to a consumer SaaS dashboard.
## Goals & non-goals
**Goals**
- A coherent visual system across every shell page (15 pages minus 3 excluded).
- Higher information density at every screen — closer to Sony / DaVinci than to today's spacing.
- Four distinct card families so the eye reads role from shape.
- A theme port that preserves brand hue 266 and the existing oklch palette under flyon-ui.
- Accessibility floor: WCAG AA contrast, full keyboard nav, reduced-motion honored.
**Non-goals**
- Mobile UX. Phones get an explicit "desktop only" splash. Tablet gets a collapsed icon-rail sidebar but no further accommodation.
- Replacing the brand color, the font stack, or the dark theme.
- Animations beyond functional state transitions (no celebrations, no page-fade, no sound design).
- Adding new pages or features. This is purely visual / structural.
- Rebuilding `edit.html`, `editor.html`, or `player.html` (deliberately excluded — see Rollout).
## Personality, scene & color strategy
- **Register:** product (app UI, design serves the product), not brand.
- **Theme scene sentence:** "MAM operator at a 27-inch monitor in a dim control room, scanning a grid of 100+ video assets at 2am while a live recording timer runs." Forces dark, low-chroma, tabular-numeric trust signals.
- **Color strategy:** restrained. Tinted neutrals (chroma 0.0100.015, hue 266) plus a single amber accent used in ≤10% of surfaces — for active states, recording indicators, primary CTAs, focus rings.
- **Anti-patterns explicitly banned:** glassmorphism as default, gradient text, side-stripe borders (>1px on the side of cards/rows/callouts), hero-metric SaaS template, identical card grids across roles, em dashes in copy. Cliché category-reflex check passes: this design lands as DAW / NLE / NLE-adjacent operational tool, not "dark blue observability dashboard."
## Architecture
### 1. Build system & theme port
The `services/web-ui` Docker image gains a Node build stage. During `docker build`, `tailwindcss --minify` runs once, scans `public/**/*.html` for class usage, and emits `public/dist/app.[hash].css`. The runtime stage stays nginx-static — no runtime Node, longer startup, or extra moving parts.
A `tailwind.config.js` at `services/web-ui/` defines a custom flyon-ui theme that maps the existing oklch palette into flyon-ui's color slots. Brand hue 266 is preserved; the 5-step depth surfaces become Tailwind's `bg-deep` / `bg-base` / `bg-panel` / `bg-surface` / `bg-raised` / `bg-hover` utility chain. Signal tokens (`signal-good` / `signal-bad` / `signal-warn` / `signal-idle`) map directly. Spacing scale uses Tailwind's default 4pt scale, which already matches the existing `--sp-*` tokens — utilities like `p-3` and `gap-4` replace `var(--sp-3)` and `gap: var(--sp-4)`.
Fonts (Inter + JetBrains Mono) move from Google CDN to self-hosted woff2 in `public/fonts/`. The four "legacy alias" entries in the current `:root` (`--status-amber`, `--status-amber-bg`, etc.) get cleaned up during the port.
The custom theme also disables flyon-ui utility classes for the banned patterns: no `glass-*`, no `gradient-text`, no card-shadow defaults.
### 2. Sidebar
- **Dimensions:** 200px wide (down from 220px). Items 28px tall (down from ~36px), 8px horizontal padding, 4px vertical.
- **Type:** Inter 13px / 500 for items. Section labels 10px / 600 / 0.14em tracked / uppercase / `text-tertiary`.
- **Header:** 18px dragon logo + Inter 13px / 600 / -0.01em "Z-AMPP" wordmark. Total header height 48px to align with topbar.
- **Active state:** `bg-surface` background + `text-primary` text + 4px leading accent dot (8px tall, vertically centered). No side-stripe border (banned). No accent background fill.
- **Hover:** `bg-hover` fade-in over 120ms ease-out. No transform.
- **IN DEV badge** (injected by `auth-guard.js`): retained, restyled as 9px / 700 / 0.12em tracked amber pill.
- **Footer user widget:** 28px round avatar, name + role stacked, logout button reveals on row hover.
### 3. Topbar
- **Dimensions:** 48px tall. Padding 16px left / 12px right. Bottom border `border-faint`.
- **Left:** breadcrumb pattern, not flat title. Inter 13px / 500 / `text-secondary` for ancestor crumbs, 13px / 600 / `text-primary` for current. 10px chevron separator in `text-tertiary` with 8px gutters.
- **Center:** page-scoped search input on pages that have searchable content (Library, Recorders, Projects, Jobs, Cluster). 360px wide, 28px tall, leading magnifier, monospace placeholder.
- **Right:** primary CTA rightmost (28px button with 12px leading icon), 1px vertical divider, then 28px-square icon-only ghost buttons for secondary actions (filter, sort, view-toggle).
- **Sticky:** `position: sticky; top: 0; z-index: 30` inside `.main`. Sidebar does not scroll separately; topbar stays visible while content scrolls.
### 4. Card families
Four distinct card shapes, each with one clear job. Same-shape repetition is banned.
**Asset card** — Library, Projects asset grid, Recorders recording cards
- 16:9 thumbnail, full-bleed. Duration chip bottom-right (JetBrains Mono 10px, `bg-deep` 70% opacity). Comment-count chip bottom-left (when >0). Selection checkbox top-left (only on hover or when any are selected). Version badge top-right when applicable.
- Metadata: filename (Inter 13px / 500), then `{author} · {date}` row (11px / `text-tertiary`, mono numerics).
- Role pill at bottom: full-width, light tint of role color, 10px / 600 / 0.08em tracked / uppercase. Dotted-border placeholder when unset.
- 1px `border-faint`, 6px radius, `bg-panel`. Hover: thumbnail +4% brightness, border lifts to `border`. No scale, no shadow.
**Operational card** — Recorders cards, Cluster nodes, Jobs queue
- Header / content / footer rows. Wider than tall (min 8:3 ratio). 14px padding, 10px row gap.
- Header: 16px name + status pill (semantic signal tokens).
- Content: role-specific. Recorder gets live preview + signal strip + timer; cluster node gets CPU/mem mini-bars; job gets progress strip.
- Footer: 1px top hairline, metadata-left + actions-right.
- Border 1px `border-faint`, becomes 1px `accent-border` when active (recording / online / running). Color change only — no glow, no shadow, no animation.
**Inline list row** — Containers, Users, Tokens, API tokens
- Not a card. Table row with extra breathing room. 44px tall, hairline divider (`border-faint`).
- Hover: `bg-hover` row tint. Selected: `bg-surface` tint + 4px leading accent dot (same indicator language as sidebar active state).
**Empty state**
- Centered 28px line icon (`text-tertiary`), 14px / 600 title, 13px body, primary action button. No card chrome — the empty state IS the page.
- Declarative copy. No exclamation points, no emojis.
### 5. Grids
- Asset grid: `repeat(auto-fill, minmax(220px, 1fr))` with 12px gap.
- Operational grid: `repeat(auto-fill, minmax(380px, 1fr))` with 14px gap.
- Page content padding: 20px sides, 16px top, 32px bottom.
### 6. Forms, slide-panels & inputs
**Slide-panel structure (the codec-clipping bug fix codified as a primitive):**
- 460px wide. `height: 100vh; display: flex; flex-direction: column; overflow: hidden`. Header `flex-shrink: 0`. Body `flex: 1; min-height: 0; overflow-y: auto`. Footer `flex-shrink: 0; bg-deep`.
- Header 52px, 18px padding, title + close button. Bottom border `border-faint`.
- Body 18px padding, `display: flex; flex-direction: column; gap: 16px`.
**Form primitives:**
- Label: 11px / 600 / 0.08em tracked / uppercase / `text-tertiary`.
- Input / select / textarea: 32px tall, 10px horizontal padding, 13px text, 1px `border` outline, 4px radius. Focus: `accent-border` outline + 2px `accent-subtle` ring.
- Form hint: 11px / `text-tertiary` / 1.5 line-height. JetBrains Mono code spans.
- Form row: `grid-template-columns: 1fr 1fr; gap: 14px`.
**Field-group (tabbed sections, generalized from the codec-block pattern):**
- Titled header strip (36px, `bg-surface`) + tab row (32px, `bg-deep`) + tab panels (14px padding).
- Active tab: 2px `accent` bottom border, text shifts from `text-tertiary` to `accent`. Tab switches are instant — no animation.
**Buttons:**
- Sizes: `sm` 28px / `md` 32px / `lg` 36px.
- Variants: `primary` (accent bg), `secondary` (`bg-surface` + border), `ghost` (transparent + secondary text, `bg-hover` on hover), `danger` (status-red bg).
- Leading icon: 12px svg, 6px gap. Disabled: 40% opacity. Active press: 60ms `opacity: 0.85`. No gradient, no shadow, no scale.
**Toggle:** 34×18, track `bg-hover``accent`, 200ms ease-out on dot only.
**Date / time inputs:** native `<input type="date">` styled to match the input primitive. No third-party picker library.
### 7. States, motion & feedback
**Loading:** skeleton blocks matched to content shape. Asset grid → 12 placeholder cards with 1.8s gradient shimmer (not opacity pulse). In-card actions get inline 12px ring spinner. In-button: label replaced by spinner, width preserved.
**Empty states:** fade in 240ms on first load; instant when user-initiated.
**Errors:**
- *Toast* (bottom-right, 320px): `bg-panel` + 1px `status-red` border + 4px `status-red` top strip. Auto-dismiss 4s success / 8s warning / manual error. Stack up to 3.
- *Inline*: red 11px text below offending field. No icon, no shake.
- *Page-level*: full-page card with icon + plain-English title + Retry + Get-help buttons. Never blocks the sidebar.
**Success:** "Recorder saved" toast. Affected card briefly tints (200ms `accent-subtle` background, fades back over 1.2s). One-time. No checkmark celebrations.
**Live / realtime (recording-in-progress):**
- Signal strip shimmer 1.8s ease-in-out (down from 2.4s linear).
- "LIVE" preview-stamp dot stutter pattern: bright 0.9s / dim 0.3s / bright 0.9s (broadcast tally light).
- Timer: 600 weight, `status-red` while recording. Already correct.
**Hover / focus:**
- All transitions 120ms ease-out on `border-color` and `background-color` only. Never on `width`, `height`, `transform`.
- Focus ring: 2px `accent-subtle` outline, 1px offset, `:focus-visible` only.
**Page transitions:** none. Click nav → page renders. Slide-panel keeps 240ms slide-in from right.
**Notifications:** no bell, no global status banner. Failures surface inline on affected pages.
### 8. Accessibility & responsive
**A11y floor:**
- WCAG AA contrast on every text/background pair. `text-tertiary` lightness bumped from 52% to 56% to clear AA cleanly.
- `:focus-visible` ring on every interactive element.
- Full keyboard nav. Slide-panel traps focus while open; Esc closes overlays.
- Every icon-only button gets `aria-label`. Toasts use `role="status" aria-live="polite"`.
- `prefers-reduced-motion: reduce` honored: kills shimmer / pulse / slide-in, state changes become instant.
**Responsive:**
- Desktop-first tool. *Fully supported* minimum viewport: 1280×800. Anything narrower is best-effort only.
- ≥1600px: standard layout, content max-width 1440px, centered.
- 12801599px: standard layout, no max-width cap.
- 7681279px (tablet, best-effort): sidebar collapses to a 56px icon-rail, breadcrumb truncates to last crumb. Functional but not polished.
- <768px (phone): explicit "Z-AMPP is desktop-only" splash. No fake mobile experience.
**Browser support:** Chromium latest 2 versions + Safari latest 2. Firefox best-effort. No IE / legacy Edge.
## Rollout
Four waves, ordered by blast radius. Each wave is its own commit / deploy / verify cycle on zampp1.
**Wave 1 — Foundation (zero user-visible change).** Build pipeline, Tailwind + flyon-ui config, theme port, primitive CSS components, shell markup migration. New primitives exist but no page uses them yet. *Validates the build system works end-to-end.*
**Wave 2 — Shell + low-risk pages.** `login.html`, `home.html`, `settings.html`, `tokens.html`, `users.html`, `containers.html`. New shell, new card / list patterns, new forms. Low risk: no live data, simple flows.
**Wave 3 — Content-heavy pages.** `index.html` (Library), `projects.html`, `upload.html`, `jobs.html`, `api-tokens.html`. Asset grid, project tree, upload queue, job queue.
**Wave 4 — Operational pages.** `recorders.html`, `cluster.html`, `capture.html`. Live data, HLS preview, signal polling, BMD picker, codec slide-panel. Done last so the primitives are battle-tested before they meet the most fragile pages.
**Excluded from rework:** `edit.html`, `editor.html` (in-development construction screen is the right treatment), `player.html` (standalone embed, no shell).
**Definition of done per page:** new shell + new primitives + AA contrast verified + keyboard-nav check + responsive at 1280/1440/1920 widths + no JS regressions (live-recording flow on recorders.html is the canary).
## Risks & mitigations
| Risk | Mitigation |
|---|---|
| Tailwind build pipeline introduction breaks docker build | Wave 1 ships the build *without* migrating any page. If build fails, we revert without losing functionality. |
| Theme port loses brand hue 266 character | Custom flyon-ui theme explicitly maps existing oklch tokens; QA on wave 1 includes side-by-side color comparison vs. current. |
| Recorders rewrite (just stabilized) gets re-touched in wave 4 | Wave 4 is last on purpose — primitives are battle-tested by then. The codec-tab pattern from the recent recorders rewrite is generalized into the `.field-group` primitive in wave 1, so wave 4's recorders rewrite is mostly markup migration, not pattern reinvention. |
| Density target is too aggressive — 13px text / 28px rows feel cramped on smaller monitors | Wave 1 ships density + AA verified at 1280×800. If feedback says cramped, bump base text to 14px in a single token change. |
| Page-level skeleton loaders are extra implementation work | Acceptable cost. Spinners-only would feel cheaper than the rest of the design. |
| Native `<input type="date">` looks inconsistent across Chromium / Safari | Acceptable. Inconsistency is small; bundle weight savings of avoiding a date-picker library is real. |
## Implementation plan handoff
Once this spec is approved by the user, the next step is invoking the `superpowers:writing-plans` skill to produce a wave-by-wave implementation plan with concrete commit / deploy steps. The plan will live at `docs/superpowers/plans/2026-05-21-ui-shell-rework-plan.md` and reference this spec.
## Open questions
None. All seven design sections were approved by the user (Zac) during brainstorming on 2026-05-21. No placeholder values remain in this spec.

View file

@ -0,0 +1,272 @@
# YouTube Importer — Design Spec
> Status: **design approved 2026-05-23**, awaiting user review of the spec before the implementation plan is written.
## Context
The Ingest group in Dragonflight today covers file Upload, Recorders (SRT/RTMP/SDI), Capture (DeckLink), Monitors, and Schedule. There is no path to bring in media that already lives on the public web. The frequent ask is: "I want to grab a YouTube link and have it become an asset in my project, with the same proxy/thumbnail pipeline as anything else." This spec adds a YouTube importer that mirrors the existing upload flow: paste a URL, pick a project, click Import, and the asset shows up in the Library once the worker is done.
The importer rides on the existing job pipeline. After the download lands in S3, the asset re-enters the same proxy → thumbnail → ready path as a regular upload, so there is no parallel "imported asset" lifecycle to maintain.
## Goals & non-goals
**Goals**
- Paste a public YouTube URL, end up with a `ready` asset in the chosen project.
- Reuse the existing `assets` table, S3 layout, BullMQ pipeline, and Jobs screen — no parallel state machine.
- Progress visible from both the import screen (queue rows) and the Jobs screen.
- Clear, actionable errors for the obvious failure modes (private, age-gated, removed, geo-blocked, network).
**Non-goals**
- Playlists, channels, or batch-paste of multiple URLs. Single URL per submission. (Easy to add later.)
- Cookies / login. Private, members-only, and age-gated videos are out of scope v1.
- Quality picker. Always grabs best MP4 (with M4A audio merge fallback).
- Non-YouTube sources (Vimeo, Twitch VODs, Dropbox links, etc.). The route is `/imports/youtube` precisely to leave room for siblings later.
- Auto-update of yt-dlp inside the running container. Updates land via image rebuild.
- Copyright enforcement. We surface a one-line "only import videos you have rights to use" note and stop there.
## Architecture
The importer threads through four existing layers:
```
[web-ui] YouTube screen ──POST /imports/youtube──▶ [mam-api]
assets row (status='ingesting')
jobs row (type='youtube_import')
BullMQ "import" queue
[worker]
yt-dlp download → S3 originals/
ffprobe metadata → assets row
status='processing'
BullMQ "proxy" queue ◀── existing path
proxy → thumbnail → ready
```
Once the worker hands off to the `proxy` queue, the asset is indistinguishable from one that came through Upload — same proxy worker, same thumbnail worker, same Library list.
## 1. UX
### Nav
A 6th child is added to the **Ingest** group in `shell.jsx`, between Upload and Recorders:
```js
{ id: "youtube", label: "YouTube", icon: "download" },
```
The `download` glyph already exists in `icons.jsx`. The matching `ingestChildren` array in `shell.jsx` and the crumbs map in `app.jsx` both gain `"youtube"`.
### Screen
A new `YouTubeImport` component lives in `screens-ingest.jsx` and is exported on `window` alongside `Upload`, `Recorders`, etc. It is registered as a route in `app.jsx`.
Layout — visually a sibling of the Upload screen:
- **Header**: title "YouTube", subtitle "Paste a link — we download and import the best available MP4."
- **Project selector**: same `select` element as Upload's, pre-selected to the first project.
- **URL input**: a single-line `field-input` with placeholder "Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)…" and an inline **Import** button. Enter submits. The button is disabled until a URL pattern matches.
- **Subtitle line under the input**: "Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported."
- **Queue panel**: identical structure to Upload's queue — one row per submitted URL, showing:
- Source icon (use `link` glyph) and the URL (truncated middle, full URL in `title` tooltip).
- Title once known (filled in by a poll on the asset row).
- Progress bar tied to job `progress` (0100). The worker drives this between 5 and 60 % for download and 60 to 100 % for upload + DB writes.
- Status pill: queued → downloading → processing → done / failed.
- Error text if the job fails (red, one line).
- A "Clear done" button at the top of the queue.
The queue persists for the session in component state only — no separate UI table. Jobs screen remains the canonical history.
### URL validation (client-side, before POST)
Accept (case-insensitive) any of these patterns:
- `https?://(www\.|m\.)?youtube\.com/watch\?[^ ]*v=[A-Za-z0-9_-]{11}`
- `https?://youtu\.be/[A-Za-z0-9_-]{11}`
- `https?://(www\.)?youtube\.com/shorts/[A-Za-z0-9_-]{11}`
Anything else is rejected inline ("That doesn't look like a YouTube URL") without an API call. The server re-validates as a defense-in-depth check.
### Out-of-scope v1 (called out, not built)
- Pasting a playlist URL. Server returns 400 "Playlists aren't supported yet."
- Multi-line paste. Single URL only.
- Quality picker. yt-dlp format string is hard-coded.
- Cookies upload. Private videos fail with a clear message.
## 2. API
### Route
New file `services/mam-api/src/routes/imports.js`, mounted at `/api/v1/imports` in `services/mam-api/src/index.js`.
**`POST /api/v1/imports/youtube`**
Request body:
```json
{ "url": "https://youtu.be/dQw4w9WgXcQ", "projectId": "uuid", "binId": "uuid?" }
```
Behavior:
1. Validate `url` against the same three regexes as the client. 400 on miss.
2. Reject playlist URLs (URL contains `list=`) with 400 "Playlists aren't supported yet."
3. Generate `assetId = uuidv4()`.
4. Insert into `assets` with:
- `status='ingesting'`
- `media_type='video'`
- `filename = url` (placeholder; worker overwrites with the sanitized title once yt-dlp prints metadata — keeps the row queryable in the meantime)
- `display_name = url` (same; worker overwrites)
- `original_s3_key = NULL` (worker fills in)
- `source_url = url` (new column — see Schema)
- `project_id`, `bin_id`, timestamps.
5. Insert into `jobs` with `type='youtube_import'`, `asset_id`, `payload={ url }`, `status='queued'`, `progress=0`.
6. Enqueue BullMQ job on the `import` queue:
```js
await importQueue.add('youtube', { assetId, url });
```
7. Respond `200 { assetId, jobId }`.
Errors:
- Missing fields → 400.
- Bad URL → 400 with `error: 'Invalid YouTube URL'`.
- Playlist URL → 400 with `error: 'Playlists aren't supported yet'`.
- Project not found → 404.
- DB / queue failure → 500 (next(err)).
### Jobs screen integration
`services/web-ui/public/screens-jobs.jsx` already normalizes job types via a `kindMap`. Add one entry:
```js
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', youtube_import: 'YouTube' };
```
Retry, delete, and the SSE event stream all work for the new type with no further changes because they key off `job.id`, not `job.type`.
## 3. Worker
### Container changes
`services/worker/Dockerfile` gains two packages:
```dockerfile
RUN apk add --no-cache ffmpeg yt-dlp python3
```
`yt-dlp` is in the Alpine `community` repo and pulls `python3` as a runtime dep — we list it explicitly for clarity. Image grows by ~25 MB.
### New worker
`services/worker/src/workers/youtube-import.js`, registered in `services/worker/src/index.js`:
```js
const workers = [
createWorker('proxy', proxyWorker),
createWorker('thumbnail', thumbnailWorker),
createWorker('conform', conformWorker),
createWorker('import', youtubeImportWorker),
];
```
### Job handler
For a job with `{ assetId, url }`:
1. `job.updateProgress(2)` — accepted.
2. Build a temp directory `tmpdir()/yt-${jobId}`.
3. Run yt-dlp:
```sh
yt-dlp \
--no-playlist \
--no-warnings \
--restrict-filenames \
-f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b" \
--merge-output-format mp4 \
--print-json \
--newline \
-o "<tmpdir>/<assetId>.%(ext)s" \
"<url>"
```
- `--print-json` writes one JSON line at the end with title, duration, width, height, uploader, etc.
- `--newline` makes progress lines newline-terminated so we can parse them.
- `--restrict-filenames` prevents shell-special characters in temp paths.
4. Stream stdout line-by-line. Lines matching `r'\[download\]\s+(\d+(\.\d+)?)%'` map to `job.updateProgress(5 + Math.floor(pct * 0.55))` so download takes us from 5 to 60 %.
5. On yt-dlp non-zero exit: parse stderr for the first line containing `ERROR:` and use it as the job's error message. Mark the asset `status='error'`, mark the job failed, throw so BullMQ records it. Surface a friendly substitution for the common cases:
- "Private video" → "Private video — not supported."
- "Sign in to confirm your age" → "Age-restricted video — not supported."
- "Video unavailable" → "Video unavailable or removed."
- "This video is not available in your country" → "Video is geo-blocked from this region."
- HTTP 429 → "YouTube rate-limited the importer — try again later."
- Anything else → use yt-dlp's stderr line verbatim, truncated to 300 chars.
6. Parse the last stdout line as JSON to read metadata. The resulting file is `<tmpdir>/<assetId>.mp4`.
7. `getMediaInfo` (existing helper in `services/worker/src/ffmpeg/executor.js`) on that path. Use ffprobe's values for codec/fps/duration when yt-dlp's are missing or wrong.
8. Sanitize the title for the S3 filename: keep `[A-Za-z0-9 ._-]`, collapse runs of whitespace, trim, cap at 120 chars, append `.mp4`. If the sanitized title is empty, fall back to `youtube-<videoId>.mp4`.
9. Upload to `originals/{assetId}/{sanitized-title}.mp4` via the existing `uploadToS3` helper. Progress 60 → 90 %.
10. UPDATE the assets row with:
- `filename = <sanitized title>.mp4`
- `display_name = <yt-dlp title untouched>`
- `original_s3_key = originals/<assetId>/<sanitized-title>.mp4`
- `codec`, `resolution`, `fps`, `duration_ms`, `file_size` from ffprobe.
- `status = 'processing'`
- `updated_at = NOW()`
11. Enqueue a `proxy` job on the existing `proxy` queue with the same payload shape `upload.js` uses:
```js
await proxyQueue.add('generate', {
assetId,
inputKey: asset.original_s3_key,
outputKey: `proxies/${assetId}.mp4`,
});
```
12. `job.updateProgress(100)`. Return — BullMQ marks the import job done. The proxy job picks up the rest exactly like a regular upload.
13. Always `rm -rf` the temp directory in a `finally`.
### Concurrency & retries
- Default BullMQ concurrency for this queue: **1** per worker process. Two simultaneous yt-dlp invocations risk YouTube rate-limiting more than they help throughput. Configurable later via env if needed.
- No automatic BullMQ retry — yt-dlp failures are almost always permanent (private, geo, removed) and a silent retry storm would chew through quota. The Jobs screen's manual Retry button is the right knob for "this should be transient" cases.
## 4. Schema migration
New file `services/mam-api/src/db/migrations/011-youtube-import.sql`:
```sql
-- 1. Add the new job type to the enum.
-- Postgres requires ALTER TYPE ... ADD VALUE for enum changes.
ALTER TYPE job_type ADD VALUE IF NOT EXISTS 'youtube_import';
-- 2. Remember where an asset came from. NULL for everything that
-- pre-dates the importer; populated for any imported asset.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT;
```
`source_url` is exposed on the asset drawer as a "Source" line ("imported from youtu.be/…") in a follow-up PR — out of scope for this spec, but worth noting that the column exists for it.
## 5. Files touched
**New**
- `services/mam-api/src/routes/imports.js`
- `services/mam-api/src/db/migrations/011-youtube-import.sql`
- `services/worker/src/workers/youtube-import.js`
**Edited**
- `services/mam-api/src/index.js` — mount the new route.
- `services/web-ui/public/screens-ingest.jsx` — add `YouTubeImport`, export on `window`.
- `services/web-ui/public/shell.jsx` — add the nav child, extend `ingestChildren`.
- `services/web-ui/public/app.jsx` — register the route and the crumb.
- `services/web-ui/public/screens-jobs.jsx` — extend `kindMap` with `youtube_import: 'YouTube'`.
- `services/worker/src/index.js` — register the `import` queue worker.
- `services/worker/Dockerfile` — add `yt-dlp` and `python3` to the apk install line.
## 6. Risks & trade-offs
- **Worker egress**. The worker container needs outbound HTTPS to YouTube. Fine in the current homelab; will fail in a locked-down cluster. Documented in the implementation plan.
- **yt-dlp drift**. YouTube changes break old yt-dlp versions every few weeks. The Alpine package lags upstream by days. Fix is to rebuild the worker image. We do not auto-update inside the running container — too risky for an offline / locked-down deploy. If imports start failing en masse, the runbook is `docker compose build worker && docker compose up -d worker`.
- **Single-URL UX feels light**. That is deliberate for v1. Adding multi-URL paste and playlist expansion are both small follow-ups once the single-URL path is stable.
- **No copyright enforcement**. We rely on the one-line notice in the UI. If misuse becomes a real concern, the next step would be an admin allowlist of domains or a per-user import quota — not in this spec.
- **`filename = url` placeholder**. Briefly, the asset row in the Library shows the URL as the name. The worker overwrites it within seconds for a successful import. Acceptable; the Library already handles "ingesting" assets with placeholder names from the upload path.
## 7. Acceptance
The feature is done when:
- A user can navigate to **Ingest → YouTube**, paste a public YouTube URL, pick a project, click Import, and within a minute or two see the asset appear in the Library with proxy and thumbnail.
- A failed import (private video, removed video, bogus URL) shows a clear error message on both the YouTube screen's queue row and the Jobs screen.
- The Jobs screen lists "YouTube" jobs alongside Proxy / Thumbnail / Conform, with Retry working.
- `source_url` is populated on the imported asset row.
- Image rebuild + `docker compose up -d worker` is the documented recovery path if YouTube changes break yt-dlp.

View file

@ -0,0 +1,269 @@
# Dragonflight User Authentication — Design
**Status:** Approved, ready for implementation planning
**Date:** 2026-05-27
**Brainstormed with:** Zac
## Problem
Dragonflight has the skeleton of an auth system spread across the codebase:
- `users` table (`id`, `username`, `password_hash`, `display_name`, `role`)
- `sessions` table (`sid`, `sess`, `expire`) for `connect-pg-simple`
- `groups`, `user_groups`, `api_tokens` tables
- `SESSION_SECRET` env var
- `AUTH_ENABLED` env flag with boot-log toggle
- PR #26 frontend handler that bounces to `/login.html` on 401
- Issue #94 "session security fixes" deployed 2026-05-26 (commit `3ebe5d6`)
But the actual `express-session` middleware was never mounted in `services/mam-api/src/index.js`. There is no `/api/v1/auth/*` router. There is no `requireAuth` middleware. As a result, when `AUTH_ENABLED=true` was tried:
1. User submits login, server returns 200 OK from a stub endpoint.
2. No `Set-Cookie` is ever sent (no session middleware mounted).
3. The next request to a protected route returns 401.
4. Frontend bounces to `/login.html`.
5. **Infinite redirect loop.**
The prior attempts failed because auth was being built reactively in pieces, with no single source of truth for what "logged in" means.
## Goals
- One coherent, readable auth code path.
- Web UI logins survive page reloads and container restarts.
- Premiere panel can authenticate via long-lived bearer tokens.
- First-run setup works on a fresh install with no env var or CLI gymnastics.
- The whole auth flow can be exercised by automated tests, including a regression test for the redirect-loop failure mode.
## Non-goals (v1)
- MFA / TOTP.
- OAuth / OIDC delegation (Forgejo, Google, etc.).
- Per-project or per-recorder permissions. Flat access: logged in = full access.
- Email-based "forgot password" (no SMTP assumed; admin-reset only).
- Audit log of who-did-what (the `last_login_at` column is the minimum).
- Service-to-service auth for `node-agent` — keeps existing `019-node-token-binding` mechanism.
## Decisions
| Decision | Choice | Reasoning |
|---|---|---|
| Client surface | Web UI + Premiere panel | Two transports (cookies + bearer), one identity backend |
| Permission model | Flat (logged in = full access) | Small homogeneous operator population. `groups` / `user_groups` schemas stay inert. |
| Identity provider | Local username/password | On-prem broadcast operators won't tolerate OIDC roundtrips. Matches existing schema. |
| First-user bootstrap | First-run setup page | Hardest to mis-configure. No env vars to leak. No CLI to remember. |
| Session lifetime | 8h absolute + 1h sliding idle | Operator security posture, tighter than typical SaaS. |
| Auth library | Hand-rolled (`express-session` + `connect-pg-simple`) | Explicit, debuggable. Rejected JWT and Passport for this codebase. |
## Architecture
### Single source of truth
"Logged in" means exactly one of two things:
1. The request carries a valid `dragonflight.sid` cookie whose row in `sessions` hasn't expired and isn't past its 1h-idle or 8h-absolute window, OR
2. The request carries `Authorization: Bearer <token>` whose SHA-256 matches an `api_tokens` row that hasn't been revoked or expired.
Nothing else counts. No `localStorage` flags, no JWT, no client-side "I think I'm logged in" hints.
### One middleware, one check
`services/mam-api/src/middleware/auth.js` exposes a single `requireAuth` function:
```js
export async function requireAuth(req, res, next) {
// Dev mode preserved. The 'dev' user is a real row in `users` seeded at
// boot when AUTH_ENABLED !== 'true', so FK-bearing routes (api_tokens,
// future comments, audit fields) keep working without conditional logic.
if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER; // { id: <UUID of seeded 'dev' user>, username: 'dev' }
return next();
}
// 1. Session check
if (req.session?.user_id) {
const now = Date.now();
if (now - req.session.first_seen_at > 8 * 3600 * 1000) return destroyAnd401(req, res);
if (now - req.session.last_seen_at > 1 * 3600 * 1000) return destroyAnd401(req, res);
req.session.last_seen_at = now;
req.user = await loadUser(req.session.user_id);
if (!req.user) return destroyAnd401(req, res);
return next();
}
// 2. Bearer check
const bearer = parseBearer(req.headers.authorization);
if (bearer) {
const hash = sha256hex(bearer);
const row = await pool.query(
`SELECT t.id, t.user_id, t.expires_at, u.username
FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1`, [hash]);
if (row.rows.length && (!row.rows[0].expires_at || row.rows[0].expires_at > new Date())) {
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [row.rows[0].id]).catch(() => {});
req.user = { id: row.rows[0].user_id, username: row.rows[0].username };
return next();
}
}
// 3. Otherwise
return res.status(401).json({ error: 'unauthorized' });
}
```
Mounted at the `/api/v1` level in `services/mam-api/src/index.js`, **before** the individual route mounts, with an allowlist for the three pre-login auth paths:
```js
app.use('/api/v1', (req, res, next) => {
const unauth = ['/auth/login', '/auth/setup', '/auth/setup-required'];
if (unauth.some(p => req.path === p)) return next();
return requireAuth(req, res, next);
});
// then: app.use('/api/v1/assets', assetsRouter), etc.
```
`/health` lives at the root, outside the `/api/v1` mount, so it's naturally unaffected. `/api/v1/cluster/*` keeps its existing `019-node-token-binding` service-auth path: requireAuth runs first, fails with 401 for an unauthenticated request, **but** the cluster routes themselves do their own token check on request bodies, so node-agent traffic must include a valid user session OR an api_token (which is the change — node-agent will need to be issued an api_token at install time). Alternative: carve `/api/v1/cluster/*` out of the requireAuth gate too, and keep node-agent on its existing binding token alone. Implementer should pick — flagged in the implementation order.
### Session middleware (actually wired this time)
In `services/mam-api/src/index.js`, **before any route**:
```js
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
const PgStore = connectPgSimple(session);
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
app.use(session({
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
secret: process.env.SESSION_SECRET,
name: 'dragonflight.sid',
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.TRUST_PROXY === 'true',
path: '/',
maxAge: 8 * 3600 * 1000,
},
rolling: false, // sliding renewal handled in requireAuth so we can enforce idle + absolute separately
resave: false,
saveUninitialized: false,
}));
```
### Auth router
`services/mam-api/src/routes/auth.js`:
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/v1/auth/setup-required` | none | `{ required: bool }`. Cheap, no auth. |
| `POST` | `/api/v1/auth/setup` | none | Only succeeds if `users` is empty. Creates first user, logs them in. |
| `POST` | `/api/v1/auth/login` | none | `{ username, password }` -> 200 + cookie or 401 |
| `POST` | `/api/v1/auth/logout` | required | Destroys session row, clears cookie |
| `GET` | `/api/v1/auth/me` | required | `{ id, username, display_name }` |
| `POST` | `/api/v1/auth/password` | required | Change own password (requires current) |
| `GET/POST/DELETE` | `/api/v1/auth/users[/:id]` | required | User CRUD |
| `GET/POST/DELETE` | `/api/v1/auth/tokens[/:id]` | required | Current user's API tokens |
### Data model
Existing schema is almost right. One small migration:
```sql
-- services/mam-api/src/db/migrations/023-auth-session-timestamps.sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
-- idle / absolute timestamps live inside session.sess JSONB; no schema change needed
```
`groups` and `user_groups` stay as-is, unused for v1. `api_tokens` is already correctly shaped.
## Flows
### Browser login (the one that broke last time)
1. SPA boots, `<AuthGate>` calls `GET /api/v1/auth/me`.
2. `requireAuth` returns 401.
3. AuthGate calls `GET /api/v1/auth/setup-required`. If `true`, render Setup screen. Otherwise, render Login screen.
4. User submits `POST /api/v1/auth/login`. Server `bcrypt.compare`s, sets `req.session.user_id`, `first_seen_at`, `last_seen_at`. **Critical:** `await new Promise(r => req.session.save(r))` before responding, so the cookie is persisted to Postgres before the next request can arrive.
5. AuthGate re-calls `/api/v1/auth/me`, gets 200, renders the app.
**Why this doesn't loop:** the explicit `req.session.save()` callback before response guarantees the cookie row exists before the SPA can fire its next request. `requireAuth` returns a clean 401 (not a redirect) so the SPA decides what to render. The static `/login.html` is deleted; there is no HTML bounce.
### Premiere panel bearer
1. Web UI -> Settings -> API Tokens -> "New token" named "Premiere panel".
2. `POST /api/v1/auth/tokens` returns `{ token: 'dfl_<32 hex>', prefix: 'dfl_a3f2', id }` **exactly once**.
3. Premiere panel sends `Authorization: Bearer dfl_<...>` on every request. `requireAuth` SHA-256s it, looks up `api_tokens.token_hash`, updates `last_used_at`.
### Idle + absolute timeout (inside `requireAuth`)
```
if session present:
if now - session.first_seen_at > 8h -> destroy session, 401
if now - session.last_seen_at > 1h -> destroy session, 401
session.last_seen_at = now
req.user = lookup(session.user_id)
next()
```
Bearer tokens have their own optional `expires_at` (`NULL` = never expires); checked the same way.
## Frontend
- **`services/web-ui/src/auth-gate.jsx`** — new component that wraps the SPA. On mount: `GET /me`. On 401: check `setup-required`, render either Setup or Login. On 200: render the app shell.
- **Login screen** — layout B from brainstorm: 22px wordmark over "WILD DRAGON BROADCAST" tagline above a `--bg-1` card containing username, password, "Sign in" button. Matches DESIGN.md tokens.
- **Setup screen** — same chrome; fields = username, password, confirm password; button = "Create admin".
- **Settings -> Account section** — change password.
- **Settings -> API Tokens section** — list / create / revoke. New token shown exactly once with a copy affordance.
- **Fetch wrapper** — the central `ZAMPP_API.fetch` (already exists) gains a 401 handler that re-mounts AuthGate's Login state with the current path saved as `last_path`, restored after re-auth.
### Removed
- The static `/login.html` page (PR #26's bounce target) is deleted. SPA handles login internally; no full-page reload.
## Error handling
| Case | Behavior |
|---|---|
| Wrong username or password | `401 { error: 'invalid credentials' }`. Same message either way, no user enumeration. |
| Login rate limiting | Per-IP exponential backoff (1s, 2s, 4s, 8s, max 30s). In-memory `Map`. Single-instance limitation documented. |
| Idle / absolute expiry | 401 -> AuthGate Login. Last path saved, restored on re-auth. |
| Setup after first user exists | `409 { error: 'setup already complete' }`. Permanently disabled. |
| Token revoke | `DELETE /api/v1/auth/tokens/:id` — only owner can revoke. Subsequent bearer requests 401. |
| Delete-self when only user | `409 { error: 'cannot delete last user' }`. |
| Forgot password | No self-serve. Any logged-in user can reset another via `POST /api/v1/auth/users/:id/password`. Documented as the recovery path. |
| Password rules | Min 12 chars, no max, no character class requirements (NIST SP 800-63B). `bcrypt` cost 12. |
| CSRF | `SameSite=Lax` + same origin + required `X-Requested-With: dragonflight-ui` header on mutating requests (belt-and-suspenders). |
| Session table growth | `connect-pg-simple` `pruneSessionInterval: 60 * 15` (every 15 min). |
## Testing
- **Unit — `services/mam-api/test/middleware/auth.test.js`**: requireAuth with (a) no creds, (b) valid session, (c) idle-expired session, (d) absolute-expired session, (e) valid bearer, (f) invalid bearer, (g) bearer matching a deleted user.
- **Integration — `services/mam-api/test/auth.integration.test.js`**: spin up Express + test Postgres. Walks: setup -> login -> /me -> mutating call -> logout -> /me 401. Second pass: idle timeout simulated by mutating `last_seen_at` in DB. Third pass: bearer issue -> use -> revoke -> 401.
- **Regression test for the redirect-loop bug:** explicit test that after `POST /auth/login` returns 200, a subsequent `GET /auth/me` with the returned cookie returns 200 in the same test client. This is the test that would have caught the original failure.
- **Manual smoke (documented in PR):** fresh install -> setup -> create admin -> land on dashboard -> reload (stays logged in) -> wait 1h idle -> reload -> bounce to login.
## Implementation order
Suggested sequencing for the implementation plan (writing-plans will refine):
1. Migration `023-auth-session-timestamps.sql`. Add idempotent seed of the dev user (`INSERT ... ON CONFLICT DO NOTHING` with a fixed UUID) so dev mode FK-bearing routes work out of the box.
2. `express-session` + `connect-pg-simple` wiring in `index.js`.
3. `requireAuth` middleware (with `DEV_USER` constant resolved from the seeded row).
4. Auth router (setup, login, logout, me, password).
5. Apply `requireAuth` to API router with allowlist. Decide cluster carve-out (see Architecture).
6. Auth tests (unit + integration + regression).
7. Frontend `<AuthGate>` + Login screen + Setup screen.
8. Frontend Settings -> Account + API Tokens.
9. Delete `/login.html`.
10. User CRUD + token CRUD routes.
11. Rate limiting + CSRF header.
12. Documentation: README updates, `AUTH_ENABLED` transition notes.
## Out-of-band notes for the implementer
- The current `cors({ origin: true, credentials: true })` in `index.js` is too permissive once cookies start carrying authority. Tighten to a specific origin list (driven by an `ALLOWED_ORIGINS` env var) at the same time as wiring the session middleware — otherwise we're undoing the `SameSite=Lax` protection from the other side.
- node-agent -> mam-api traffic on `/api/v1/cluster/*` must keep working. Add a route-level carve-out comment that this path uses the existing `019-node-token-binding` token, not the user-auth path.
- The boot log currently says `Authentication: ENABLED` / `DISABLED (set AUTH_ENABLED=true for production)`. Once this lands, the recommended default flips: `AUTH_ENABLED=true` becomes the documented default in `.env.example` and the README, and `AUTH_ENABLED=false` is documented as a dev-only escape hatch.

View file

@ -0,0 +1,84 @@
# HLS VOD Playback for Browser
Date: 2026-05-29 | Status: design → implementation
Authors: Zac + Claude
## Purpose
Replace the browser playback path for **recorded (VOD) assets** with HLS, retiring
the MP4 range-stitching workaround. The MP4 proxy is **kept** (supplements, not
replaces) because the Premiere UXP panel and conform pipeline consume it.
## Background — current state
- `GET /assets/:id/stream` returns `{ url: /api/v1/assets/:id/video, type: 'mp4' }`
for ready assets.
- `GET /assets/:id/video` streams `proxies/<id>.mp4` through Node with the
**RustFS range-stitching hack** (`stitchedS3Stream`): RustFS mis-serves ranged
GETs whose start offset is past ~5.8 MB, so the endpoint streams from byte 0 and
drops bytes. Works, but wastes bandwidth/CPU per seek and is fragile.
- **Live** assets already use HLS (`type: 'hls'`, `/live/<id>/index.m3u8`), and
`hls.js` is already loaded and wired in `screens-asset.jsx` for `type === 'hls'`.
- The proxy worker (`services/worker/src/workers/proxy.js`) produces a single
H.264/AAC/yuv420p MP4 — already HLS-compatible.
## Decisions
- **Supplement, not replace.** Keep `proxies/<id>.mp4`; add an HLS rendition.
- **Generate in the proxy worker** via fast remux (`-c copy`) — no re-encode.
- **Serve segments through mam-api** as whole-file GETs (no Range) — sidesteps the
RustFS range bug entirely and reuses session auth.
## Architecture
### 1. Generation (worker/proxy.js)
After uploading `proxies/<id>.mp4`, remux it to HLS into a temp dir:
```
ffmpeg -i <proxy.mp4> -c copy -f hls \
-hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \
-hls_segment_filename <tmp>/seg_%03d.ts <tmp>/index.m3u8
```
Upload every file in the temp dir to `hls/<assetId>/` (playlist + `.ts`). Set
`assets.hls_s3_key = 'hls/<assetId>/index.m3u8'`. Remux is seconds; failure is
non-fatal (MP4 path still works as fallback).
### 2. Storage / schema
Migration adds `assets.hls_s3_key TEXT` (nullable). Presence = HLS available.
Segment objects live under `hls/<assetId>/seg_NNN.ts`; playlist references
**relative** segment names so the serving endpoint is path-agnostic.
### 3. Serving (mam-api)
New `GET /assets/:id/hls/:file` (file = `index.m3u8` or `seg_NNN.ts`):
- Validate `:file` against `^(index\.m3u8|seg_\d+\.ts)$` (no traversal).
- Whole-object GET of `hls/<id>/<file>` from S3 — **no Range handling**.
- Content-Type: `application/vnd.apple.mpegurl` (m3u8) / `video/mp2t` (ts).
- `Cache-Control: private, max-age=3600` for segments; `no-cache` for the playlist.
- Covered by the existing `requireAuth` gate; `hls.js` carries the same-origin
session cookie (same mechanism the live HLS path already relies on).
### 4. Stream selection (mam-api `/stream`)
For non-live assets: if `hls_s3_key` is set →
`{ url: '/api/v1/assets/:id/hls/index.m3u8', type: 'hls' }`. Else fall back to the
existing MP4 `/video` response. Live unchanged.
### 5. Backfill (existing assets)
Add an `hls` BullMQ job + `POST /assets/:id/reprocess?type=hls`: downloads the
existing `proxy_s3_key`, remuxes to HLS, uploads, sets `hls_s3_key`. No re-encode.
### 6. Frontend
No change required — `screens-asset.jsx` already plays `type: 'hls'` via `hls.js`.
Verify `hls.js` xhr carries credentials (same-origin cookie) for the proxied
segments; add `xhrSetup` withCredentials only if needed.
## Out of scope
- Multi-bitrate/ABR ladders (single rendition for now).
- Replacing the MP4 proxy or the `/video` endpoint (kept as fallback + for panel).
- Live-asset playback changes (already HLS).
## Test plan
1. Upload/capture an asset → proxy job produces MP4 **and** `hls/<id>/index.m3u8`.
2. `/stream` returns `type: 'hls'`; `/assets/:id/hls/index.m3u8` → 200 m3u8;
`/assets/:id/hls/seg_000.ts` → 200 `video/mp2t`, whole-file (no 206/Range).
3. Browser: asset plays + seeks via hls.js (no range-stitching path hit).
4. `reprocess?type=hls` backfills an older asset; it then plays via HLS.
5. MP4 proxy + `/hires` download still work (panel workflow intact).

View file

@ -0,0 +1,235 @@
# Wild Dragon MAM — Playout / Master Control (MCR)
**Date:** 2026-05-30 (revised 2026-05-30 — §7 closed)
**Status:** APPROVED — implementation in progress (code drafted but uncommitted; see WORK_LOG_PLAYOUT.md)
**Author:** Zac + Claude
---
## Resolved Decisions
| Question | Decision |
|----------|----------|
| Playout engine | **CasparCG Server** (orchestrated via AMCP), not ffmpeg-native |
| Channel count | **Multi-channel from start** — N independent channels, placed across cluster nodes by capability (mirrors recorders) |
| Scheduling model | **Phased** — Phase A: on-demand playlist player; Phase B: 24/7 continuous channel |
| Output targets | SDI (DeckLink), NDI, SRT, RTMP — all via CasparCG consumers |
| Media source | Assets live in **S3**; must be staged to a CasparCG-local media volume before play (see §4) |
| CasparCG packaging | **Build our own image** (like `capture/build-with-decklink.sh`) — GL context via GPU passthrough or Xvfb; NDI + DeckLink SDKs fetched at build time (not redistributable) |
| Master codec playability | Zac confirms current masters **play fine in CasparCG** — no transcode-for-playout step; staging is a plain S3→/media copy. Validate on hardware but do not gate on it |
| Management UI | **Single Dragonflight `playout.html` GUI** drives everything via AMCP; operator never touches CasparCG directly |
---
## Overview
Playout adds **master-control-room** capability to Dragonflight: take library assets, arrange them on a timeline / playlist, and play them out continuously to a broadcast output — SDI via DeckLink, or stream via SRT / RTMP / NDI. Drag-and-drop scheduling, a program/preview monitor, and as-run logging.
This is the **mirror image** of the existing capture path. Capture is `input → ffmpeg encode → S3`. Playout is `S3 asset → engine → output`. We reuse three things wholesale:
1. **Cluster node + capability model** — nodes already advertise DeckLink/Deltacast/GPU in `cluster_nodes.capabilities`; channels are placed on nodes that have a free output port, exactly as recorders claim input ports.
2. **Sidecar orchestration** — mam-api spawns containers via the local Docker socket or the remote `node-agent /sidecar/start`. A CasparCG channel is just a different sidecar image.
3. **Scheduler tick + PG advisory lock**`src/scheduler.js` already runs a single-leader tick over a schedule table. Phase B's wall-clock channel reuses this pattern.
### Why CasparCG over ffmpeg-native
The capture stack proves we can drive ffmpeg + DeckLink. But playout's hard part is **gapless, frame-accurate, clean transitions between clips** — every clip boundary in an ffmpeg-per-clip model is a black flash unless we engineer a concat-feeder. CasparCG solves this natively: a channel is a persistent output with a playlist, hard/mix/wipe transitions, layered graphics/logo (CG), and DeckLink/NDI/SRT/RTMP consumers built in. We orchestrate it over **AMCP** (its TCP control protocol) instead of reinventing a feeder. Trade: a new dependency + container image, and media must be on a CasparCG-visible disk (§4).
---
## 1. Data Model
New migration `029-playout.sql`. Five tables.
### 1.1 `playout_channels`
A logical output. One channel → one engine instance → one output target.
```
id uuid pk
name text -- "Channel 1", "Pop-up SDI"
node_id uuid -> cluster_nodes(id) -- where the engine runs (null = primary)
output_type text -- 'decklink' | 'ndi' | 'srt' | 'rtmp'
output_config jsonb -- { device_index } | { ndi_name } | { url, latency } | { url, key }
video_format text -- '1080i5994' | '1080p5994' | '720p5994' ...
status text -- 'stopped' | 'starting' | 'running' | 'error'
container_id text -- running CasparCG sidecar
project_id uuid -> projects(id) -- RBAC scoping (nullable = admin-only)
created_at / updated_at
```
`output_type` + `output_config` map straight to a CasparCG consumer:
- `decklink``ADD <ch> DECKLINK <device> ...`
- `ndi``ADD <ch> NDI ...`
- `srt`/`rtmp` → `ADD <ch> FFMPEG <url> -f mpegts ...` (CasparCG 2.3+ ffmpeg consumer)
### 1.2 `playout_playlists`
An ordered list of items bound to a channel. Phase A's primary object.
```
id, channel_id -> playout_channels(id)
name, loop boolean, created_at / updated_at
```
### 1.3 `playout_items`
One entry on a playlist OR one entry on the 24/7 timeline.
```
id
playlist_id uuid -> playout_playlists(id) -- Phase A
asset_id uuid -> assets(id)
sort_order int -- position in playlist (Phase A)
scheduled_at timestamptz -- wall-clock start (Phase B, null in A)
in_point numeric -- seconds, trim head (reuse subclip in/out from editor)
out_point numeric -- seconds, trim tail
transition text -- 'cut' | 'mix' | 'wipe'
transition_ms int
graphics jsonb -- optional CG/template overlay (Phase B+)
media_status text -- 'pending' | 'staging' | 'ready' | 'error' (see §4)
media_path text -- resolved path inside the CasparCG media volume
```
### 1.4 `playout_schedule` (Phase B)
Day-ahead, wall-clock-bound timeline rows. Same shape as `playout_items` but `scheduled_at` is authoritative and the scheduler tick (§5) drives transitions. Phase A can ignore this table.
### 1.5 `playout_as_run`
Append-only log: what actually played, when, for how long. Compliance / billing.
```
id, channel_id, asset_id, item_id
started_at, ended_at, duration_s, result -- 'played' | 'skipped' | 'error'
```
---
## 2. Services & Components
### 2.1 New sidecar: `services/playout/` (CasparCG wrapper)
A thin container: **CasparCG Server** + a small Node control shim exposing HTTP, the same way `capture` wraps ffmpeg.
- Base image: official/community `casparcg/server` (Linux build with DeckLink + NDI + FFmpeg producers/consumers).
- Node shim (`src/index.js`): opens an AMCP TCP socket to local CasparCG, exposes:
- `POST /channel/start``ADD <ch> <consumer>` for the channel's output target
- `POST /play``PLAY <ch>-<layer> <media> [transition]`
- `POST /loadbg` + `/play` → preview/cue then take (preview monitor)
- `POST /stop`, `GET /status``INFO <ch>` (current clip, position, fps)
- playlist load → translate `playout_items` rows into a sequence of AMCP `LOADBG`/`PLAY` calls, advancing on `OnTransition` / end-of-clip events.
- Mirrors capture's status-polling contract so the UI monitor reuses existing plumbing.
### 2.2 mam-api: `src/routes/playout.js`
CRUD + control, RBAC-scoped via the existing `assertProjectAccess` helper (channels carry `project_id`).
```
GET /playout/channels list (project-filtered)
POST /playout/channels create (edit on project)
POST /playout/channels/:id/start|stop spawn/kill CasparCG sidecar
GET /playout/channels/:id/status proxy engine INFO
POST /playout/channels/:id/play|pause|skip transport control
GET/POST/PUT/DELETE /playout/playlists... playlist + item CRUD, reorder
POST /playout/items/:id/stage kick S3→media-volume staging (§4)
GET /playout/channels/:id/asrun as-run log
```
Channel start/stop reuses `resolveNodeTarget()` + the Docker-socket / `node-agent /sidecar/start` split already in `recorders.js`. **Refactor opportunity:** lift that sidecar-spawn logic out of `recorders.js` into `src/orchestration/sidecar.js` so both recorders and playout share it (keep this small — only what both need).
### 2.3 web-ui: `playout.html` + `public/playout.jsx`
New MCR page. Layout:
```
┌─ PREVIEW ───────────┬─ PROGRAM (on air) ──────┐
│ [cued clip] │ [live output] ● ON AIR │
│ TC / duration │ TC / remaining │
│ [CUE] [TAKE] │ [PLAY][PAUSE][SKIP][STOP]│
├─ MEDIA BIN ─────────┴──────────────────────────┤
│ (draggable asset list, reuse asset browser) │
├─ PLAYLIST / TIMELINE ──────────────────────────┤
│ ▸ clip A ──▸ clip B ──▸ clip C (drag-drop) │ Phase A: ordered list
│ └ 24h time grid w/ now-bar │ Phase B: time-of-day grid
└────────────────────────────────────────────────┘
```
- Drag-drop: reuse whatever the NLE editor timeline uses (check `editor.jsx`); assets drag from the bin into the playlist/grid.
- API via existing `ZAMPP_API.fetch` wrapper.
- Program monitor: HLS preview of the output — CasparCG can emit a second low-bitrate FFmpeg consumer to HLS, reusing the `/live/<id>` HLS plumbing capture already uses.
---
## 3. Channel placement & ports
A DeckLink port is exclusive — same constraint capture already handles. A node's DeckLink port can be an **input (recorder)** or an **output (playout channel)**, never both at once. So:
- Extend the capability/port-claim check: when starting a channel on `output_type=decklink`, verify the target node has that device index free (no active recorder, no active channel).
- NDI / SRT / RTMP outputs have no hardware contention → can stack many per node (GPU/CPU-bound only).
- Surface a unified "device map" (extend the existing cluster DeckLink-status endpoint) showing each port's role: idle / recording / playing-out.
---
## 4. Media staging (the S3 ⇄ CasparCG gap)
**The crux.** Assets live in S3 (`original_s3_key` / `proxy_s3_key`). CasparCG plays from a **local media folder**. Options:
- **A — Pre-stage to a shared media volume (recommended).** Before a clip can go on air, download/symlink it from S3 to a CasparCG-visible volume (`/media`), set `playout_items.media_status='ready'` + `media_path`. A new BullMQ `playout-stage` job (reuses the worker pattern) does the pull. UI shows per-item readiness; TAKE is blocked until `ready`. Mirrors the growing-file SMB share already mounted for capture.
- **B — Stream from S3 via presigned URL.** CasparCG FFmpeg producer plays an HTTPS presigned URL directly. Zero staging, but seeking/trim and gapless transitions over network are fragile for broadcast. Acceptable as a fallback for SRT/RTMP, risky for SDI.
**Decision:** Phase A uses **A** (stage proxies for preview, masters for air) with **B** available as a per-channel "low-latency / no-stage" toggle. Zac confirms the current masters play fine in CasparCG, so staging is a **plain S3→/media copy — no transcode-for-playout step**. (Validate on hardware during implementation, but the model does not assume a transcode stage.)
---
## 5. Scheduling
### Phase A — playlist player
No wall clock. Operator builds a `playout_playlists` row, drags items in, hits PLAY. The playout sidecar walks `playout_items` by `sort_order`, cueing the next clip during the current one (`LOADBG`) and taking it at end-of-clip with the configured transition. `loop` repeats. As-run logged per item.
### Phase B — 24/7 continuous channel
Wall-clock timeline in `playout_schedule`. Reuse `src/scheduler.js`:
- Add a second tick (or extend the existing one) under the **same PG advisory lock pattern** — exactly-one-leader, so a multi-replica deploy doesn't double-fire.
- Tick responsibilities: stage upcoming items (look-ahead window), verify the on-air item matches the schedule, **fill gaps** (loop a filler/slate asset when the timeline has a hole — a channel must never go black), roll the day forward.
- As-run becomes the compliance record.
---
## 6. Phasing / Milestones
**Phase A — Playlist playout MVP**
1. Migration `029-playout.sql` (channels, playlists, items, as-run).
2. `services/playout/` sidecar: CasparCG image + AMCP control shim, single output target (start with **SRT or NDI** — no hardware needed for dev; DeckLink behind hardware check).
3. mam-api `routes/playout.js` — channel + playlist CRUD, start/stop, transport, RBAC.
4. `playout-stage` BullMQ job (S3 → /media).
5. web-ui `playout.html` — bin + drag-drop ordered playlist + program/preview monitors + transport.
6. DeckLink output on real hardware; port-contention check vs recorders.
**Phase B — 24/7 continuous channel**
7. `playout_schedule` + time-of-day grid UI.
8. Scheduler tick (advisory-locked) — look-ahead staging, gap-fill/slate, day-roll.
9. As-run reporting view.
10. Graphics/CG overlay (logo bug, lower-thirds) via CasparCG templates.
---
## 7. Open Questions (for review)
**Resolved (2026-05-30):**
- ~~CasparCG packaging~~**build our own image.** Fetch DeckLink + NDI SDKs at build time (not redistributable — same as capture's DeckLink build). GL context for the mixer comes from GPU passthrough on a real node, or **Xvfb** (virtual framebuffer) where there's no display — community images run `--privileged` + X11 socket. Pin the NDI SDK version to what the server expects (`.so` version mismatch is the common docker failure).
- ~~Master codec playability~~ → Zac confirms masters **play fine**; no transcode-for-playout. Staging = plain S3→/media copy.
- ~~Management GUI~~**single Dragonflight `playout.html`** drives everything via AMCP; operator never touches CasparCG.
- ~~Audio loudness~~**pre-normalize at stage time** (Zac, 2026-05-30). `playout-stage` job runs ffmpeg `loudnorm` (EBU R128, target 23 LUFS, true-peak 1 dBTP) once, on the S3→/media copy. Output is the cached version CasparCG plays. Staging is no longer a pure copy — staging cost ≈ realtime CPU per clip on first stage; results are reusable across channels. Override (`media_status='ready'` + raw copy) available for clips already mastered to spec.
- ~~Frame rate~~**`1080p5994`** default for new channels (Zac, 2026-05-30). Progressive 1080 @ 59.94 fps. Per-channel override allowed (`video_format` column). Streaming-friendly (SRT/RTMP/NDI) and current SDI gear accepts it; matches capture's 59.94 cadence.
- ~~Preview latency~~**HLS v1** (Zac, 2026-05-30). Reuse capture's `/live/<id>` HLS plumbing. CasparCG emits a second low-bitrate FFmpeg consumer to HLS. ~46s lag, fine for confidence monitor. Operator desk gets a real downstream monitor off the SDI/NDI output anyway. Revisit WebRTC if MCR operators complain.
- ~~Failover~~**auto-restart on healthy node** (Zac, 2026-05-30). Scheduler tick (§5) monitors `playout_sidecars` health (AMCP ping + container alive); on N missed checks marks the channel `error`, re-places it on another capability-matching node with a free output port, resumes the playlist from the next item after the as-run-logged on-air item. Gap = black/slate for ~530 s during respawn (operator sees a flag in the UI). **DeckLink channels are not auto-failed-over in v1** — device-index pinning makes the destination port non-trivial; v1 alerts and lets the operator move the channel. NDI/SRT/RTMP channels (no hardware contention) failover automatically. Tracked via `restart_count` + `last_restart_at` on `playout_channels`.
**Still open:**
- (none — all §7 questions resolved 2026-05-30)
---
## 8. Reused building blocks (already in the repo)
| Need | Existing piece |
|------|----------------|
| Spawn engine container local/remote | `recorders.js` Docker-socket + `node-agent /sidecar/start` |
| Node capability / port model | `cluster_nodes.capabilities`, cluster DeckLink-status endpoint |
| Single-leader scheduled transitions | `src/scheduler.js` + PG advisory lock |
| Background media jobs | BullMQ worker (`services/worker`) |
| RBAC scoping | `src/auth/authz.js` `assertProjectAccess` (channel/project_id) |
| HLS preview plumbing | capture's `/live/<id>` HLS output |
| Subclip in/out points | NLE editor in/out marking |
| API wrapper / SPA shell | `ZAMPP_API.fetch`, esbuild JSX pages |

View file

@ -1,8 +1,97 @@
# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ─────────
# All-Intra HEVC NVENC is the master codec for growing-file ingest (see
# docs/design/2026-05-29-all-intra-hevc-ingest.md). This stage gets the
# nv-codec-headers (header-only, no driver / no full CUDA toolkit needed)
# so ffmpeg's configure can light up hevc_nvenc / h264_nvenc / cuvid.
# At runtime, /dev/nvidia* + the host driver libs (via the NVIDIA Container
# Toolkit) supply the actual encoder.
FROM debian:bookworm AS ffmpeg-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential nasm yasm pkg-config git ca-certificates python3 \
libssl-dev libx264-dev libx265-dev libvpx-dev libopus-dev \
libmp3lame-dev libsrt-openssl-dev \
libzmq3-dev zlib1g-dev libstdc++-12-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy in BMD DeckLink SDK headers and patch script
COPY sdk/ /decklink-sdk/
COPY patch_decklink.py /patch_decklink.py
COPY decklink-sdk16.patch /decklink-sdk16.patch
# nv-codec-headers — just the ffnvcodec public headers + a pkg-config file.
# Pin to a tag known to work with FFmpeg 7.1 (n12.x series).
RUN git clone --depth=1 --branch n12.1.14.0 https://github.com/FFmpeg/nv-codec-headers.git /nv-codec-headers \
&& make -C /nv-codec-headers PREFIX=/usr/local install
# Pull FFmpeg 7.1 source
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
# Patch FFmpeg DeckLink code for SDK 16.x API changes
RUN python3 /patch_decklink.py
WORKDIR /ffmpeg
# NVENC adds: --enable-nvenc (encoder), --enable-cuvid (decoder), --enable-ffnvcodec.
# We deliberately do NOT enable --enable-cuda-nvcc / --enable-libnpp here — those
# require the full ~3GB CUDA toolkit and are only needed for GPU filters like
# yadif_cuda / scale_cuda. If §5's GPU deinterlace stretch goal goes ahead,
# rebuild this image off nvidia/cuda:12.x-devel and flip those flags on.
RUN ./configure \
--prefix=/usr/local \
--extra-cflags="-I/decklink-sdk -I/usr/local/include" \
--extra-ldflags="-L/usr/local/lib" \
--enable-gpl \
--enable-nonfree \
--enable-libx264 \
--enable-libx265 \
--enable-libvpx \
--enable-libopus \
--enable-libmp3lame \
--enable-libsrt \
--enable-libzmq \
--enable-decklink \
--enable-ffnvcodec \
--enable-nvenc \
--enable-cuvid \
--disable-doc \
--disable-debug \
--disable-ffplay \
&& make -j$(nproc) \
&& make install
# Sanity-check: hevc_nvenc and h264_nvenc must be present in the encoder list,
# otherwise the resulting image is useless for the All-Intra HEVC pipeline.
RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|| (echo 'FATAL: nvenc encoders missing from ffmpeg build' && exit 1)
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
FROM node:20-bookworm
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
# Runtime deps for compiled ffmpeg libs
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 \
&& rm -rf /var/lib/apt/lists/*
# Copy compiled ffmpeg/ffprobe
COPY --from=ffmpeg-builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg-builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
COPY --from=ffmpeg-builder /usr/local/lib/ /usr/local/lib/
# DeckLink runtime .so
COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
RUN ldconfig
# Mount points the recorder lifecycle expects to exist.
# /live — HLS preview output (bound from host LIVE_DIR by node-agent)
# /growing — growing-file master output (bound from host /mnt/NVME/MAM/growing)
RUN mkdir -p /live /growing
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3001
CMD ["node", "src/index.js"]

View file

@ -0,0 +1,30 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "=== Checking prerequisites ==="
if [ ! -f sdk/DeckLinkAPI.h ]; then
echo "ERROR: sdk/DeckLinkAPI.h not found."
echo ""
echo "Please download the Blackmagic DeckLink SDK 16.x from:"
echo " https://www.blackmagicdesign.com/developer/product/capture"
echo ""
echo "Then extract the Linux/include/ folder contents into:"
echo " $(pwd)/sdk/"
echo ""
echo "Required files: DeckLinkAPI.h DeckLinkAPIVersion.h DeckLinkAPIDispatch.cpp"
echo " LinuxCOM.h DeckLinkAPIModes.h DeckLinkAPITypes.h"
exit 1
fi
echo "SDK headers found:"
ls sdk/*.h sdk/*.cpp 2>/dev/null
echo ""
echo "=== Building capture container with DeckLink FFmpeg ==="
docker compose -f ../../docker-compose.yml build capture
echo ""
echo "=== Verifying DeckLink support in built image ==="
docker run --rm wild-dragon-capture ffmpeg -f decklink -list_devices true -i dummy 2>&1 | head -20

View file

@ -0,0 +1,346 @@
diff --git a/libavdevice/decklink_common.cpp b/libavdevice/decklink_common.cpp
index fe187cd..47de7ef 100644
--- a/libavdevice/decklink_common.cpp
+++ b/libavdevice/decklink_common.cpp
@@ -25,12 +25,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
-
#ifdef _WIN32
#include <DeckLinkAPI_i.c>
#else
@@ -517,8 +512,8 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
return AVERROR(EIO);
while (ret == 0 && iter->Next(&dl) == S_OK) {
- IDeckLinkOutput_v14_2_1 *output_config;
- IDeckLinkInput_v14_2_1 *input_config;
+ IDeckLinkOutput *output_config;
+ IDeckLinkInput *input_config;
const char *display_name = NULL;
const char *unique_name = NULL;
AVDeviceInfo *new_device = NULL;
@@ -532,14 +527,14 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
goto next;
if (show_outputs) {
- if (dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **)&output_config) == S_OK) {
+ if (dl->QueryInterface(IID_IDeckLinkOutput, (void **)&output_config) == S_OK) {
output_config->Release();
add = 1;
}
}
if (show_inputs) {
- if (dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **)&input_config) == S_OK) {
+ if (dl->QueryInterface(IID_IDeckLinkInput, (void **)&input_config) == S_OK) {
input_config->Release();
add = 1;
}
diff --git a/libavdevice/decklink_common.h b/libavdevice/decklink_common.h
index 095b438..6b32dc2 100644
--- a/libavdevice/decklink_common.h
+++ b/libavdevice/decklink_common.h
@@ -29,23 +29,6 @@
#define IDeckLinkProfileAttributes IDeckLinkAttributes
#endif
-#if BLACKMAGIC_DECKLINK_API_VERSION < 0x0e030000
-#define IDeckLinkInput_v14_2_1 IDeckLinkInput
-#define IDeckLinkInputCallback_v14_2_1 IDeckLinkInputCallback
-#define IDeckLinkMemoryAllocator_v14_2_1 IDeckLinkMemoryAllocator
-#define IDeckLinkOutput_v14_2_1 IDeckLinkOutput
-#define IDeckLinkVideoFrame_v14_2_1 IDeckLinkVideoFrame
-#define IDeckLinkVideoInputFrame_v14_2_1 IDeckLinkVideoInputFrame
-#define IDeckLinkVideoOutputCallback_v14_2_1 IDeckLinkVideoOutputCallback
-#define IID_IDeckLinkInput_v14_2_1 IID_IDeckLinkInput
-#define IID_IDeckLinkInputCallback_v14_2_1 IID_IDeckLinkInputCallback
-#define IID_IDeckLinkMemoryAllocator_v14_2_1 IID_IDeckLinkMemoryAllocator
-#define IID_IDeckLinkOutput_v14_2_1 IID_IDeckLinkOutput
-#define IID_IDeckLinkVideoFrame_v14_2_1 IID_IDeckLinkVideoFrame
-#define IID_IDeckLinkVideoInputFrame_v14_2_1 IID_IDeckLinkVideoInputFrame
-#define IID_IDeckLinkVideoOutputCallback_v14_2_1 IID_IDeckLinkVideoOutputCallback
-#endif
-
extern "C" {
#include "libavutil/mem.h"
#include "libavcodec/packet_internal.h"
@@ -93,16 +76,6 @@ static char *dup_cfstring_to_utf8(CFStringRef w)
#define DECKLINK_FREE(s) free((void *) s)
#endif
-#ifdef _WIN32
-#include <guiddef.h> // REFIID, IsEqualIID()
-#define DECKLINK_IsEqualIID IsEqualIID
-#else
-static inline bool DECKLINK_IsEqualIID(const REFIID& riid1, const REFIID& riid2)
-{
- return memcmp(&riid1, &riid2, sizeof(REFIID)) == 0;
-}
-#endif
-
class decklink_output_callback;
class decklink_input_callback;
@@ -120,8 +93,8 @@ typedef struct DecklinkPacketQueue {
struct decklink_ctx {
/* DeckLink SDK interfaces */
IDeckLink *dl;
- IDeckLinkOutput_v14_2_1 *dlo;
- IDeckLinkInput_v14_2_1 *dli;
+ IDeckLinkOutput *dlo;
+ IDeckLinkInput *dli;
IDeckLinkConfiguration *cfg;
IDeckLinkProfileAttributes *attr;
decklink_output_callback *output_callback;
diff --git a/libavdevice/decklink_dec.cpp b/libavdevice/decklink_dec.cpp
index 8830779..418701e 100644
--- a/libavdevice/decklink_dec.cpp
+++ b/libavdevice/decklink_dec.cpp
@@ -31,11 +31,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
extern "C" {
#include "config.h"
@@ -109,7 +105,7 @@ static VANCLineNumber vanc_line_numbers[] = {
{bmdModeUnknown, 0, -1, -1, -1}
};
-class decklink_allocator : public IDeckLinkMemoryAllocator_v14_2_1
+class decklink_allocator : public IDeckLinkMemoryAllocator
{
public:
decklink_allocator(): _refs(1) { }
@@ -133,21 +129,7 @@ public:
virtual HRESULT STDMETHODCALLTYPE Decommit() { return S_OK; }
// IUnknown methods
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkMemoryAllocator_v14_2_1)) {
- *ppv = static_cast<IDeckLinkMemoryAllocator_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
-
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
virtual ULONG STDMETHODCALLTYPE Release(void)
{
@@ -490,7 +472,7 @@ skip_packet:
}
-static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, int64_t pts)
+static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame *videoFrame, int64_t pts)
{
const uint8_t KLV_DID = 0x44;
const uint8_t KLV_IN_VANC_SDID = 0x04;
@@ -592,30 +574,17 @@ static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideo
}
}
-class decklink_input_callback : public IDeckLinkInputCallback_v14_2_1
+class decklink_input_callback : public IDeckLinkInputCallback
{
public:
explicit decklink_input_callback(AVFormatContext *_avctx);
~decklink_input_callback();
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkInputCallback_v14_2_1)) {
- *ppv = static_cast<IDeckLinkInputCallback_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void);
virtual ULONG STDMETHODCALLTYPE Release(void);
virtual HRESULT STDMETHODCALLTYPE VideoInputFormatChanged(BMDVideoInputFormatChangedEvents, IDeckLinkDisplayMode*, BMDDetectedVideoInputFormatFlags);
- virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame_v14_2_1*, IDeckLinkAudioInputPacket*);
+ virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame*, IDeckLinkAudioInputPacket*);
private:
std::atomic<int> _refs;
@@ -624,7 +593,7 @@ private:
int no_video;
int64_t initial_video_pts;
int64_t initial_audio_pts;
- IDeckLinkVideoInputFrame_v14_2_1* last_video_frame;
+ IDeckLinkVideoInputFrame* last_video_frame;
};
decklink_input_callback::decklink_input_callback(AVFormatContext *_avctx) : _refs(1)
@@ -656,7 +625,7 @@ ULONG decklink_input_callback::Release(void)
return ret;
}
-static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
+static int64_t get_pkt_pts(IDeckLinkVideoInputFrame *videoFrame,
IDeckLinkAudioInputPacket *audioFrame,
int64_t wallclock,
int64_t abs_wallclock,
@@ -710,7 +679,7 @@ static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
return pts;
}
-static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
+static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame *videoFrame)
{
IDeckLinkTimecode *timecode;
int ret = AVERROR(ENOENT);
@@ -732,7 +701,7 @@ static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational f
return ret;
}
-static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
+static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame *videoFrame)
{
AVRational frame_rate = ctx->video_st->r_frame_rate;
int ret;
@@ -757,7 +726,7 @@ static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimec
}
HRESULT decklink_input_callback::VideoInputFrameArrived(
- IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
+ IDeckLinkVideoInputFrame *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
{
void *frameBytes;
void *audioFrameBytes;
@@ -1172,7 +1141,7 @@ av_cold int ff_decklink_read_header(AVFormatContext *avctx)
goto error;
/* Get input device. */
- if (ctx->dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **) &ctx->dli) != S_OK) {
+ if (ctx->dl->QueryInterface(IID_IDeckLinkInput, (void **) &ctx->dli) != S_OK) {
av_log(avctx, AV_LOG_ERROR, "Could not open input device from '%s'\n",
avctx->url);
ret = AVERROR(EIO);
diff --git a/libavdevice/decklink_enc.cpp b/libavdevice/decklink_enc.cpp
index d2e246c..cb8f917 100644
--- a/libavdevice/decklink_enc.cpp
+++ b/libavdevice/decklink_enc.cpp
@@ -28,11 +28,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
extern "C" {
#include "libavformat/avformat.h"
@@ -52,7 +48,7 @@ extern "C" {
#endif
/* DeckLink callback class declaration */
-class decklink_frame : public IDeckLinkVideoFrame_v14_2_1
+class decklink_frame : public IDeckLinkVideoFrame
{
public:
decklink_frame(struct decklink_ctx *ctx, AVFrame *avframe, AVCodecID codec_id, int height, int width) :
@@ -115,20 +111,7 @@ public:
_ancillary->AddRef();
return S_OK;
}
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoFrame_v14_2_1)) {
- *ppv = static_cast<IDeckLinkVideoFrame_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
virtual ULONG STDMETHODCALLTYPE Release(void)
{
@@ -155,10 +138,10 @@ private:
std::atomic<int> _refs;
};
-class decklink_output_callback : public IDeckLinkVideoOutputCallback_v14_2_1
+class decklink_output_callback : public IDeckLinkVideoOutputCallback
{
public:
- virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame_v14_2_1 *_frame, BMDOutputFrameCompletionResult result)
+ virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame *_frame, BMDOutputFrameCompletionResult result)
{
decklink_frame *frame = static_cast<decklink_frame *>(_frame);
struct decklink_ctx *ctx = frame->_ctx;
@@ -176,20 +159,7 @@ public:
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE ScheduledPlaybackHasStopped(void) { return S_OK; }
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoOutputCallback_v14_2_1)) {
- *ppv = static_cast<IDeckLinkVideoOutputCallback_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return 1; }
virtual ULONG STDMETHODCALLTYPE Release(void) { return 1; }
};
@@ -769,7 +739,7 @@ static int decklink_write_video_packet(AVFormatContext *avctx, AVPacket *pkt)
ctx->first_pts = pkt->pts;
/* Schedule frame for playback. */
- hr = ctx->dlo->ScheduleVideoFrame(frame,
+ hr = ctx->dlo->ScheduleVideoFrame((class IDeckLinkVideoFrame *) frame,
pkt->pts * ctx->bmd_tb_num,
ctx->bmd_tb_num, ctx->bmd_tb_den);
/* Pass ownership to DeckLink, or release on failure */
@@ -904,7 +874,7 @@ av_cold int ff_decklink_write_header(AVFormatContext *avctx)
return ret;
/* Get output device. */
- if (ctx->dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **) &ctx->dlo) != S_OK) {
+ if (ctx->dl->QueryInterface(IID_IDeckLinkOutput, (void **) &ctx->dlo) != S_OK) {
av_log(avctx, AV_LOG_ERROR, "Could not open output device from '%s'\n",
avctx->url);
ret = AVERROR(EIO);

View file

@ -0,0 +1,21 @@
#!/usr/bin/env python3
# Apply the upstream FFmpeg master decklink SDK-16 compatibility patch on top
# of the release/7.1 source. The patch renames every IDeckLink* interface and
# helper to its _v14_2_1 versioned form so the call sites keep working against
# SDK 16's headers (which only retain the versioned aliases). Cherry-picking
# individual replacements like the previous regex patch produced inconsistent
# code that compiled but silently dropped every video frame.
import subprocess, sys, pathlib
patch = pathlib.Path('/decklink-sdk16.patch')
if not patch.exists():
print('FATAL: /decklink-sdk16.patch not found in build context', file=sys.stderr)
sys.exit(1)
# Patch was produced as `git diff HEAD FETCH_HEAD` where HEAD=release/7.1 and
# FETCH_HEAD=master, so we apply it in REVERSE to move 7.1 → master.
result = subprocess.run(
['git', 'apply', '-R', '--verbose', str(patch)],
cwd='/ffmpeg', capture_output=True, text=True,
)
print(result.stdout)
print(result.stderr, file=sys.stderr)
sys.exit(result.returncode)

View file

@ -1,9 +1,112 @@
import { spawn } from 'child_process';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';
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
// (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';
// ── 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.
const VIDEO_CODECS = {
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' },
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
// All-Intra HEVC on NVENC — the growing-file master codec.
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
// to its last complete frame — the prerequisite for edit-while-record.
//
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
// is rejected with EINVAL (validated on the L4, driver 595). The working
// recipe for true all-intra is therefore:
// -bf 0 no B-frames
// -g 600 large GOP just to satisfy the init check
// -forced-idr 1 forced keyframes are emitted as IDR
// -force_key_frames expr:1 force a keyframe on EVERY frame
// → ffprobe confirms pict_type = I for all frames.
//
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
// The frag-MOV index is not deferred to EOF, so the file stays readable while
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
//
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
hevc_nvenc: {
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
bitrateControl: true,
pixFmt: 'p010le',
},
};
const AUDIO_CODECS = {
pcm_s16le: { args: ['-c:a', 'pcm_s16le'], bitrateControl: false },
pcm_s24le: { args: ['-c:a', 'pcm_s24le'], bitrateControl: false },
pcm_s32le: { args: ['-c:a', 'pcm_s32le'], bitrateControl: false },
aac: { args: ['-c:a', 'aac'], bitrateControl: true },
ac3: { args: ['-c:a', 'ac3'], bitrateControl: true },
opus: { args: ['-c:a', 'libopus'], bitrateControl: true },
flac: { args: ['-c:a', 'flac'], bitrateControl: false },
};
const CONTAINER_FMT = {
mov: 'mov',
mp4: 'mp4',
mkv: 'matroska',
mxf: 'mxf',
ts: 'mpegts',
};
const CONTAINER_EXT = {
mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
};
function buildEncodeArgs({
codec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container, isNetwork, isProxy = false,
}) {
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
const args = [];
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
args.push(...v.args);
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
if (framerate && framerate !== 'native') args.push('-r', framerate);
args.push(...a.args);
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
if (audioChannels) args.push('-ac', String(audioChannels));
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+frag_keyframe+empty_moov');
}
args.push('-f', fmt);
return args;
}
class CaptureManager {
constructor() {
this.state = {
@ -11,6 +114,10 @@ class CaptureManager {
sessionId: null,
processes: {},
currentSession: {},
framesReceived: 0,
currentFps: 0,
lastFrameAt: null,
lastError: null,
};
}
@ -19,20 +126,19 @@ class CaptureManager {
* Returns { inputArgs, isNetwork }
* @private
*/
_buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
if (sourceType === 'srt') {
let url;
if (listen) {
const port = listenPort || 9000;
url = `srt://0.0.0.0:${port}?mode=listener`;
} else {
// Caller mode — ensure mode=caller is appended if not already present
url = sourceUrl;
if (!url.includes('mode=')) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
}
return { inputArgs: ['-i', url], isNetwork: true };
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
}
if (sourceType === 'rtmp') {
@ -40,33 +146,104 @@ class CaptureManager {
const port = listenPort || 1935;
const key = streamKey || 'stream';
return {
inputArgs: ['-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
isNetwork: true,
};
}
return { inputArgs: ['-i', sourceUrl], isNetwork: true };
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
}
// Deltacast SDI via VideoMaster SDK FFmpeg plugin.
// FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
// When the physical device is absent (/dev/deltacast<N> missing), fall back
// to a lavfi test card so development and integration testing work without hardware.
if (sourceType === 'deltacast') {
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
? parseInt(device, 10)
: 0;
const { existsSync } = await import('node:fs');
const deviceNode = `/dev/deltacast${idx}`;
if (existsSync(deviceNode)) {
console.log(`[capture] Deltacast index ${idx}${deviceNode} (hardware)`);
return {
inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
isNetwork: false,
};
} else {
// No hardware — lavfi test card with port label + timecode burn-in.
// Matches the deltacast-sdi-recorder standalone app fallback exactly so
// recorded files look right in the MAM library during dev.
console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
const testSrc = [
`testsrc2=size=1920x1080:rate=30`,
`drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
`drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
].join(',');
return {
inputArgs: [
'-f', 'lavfi', '-i', testSrc,
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
'-map', '0:v:0', '-map', '1:a:0',
],
isNetwork: false,
};
}
}
// Default: SDI via DeckLink
// device may be an integer index (0-based) or a full device name string.
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
// Map integer index -> name using ffmpeg -sources decklink at runtime.
//
// ffmpeg -sources decklink output format:
// Auto-detected sources for decklink:
// DeckLink Duo 2
// DeckLink Duo 2 (2)
// Lines containing device names start with whitespace; the header line
// starts with a non-space character. Previous code used a v4l2-style
// hex-address regex that never matched DeckLink output → index 1+ always
// fell through to a wrong fallback, producing black output from port 2+.
let deckLinkName = String(device);
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
const idx = parseInt(device, 10);
try {
const { execSync } = await import('child_process');
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const names = [];
for (const line of out.split('\n')) {
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (m) names.push(m[1]);
}
if (names[idx]) {
deckLinkName = names[idx];
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
} else {
// Fallback: cannot determine model name without enumeration.
// Log a warning — operator should check the detected device list.
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
deckLinkName = `DeckLink (${idx})`;
}
} catch (err) {
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
// Pass the numeric index directly; some ffmpeg builds accept it.
deckLinkName = String(device);
}
}
return {
inputArgs: ['-f', 'decklink', '-i', String(device)],
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
isNetwork: false,
};
}
/**
* Start a new capture session
* @param {Object} params
* - projectId, binId, clipName always required
* - device DeckLink device index (SDI only)
* - sourceType 'sdi' | 'srt' | 'rtmp' (default: 'sdi')
* - sourceUrl URL for caller mode (SRT/RTMP caller)
* - listen true for listener/server mode
* - listenPort port to bind in listener mode
* - streamKey RTMP stream key for listener mode
* @returns {Object} Session info
* Start a new capture session.
*
* Codec parameters all have sensible defaults so legacy callers (no codec
* args) still produce ProRes HQ master + H.264 proxy.
*/
async start({
assetId,
projectId,
binId,
clipName,
@ -76,96 +253,172 @@ class CaptureManager {
listen = false,
listenPort,
streamKey,
// ── Recording codec ─────────────────────────────────────────────
videoCodec = 'prores_hq',
videoBitrate = null,
framerate = null,
audioCodec = 'pcm_s24le',
audioBitrate = null,
audioChannels = 2,
container = 'mov',
// ── Proxy codec ─────────────────────────────────────────────────
proxyEnabled = true,
proxyVideoCodec = 'h264',
proxyVideoBitrate = '8M',
proxyFramerate = null,
proxyAudioCodec = 'aac',
proxyAudioBitrate = '192k',
proxyAudioChannels = 2,
proxyContainer = 'mp4',
}) {
this._assetIdForHls = assetId || null;
if (this.state.recording) {
throw new Error('Capture already in progress');
}
const sessionId = uuidv4();
const hiresKey = `projects/${projectId}/masters/${clipName}.mov`;
const hiresExt = CONTAINER_EXT[container] || 'mov';
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
// Network sources cannot be opened by two FFmpeg processes simultaneously.
// proxyKey is null for SRT/RTMP — the BullMQ worker generates the proxy
// after the recording stops (same pipeline used for uploaded files).
const proxyKey = sourceType === 'sdi'
? `projects/${projectId}/proxies/${clipName}.mp4`
// 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
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
: null;
if (growingPath) {
try { mkdirSync(dirname(growingPath), { recursive: true }); }
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
}
// DeckLink hardware does NOT support concurrent capture from the same port.
// Opening a second ffmpeg process on the same DeckLink input while the first
// is already capturing causes "Cannot Autodetect input stream or No signal"
// on the second process — making the proxy empty and potentially crashing the
// container before the hires upload completes.
//
// Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
// worker generate the proxy from the hires master after the recording stops.
// The stop handler sets needsProxy=true so the worker picks it up.
const proxyKey = null;
const startedAt = new Date().toISOString();
const { inputArgs, isNetwork } = this._buildInputArgs({
sourceType,
device,
sourceUrl,
listen,
listenPort,
streamKey,
const { inputArgs, isNetwork } = await this._buildInputArgs({
sourceType, device, sourceUrl, listen, listenPort, streamKey,
});
// ProRes hires — fragmented moov for pipe-safe output on network sources
const hiresCodecArgs = isNetwork
? [
'-c:v', 'prores_ks',
'-profile:v', '3',
'-c:a', 'pcm_s24le',
'-movflags', '+frag_keyframe+empty_moov',
'-f', 'mov',
]
: [
'-c:v', 'prores_ks',
'-profile:v', '3',
'-c:a', 'pcm_s24le',
'-f', 'mov',
];
const hiresCodecArgs = buildEncodeArgs({
codec: videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container,
isNetwork,
isProxy: false,
});
// Spawn hires FFmpeg process
const hiresProcess = spawn('ffmpeg', [
console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// When growing-files is on, write directly to the SMB share so Premier
// can mount and edit the live file. Promotion worker uploads to S3 on EOF.
// Otherwise, stream the master to S3 via stdout pipe (legacy behavior).
const hiresOutput = growingPath ? growingPath : 'pipe:1';
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
// For SDI we cannot open the DeckLink device a second time for a preview
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
let sdiHlsDir = null;
let hiresArgs;
if (sourceType === 'sdi' && this._assetIdForHls) {
const fsMod = await import('node:fs');
sdiHlsDir = '/live/' + this._assetIdForHls;
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
hiresArgs = [
...inputArgs,
'-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
// Output 0 — ProRes master (S3 pipe or growing file)
'-map', '[vhi]', '-map', '0:a:0?',
...hiresCodecArgs,
'pipe:1',
], {
stdio: ['ignore', 'pipe', 'pipe'],
});
hiresOutput,
// Output 1 — low-latency H.264 HLS preview for the UI monitor
'-map', '[vlo]', '-map', '0:a:0?',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
sdiHlsDir + '/index.m3u8',
];
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
} else {
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
}
const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
const hiresUpload = growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
const processes = { hires: hiresProcess };
const uploads = { hires: hiresUpload };
hiresProcess.stderr.on('data', (data) => {
console.error(`[HIRES] ${data}`);
});
// SDI only: spawn a second FFmpeg process for the proxy.
// DeckLink cards can be opened simultaneously by multiple processes;
// network streams cannot.
if (!isNetwork) {
const proxyProcess = spawn('ffmpeg', [
// ── HLS tee for network sources (live preview in the UI) ──────────
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && this._assetIdForHls) {
try {
const fs = await import('node:fs');
hlsDir = '/live/' + this._assetIdForHls;
fs.mkdirSync(hlsDir, { recursive: true });
const hlsArgs = [
...inputArgs,
'-c:v', 'libx264',
'-preset', 'fast',
'-b:v', '10M',
'-c:a', 'aac',
'-b:a', '192k',
'-movflags', '+frag_keyframe+empty_moov',
'-f', 'mp4',
'pipe:1',
], {
stdio: ['ignore', 'pipe', 'pipe'],
});
const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout);
processes.proxy = proxyProcess;
uploads.proxy = proxyUpload;
proxyProcess.stderr.on('data', (data) => {
console.error(`[PROXY] ${data}`);
});
'-map', '0:v:0?', '-map', '0:a:0?',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', hlsDir + '/seg-%05d.ts',
hlsDir + '/index.m3u8',
];
hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
processes.hls = hlsProcess;
console.log('[HLS] tee started -> ' + hlsDir);
} catch (err) {
console.error('[HLS] tee failed:', err.message);
}
}
hiresProcess.stderr.on('data', (data) => {
const text = data.toString();
console.error(`[HIRES] ${text}`);
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.currentFps = parseFloat(m[2]);
this.state.lastFrameAt = new Date().toISOString();
}
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
}
});
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
// DeckLink hardware does not support two concurrent readers on the same port.
this.state.recording = true;
this.state.sessionId = sessionId;
this.state.processes = processes;
this.state.framesReceived = 0;
this.state.currentFps = 0;
this.state.lastFrameAt = null;
this.state.lastError = null;
this.state.currentSession = {
sessionId,
projectId,
@ -176,19 +429,21 @@ class CaptureManager {
sourceUrl,
hiresKey,
proxyKey,
growingPath,
startedAt,
duration: 0,
uploads,
codecs: {
videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels, container,
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
},
};
return this._formatSessionResponse();
}
/**
* Stop the current capture session
* @param {string} sessionId - Session ID to stop
* @returns {Object} Completed session info
*/
async stop(sessionId) {
if (!this.state.recording || this.state.sessionId !== sessionId) {
throw new Error('No active capture session or session ID mismatch');
@ -196,20 +451,13 @@ class CaptureManager {
const { processes, currentSession } = this.state;
// Gracefully terminate all FFmpeg processes
if (processes.hires) {
processes.hires.kill('SIGINT');
}
if (processes.proxy) {
processes.proxy.kill('SIGINT');
}
if (processes.hires) processes.hires.kill('SIGINT');
if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
try {
// Wait for all in-flight S3 uploads to complete
const uploadPromises = [currentSession.uploads.hires];
if (currentSession.uploads.proxy) {
uploadPromises.push(currentSession.uploads.proxy);
}
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
await Promise.all(uploadPromises);
} catch (error) {
console.error('Error during upload completion:', error);
@ -220,11 +468,15 @@ class CaptureManager {
const stopTime = new Date(stoppedAt);
const duration = Math.round((stopTime - startTime) / 1000);
// Reset state
this.state.recording = false;
this.state.sessionId = null;
this.state.processes = {};
// No frames received → the upload (if any) produced a 0-byte object.
// Surface that so the shutdown handler can mark the asset as 'error'
// instead of posting a broken hi-res key downstream.
const framesReceived = this.state.framesReceived;
return {
sessionId,
projectId: currentSession.projectId,
@ -232,28 +484,31 @@ class CaptureManager {
clipName: currentSession.clipName,
sourceType: currentSession.sourceType,
hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey, // null for SRT/RTMP
proxyKey: currentSession.proxyKey,
growingPath: currentSession.growingPath || null,
startedAt: currentSession.startedAt,
stoppedAt,
duration,
framesReceived,
empty: framesReceived === 0,
};
}
/**
* Get current capture status
* @returns {Object} Current state
*/
getStatus() {
if (!this.state.recording) {
return {
recording: false,
};
}
if (!this.state.recording) return { recording: false };
const startTime = new Date(this.state.currentSession.startedAt);
const now = new Date();
const duration = Math.round((now - startTime) / 1000);
const lastFrameAt = this.state.lastFrameAt;
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
let signal = 'connecting';
if (this.state.framesReceived > 0) {
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
} else if (this.state.lastError) {
signal = 'error';
}
return {
recording: true,
sessionId: this.state.sessionId,
@ -264,13 +519,16 @@ class CaptureManager {
binId: this.state.currentSession.binId,
duration,
startedAt: this.state.currentSession.startedAt,
signal,
framesReceived: this.state.framesReceived,
currentFps: this.state.currentFps,
lastFrameAt,
msSinceFrame,
lastError: this.state.lastError,
codecs: this.state.currentSession.codecs,
};
}
/**
* Format session response
* @private
*/
_formatSessionResponse() {
const { currentSession, sessionId } = this.state;
return {
@ -283,8 +541,10 @@ class CaptureManager {
hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey,
startedAt: currentSession.startedAt,
codecs: currentSession.codecs,
};
}
}
export default new CaptureManager();
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };

View file

@ -2,25 +2,193 @@ import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import captureRoutes from './routes/capture.js';
import captureManager from './capture-manager.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
const MAM_API_TOKEN = process.env.MAM_API_TOKEN || '';
// Middleware
app.use(cors());
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// Routes
app.use('/capture', captureRoutes);
// Start server
app.listen(PORT, () => {
const server = app.listen(PORT, () => {
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
bootstrapAutoStart();
});
// Mapped from the env vars routes/recorders.js writes into the container.
// Empty strings collapse to undefined so capture-manager's defaults win.
function envOpt(name) {
const v = process.env[name];
return v === undefined || v === '' ? undefined : v;
}
function envInt(name) {
const v = envOpt(name);
if (v === undefined) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
function envBool(name) {
const v = envOpt(name);
if (v === undefined) return undefined;
return v === 'true' || v === '1' || v === 'yes';
}
async function bootstrapAutoStart() {
const recorderId = process.env.RECORDER_ID;
const sourceType = process.env.SOURCE_TYPE;
if (!recorderId || !sourceType) {
console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar');
return;
}
const projectId = process.env.PROJECT_ID;
const clipName = process.env.CLIP_NAME;
if (!projectId || !clipName) {
console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start');
return;
}
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
const listenPort = envInt('LISTEN_PORT');
const streamKey = envOpt('STREAM_KEY');
const sourceUrl = envOpt('SOURCE_URL');
const device = envInt('DEVICE_INDEX');
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
try {
const session = await captureManager.start({
assetId: envOpt('ASSET_ID') || null,
projectId,
binId: envOpt('BIN_ID') || null,
clipName,
device,
sourceType,
sourceUrl,
listen,
listenPort,
streamKey,
// Recording codec — recorders.js passes these straight through
videoCodec: envOpt('RECORDING_CODEC') || 'prores_hq',
videoBitrate: envOpt('RECORDING_VIDEO_BITRATE'),
framerate: envOpt('RECORDING_FRAMERATE'),
audioCodec: envOpt('RECORDING_AUDIO_CODEC') || 'pcm_s24le',
audioBitrate: envOpt('RECORDING_AUDIO_BITRATE'),
audioChannels: envInt('RECORDING_AUDIO_CHANNELS') ?? 2,
container: envOpt('RECORDING_CONTAINER') || 'mov',
proxyEnabled: envBool('PROXY_ENABLED') ?? true,
proxyVideoCodec: envOpt('PROXY_CODEC') || 'h264',
proxyVideoBitrate: envOpt('PROXY_VIDEO_BITRATE') || '8M',
proxyFramerate: envOpt('PROXY_FRAMERATE'),
proxyAudioCodec: envOpt('PROXY_AUDIO_CODEC') || 'aac',
proxyAudioBitrate: envOpt('PROXY_AUDIO_BITRATE') || '192k',
proxyAudioChannels: envInt('PROXY_AUDIO_CHANNELS') ?? 2,
proxyContainer: envOpt('PROXY_CONTAINER') || 'mp4',
});
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
} catch (err) {
console.error('[bootstrap] failed to start capture:', err);
}
}
let shuttingDown = false;
async function gracefulShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] ${signal} received`);
const status = captureManager.getStatus();
if (status.recording) {
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
try {
const completed = await captureManager.stop(status.sessionId);
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);
const liveAssetId = process.env.ASSET_ID || null;
// No frames received → the source never connected (bad SRT URL, dead
// SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
// point is 0 bytes and would just clog the proxy queue with "moov
// atom not found" failures. Mark the pre-created live asset as
// 'error' and skip the POST /assets registration entirely.
if (completed.empty) {
console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
if (liveAssetId) {
try {
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
});
} catch (e) {
console.error('[shutdown] failed to flag empty asset:', e.message);
}
}
} else if (liveAssetId) {
// Finalise the pre-created live asset by id (avoids POST / 409 collision).
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/finalize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
body: JSON.stringify({ hiresKey: completed.hiresKey, proxyKey: completed.proxyKey, duration: completed.duration }),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api finalize returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] live asset finalised with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to finalise asset:', mamErr.message);
}
} else {
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,
clipName: completed.clipName,
sourceType: completed.sourceType,
hiresKey: completed.hiresKey,
proxyKey: completed.proxyKey,
needsProxy: completed.proxyKey === null,
duration: completed.duration,
capturedAt: completed.startedAt,
}),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] asset registered with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to register asset:', mamErr.message);
}
}
} catch (err) {
console.error('[shutdown] error during stop:', err);
}
}
server.close(() => {
console.log('[shutdown] http server closed - exiting');
process.exit(0);
});
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View file

@ -1,7 +1,79 @@
import express from 'express';
import { execSync } from 'child_process';
import { execSync, spawn } from 'child_process';
import { existsSync, readdirSync } from 'node:fs';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
import net from 'net';
function parseUrl(u) {
try {
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
if (!m) return null;
return { host: m[1], port: parseInt(m[2] || '0', 10) };
} catch (_) { return null; }
}
async function checkReachable(host, port, sourceType) {
if (!port) return { ok: true };
if (sourceType === 'srt') return await udpSendProbe(host, port);
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
return { ok: true };
}
function udpSendProbe(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
sock.on('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
} else {
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
}
});
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
setTimeout(() => finish({ ok: true }), 1500);
});
}
function tcpConnectProbe(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
sock.setTimeout(2500);
sock.once('connect', () => finish({ ok: true }));
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
sock.once('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
});
sock.connect(port, host);
});
}
function classifyProbeError(raw, sourceType) {
const r = (raw || '').toLowerCase();
if (sourceType === 'srt') {
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
}
}
if (sourceType === 'rtmp') {
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
}
return raw;
}
const router = express.Router();
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
@ -16,7 +88,7 @@ router.get('/devices', (req, res) => {
let output = '';
try {
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
output = execSync('ffmpeg -sources decklink 2>&1', {
encoding: 'utf-8',
});
} catch (error) {
@ -24,13 +96,13 @@ router.get('/devices', (req, res) => {
output = error.stderr ? error.stderr.toString() : error.toString();
}
// Parse ffmpeg output for DeckLink device names
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
// Parse ffmpeg output for DeckLink device names.
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
const lines = output.split('\n');
let deviceIndex = 0;
for (const line of lines) {
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (match) {
devices.push({
index: deviceIndex,
@ -47,6 +119,57 @@ router.get('/devices', (req, res) => {
}
});
/**
* GET /devices/deltacast
* List available Deltacast ports.
* Reads /dev/deltacast<N> nodes; falls back to env DELTACAST_PORT_COUNT
* so nodes without hardware still report their configured port count
* (test-card mode).
*/
router.get('/devices/deltacast', (req, res) => {
try {
const devices = [];
// First: enumerate actual /dev/deltacast* device nodes.
try {
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
devEntries.sort();
for (const entry of devEntries) {
const m = entry.match(/^deltacast(\d+)$/);
if (m) {
devices.push({
index: parseInt(m[1], 10),
name: `Deltacast Port ${m[1]}`,
device: `/dev/${entry}`,
present: true,
});
}
}
} catch (_) { /* /dev always exists; ignore */ }
// Second: if DELTACAST_PORT_COUNT env is set and larger than what we found,
// fill in the remaining slots as test-card entries (no physical device).
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
const found = new Set(devices.map(d => d.index));
for (let i = 0; i < envCount; i++) {
if (!found.has(i)) {
devices.push({
index: i,
name: `Deltacast Port ${i} (test card)`,
device: `/dev/deltacast${i}`,
present: false,
});
}
}
devices.sort((a, b) => a.index - b.index);
res.json({ devices });
} catch (error) {
console.error('Error listing Deltacast devices:', error);
res.status(500).json({ error: 'Failed to list Deltacast devices' });
}
});
/**
* GET /status
* Get current capture status
@ -60,6 +183,103 @@ router.get('/status', (req, res) => {
res.status(500).json({ error: 'Failed to get status' });
}
});
router.post('/probe', async (req, res) => {
try {
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
if (source_type === 'sdi') {
try {
const raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const devices = [];
for (const line of raw.split('\n')) {
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (m) devices.push(m[1]);
}
return res.json({ ok: true, source_type, devices });
} catch (err) {
const out = (err.stderr || err.stdout || err.toString()).toString();
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
}
}
if (source_type === 'deltacast') {
// Enumerate /dev/deltacast* nodes; report present/absent per index.
try {
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
const found = devEntries.map(n => {
const m = n.match(/^deltacast(\d+)$/);
return { index: parseInt(m[1], 10), device: `/dev/${n}`, present: true };
});
const foundIdx = new Set(found.map(d => d.index));
for (let i = 0; i < envCount; i++) {
if (!foundIdx.has(i)) {
found.push({ index: i, device: `/dev/deltacast${i}`, present: false });
}
}
found.sort((a, b) => a.index - b.index);
return res.json({ ok: true, source_type, devices: found });
} catch (err) {
return res.json({ ok: false, source_type, error: err.message });
}
}
if (listen) {
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
}
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
// an actionable error instead of the opaque libsrt "Input/output error".
const parsed = parseUrl(source_url);
if (!parsed) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
}
const reach = await checkReachable(parsed.host, parsed.port, source_type);
if (!reach.ok) {
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
}
let url = source_url;
if (source_type === 'srt' && !/mode=/.test(url)) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
const ff = spawn('ffprobe', args);
let stdout = '', stderr = '';
ff.stdout.on('data', (c) => { stdout += c; });
ff.stderr.on('data', (c) => { stderr += c; });
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
ff.on('close', (code) => {
clearTimeout(killer);
if (code !== 0) {
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
const friendly = classifyProbeError(rawErr, source_type);
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
}
try {
const parsed = JSON.parse(stdout);
const streams = (parsed.streams || []).map(s => ({
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
sample_rate: s.sample_rate, channels: s.channels,
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
}));
return res.json({ ok: true, source_type, source_url,
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
streams });
} catch (err) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
}
});
} catch (error) {
console.error('Probe error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /start

View file

@ -0,0 +1,330 @@
import express from 'express';
import { execSync, spawn } from 'child_process';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
import net from 'net';
function parseUrl(u) {
try {
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
if (!m) return null;
return { host: m[1], port: parseInt(m[2] || '0', 10) };
} catch (_) { return null; }
}
async function checkReachable(host, port, sourceType) {
if (!port) return { ok: true };
if (sourceType === 'srt') return await udpSendProbe(host, port);
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
return { ok: true };
}
function udpSendProbe(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
sock.on('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
} else {
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
}
});
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
setTimeout(() => finish({ ok: true }), 1500);
});
}
function tcpConnectProbe(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
sock.setTimeout(2500);
sock.once('connect', () => finish({ ok: true }));
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
sock.once('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
});
sock.connect(port, host);
});
}
function classifyProbeError(raw, sourceType) {
const r = (raw || '').toLowerCase();
if (sourceType === 'srt') {
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
}
}
if (sourceType === 'rtmp') {
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
}
return raw;
}
const router = express.Router();
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
/**
* GET /devices
* List available DeckLink devices
*/
router.get('/devices', (req, res) => {
try {
const devices = [];
let output = '';
try {
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
encoding: 'utf-8',
});
} catch (error) {
// ffmpeg returns non-zero, but stderr is still captured
output = error.stderr ? error.stderr.toString() : error.toString();
}
// Parse ffmpeg output for DeckLink device names
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
const lines = output.split('\n');
let deviceIndex = 0;
for (const line of lines) {
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
if (match) {
devices.push({
index: deviceIndex,
name: match[1],
});
deviceIndex++;
}
}
res.json({ devices });
} catch (error) {
console.error('Error listing devices:', error);
res.status(500).json({ error: 'Failed to list devices' });
}
});
/**
* GET /status
* Get current capture status
*/
router.get('/status', (req, res) => {
try {
const status = captureManager.getStatus();
res.json(status);
} catch (error) {
console.error('Error getting status:', error);
res.status(500).json({ error: 'Failed to get status' });
}
});
router.post('/probe', async (req, res) => {
try {
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
if (source_type === 'sdi') {
try {
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
const devices = [];
for (const line of raw.split('\n')) {
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
if (m) devices.push(m[1]);
}
return res.json({ ok: true, source_type, devices });
} catch (err) {
const out = (err.stderr || err.stdout || err.toString()).toString();
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
}
}
if (listen) {
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
}
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
// an actionable error instead of the opaque libsrt "Input/output error".
const parsed = parseUrl(source_url);
if (!parsed) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
}
const reach = await checkReachable(parsed.host, parsed.port, source_type);
if (!reach.ok) {
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
}
let url = source_url;
if (source_type === 'srt' && !/mode=/.test(url)) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
const ff = spawn('ffprobe', args);
let stdout = '', stderr = '';
ff.stdout.on('data', (c) => { stdout += c; });
ff.stderr.on('data', (c) => { stderr += c; });
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
ff.on('close', (code) => {
clearTimeout(killer);
if (code !== 0) {
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
const friendly = classifyProbeError(rawErr, source_type);
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
}
try {
const parsed = JSON.parse(stdout);
const streams = (parsed.streams || []).map(s => ({
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
sample_rate: s.sample_rate, channels: s.channels,
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
}));
return res.json({ ok: true, source_type, source_url,
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
streams });
} catch (err) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
}
});
} catch (error) {
console.error('Probe error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /start
* Start a new capture session
*
* Body (SDI):
* { project_id, clip_name, device, bin_id?, source_type? }
*
* Body (SRT/RTMP caller):
* { project_id, clip_name, source_type, source_url, bin_id? }
*
* Body (SRT/RTMP listener):
* { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? }
*/
router.post('/start', async (req, res) => {
try {
const {
project_id,
bin_id,
clip_name,
device,
source_type = 'sdi',
source_url,
listen = false,
listen_port,
stream_key,
} = req.body;
if (!project_id || !clip_name) {
return res.status(400).json({
error: 'Missing required fields: project_id, clip_name',
});
}
// Source-specific validation
if (source_type === 'sdi') {
if (device === undefined || device === null) {
return res.status(400).json({ error: 'SDI source requires: device' });
}
} else if (source_type === 'srt' || source_type === 'rtmp') {
if (!listen && !source_url) {
return res.status(400).json({
error: `${source_type.toUpperCase()} caller mode requires: source_url`,
});
}
} else {
return res.status(400).json({
error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`,
});
}
const session = await captureManager.start({
projectId: project_id,
binId: bin_id || null,
clipName: clip_name,
device,
sourceType: source_type,
sourceUrl: source_url,
listen,
listenPort: listen_port,
streamKey: stream_key,
});
res.json(session);
} catch (error) {
console.error('Error starting capture:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /stop
* Stop the current capture session
* Body: { session_id }
*/
router.post('/stop', async (req, res) => {
try {
const { session_id } = req.body;
if (!session_id) {
return res.status(400).json({ error: 'Missing required field: session_id' });
}
const completedSession = await captureManager.stop(session_id);
// Register asset with mam-api.
// If proxyKey is null (SRT/RTMP source), set needsProxy=true so the
// worker generates a proxy from the hires file asynchronously.
try {
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completedSession.projectId,
binId: completedSession.binId,
clipName: completedSession.clipName,
sourceType: completedSession.sourceType,
hiresKey: completedSession.hiresKey,
proxyKey: completedSession.proxyKey,
needsProxy: completedSession.proxyKey === null,
duration: completedSession.duration,
capturedAt: completedSession.startedAt,
}),
});
if (!mamResponse.ok) {
console.warn(
`MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`,
);
}
} catch (mamError) {
console.warn('Failed to register asset with MAM API:', mamError.message);
}
res.json(completedSession);
} catch (error) {
console.error('Error stopping capture:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View file

@ -1,4 +1,8 @@
FROM node:22-alpine
FROM node:22-slim
# unzip/tar needed for SDK upload extraction (see routes/sdk.js)
RUN apt-get update \
&& apt-get install -y --no-install-recommends unzip tar ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev

2992
services/mam-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,10 +2,12 @@
"name": "wild-dragon-mam-api",
"version": "0.1.0",
"description": "Media Asset Management API for Wild Dragon",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
"dev": "node --watch src/index.js",
"test": "node --test $(find test -name '*.test.js' | sort)"
},
"dependencies": {
"express": "^4.18.2",
@ -20,7 +22,9 @@
"bullmq": "^5.5.0",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.1",
"dotenv": "^16.4.5"
"dotenv": "^16.4.5",
"qrcode": "^1.5.4",
"google-auth-library": "^9.14.0"
},
"engines": {
"node": ">=22.0.0"

View file

@ -0,0 +1,90 @@
// Per-project authorization — the single source of truth for "can this user
// touch this project?". v1 auth answers "are you logged in?"; this answers
// "which projects, and at what level?".
//
// Model (locked with Zac):
// - role 'admin' → global bypass; every project at 'edit'.
// - role 'editor'/'viewer' → scoped to projects granted to them directly
// (project_access subject_type='user') or via a
// group they belong to (subject_type='group').
// - grant level 'view' → read-only; 'edit' → read-write.
//
// A user's effective level on a project is the MAX of every matching grant
// (direct + each group). 'edit' outranks 'view'.
//
// All functions take an optional `db` (defaults to the shared pool) so tests
// can inject an isolated test pool.
import defaultPool from '../db/pool.js';
const LEVEL_RANK = { view: 1, edit: 2 };
export function isAdmin(user) {
return user?.role === 'admin';
}
// Returns the higher of two levels (either may be null/undefined).
function maxLevel(a, b) {
const ra = LEVEL_RANK[a] || 0;
const rb = LEVEL_RANK[b] || 0;
if (ra === 0 && rb === 0) return null;
return ra >= rb ? a : b;
}
// Resolve every project the user can see, with their effective level.
// admin → { all: true, ids: null, levelByProject: null }
// else → { all: false, ids: Set<projectId>, levelByProject: Map<projectId, 'view'|'edit'> }
export async function accessibleProjectIds(user, db = defaultPool) {
if (isAdmin(user)) return { all: true, ids: null, levelByProject: null };
const levelByProject = new Map();
if (!user?.id) return { all: false, ids: new Set(), levelByProject };
const { rows } = await db.query(
`SELECT pa.project_id, pa.level
FROM project_access pa
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
OR (pa.subject_type = 'group' AND pa.subject_id IN (
SELECT group_id FROM user_groups WHERE user_id = $1
))`,
[user.id]
);
for (const r of rows) {
levelByProject.set(r.project_id, maxLevel(levelByProject.get(r.project_id), r.level));
}
return { all: false, ids: new Set(levelByProject.keys()), levelByProject };
}
// Effective level on a single project: 'edit' | 'view' | null.
export async function projectLevel(user, projectId, db = defaultPool) {
if (isAdmin(user)) return 'edit';
if (!user?.id || !projectId) return null;
const { rows } = await db.query(
`SELECT pa.level
FROM project_access pa
WHERE pa.project_id = $1
AND ( (pa.subject_type = 'user' AND pa.subject_id = $2)
OR (pa.subject_type = 'group' AND pa.subject_id IN (
SELECT group_id FROM user_groups WHERE user_id = $2
)) )`,
[projectId, user.id]
);
let level = null;
for (const r of rows) level = maxLevel(level, r.level);
return level;
}
// Throw a 403-shaped error (caught by errorHandler) unless the user has at
// least `need` access on the project. `need` ∈ 'view' | 'edit'.
export async function assertProjectAccess(user, projectId, need = 'view', db = defaultPool) {
if (isAdmin(user)) return;
const have = await projectLevel(user, projectId, db);
if (!have || (LEVEL_RANK[have] || 0) < (LEVEL_RANK[need] || 0)) {
const err = new Error('forbidden');
err.status = 403;
throw err;
}
}

View file

@ -0,0 +1,90 @@
// Google OAuth (OIDC) sign-in helpers.
//
// Entirely config-gated: if GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET /
// OAUTH_REDIRECT_URL aren't set, isConfigured() is false and the routes 404, so
// a deployment without Google SSO behaves exactly as before. google-auth-library
// is imported lazily so the dependency is only required when the feature is on.
//
// Flow: /auth/google redirects to Google's consent screen with a signed `state`;
// /auth/google/callback exchanges the code, verifies the ID token, enforces the
// allowed Workspace domain, and auto-provisions a viewer account on first login.
const SCOPES = ['openid', 'email', 'profile'];
export function isConfigured() {
return !!(process.env.GOOGLE_CLIENT_ID
&& process.env.GOOGLE_CLIENT_SECRET
&& process.env.OAUTH_REDIRECT_URL);
}
export function allowedDomain() {
return (process.env.GOOGLE_ALLOWED_DOMAIN || '').trim().toLowerCase() || null;
}
// Lazily build an OAuth2 client (throws a clear error if the dep is missing).
async function makeClient() {
let OAuth2Client;
try {
({ OAuth2Client } = await import('google-auth-library'));
} catch {
const err = new Error('google-auth-library is not installed');
err.status = 500;
throw err;
}
return new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.OAUTH_REDIRECT_URL,
});
}
// URL of Google's consent screen. `state` is an opaque anti-CSRF token we also
// stash in the session and re-check on callback.
export async function buildAuthUrl(state) {
const client = await makeClient();
return client.generateAuthUrl({
access_type: 'online',
scope: SCOPES,
state,
prompt: 'select_account',
// If a Workspace domain is configured, hint Google to scope the picker to it.
...(allowedDomain() ? { hd: allowedDomain() } : {}),
});
}
// Exchange the authorization code and verify the returned ID token. Returns the
// verified { sub, email, name, hd } payload. Throws { status } on any failure.
export async function exchangeAndVerify(code) {
const client = await makeClient();
const { tokens } = await client.getToken(code);
if (!tokens.id_token) {
const err = new Error('no id_token from Google'); err.status = 401; throw err;
}
const ticket = await client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const p = ticket.getPayload();
if (!p || !p.sub) {
const err = new Error('invalid id_token'); err.status = 401; throw err;
}
// Require an explicitly verified email — a missing/undefined claim is NOT
// treated as verified, since the email drives account linking/provisioning.
if (!p.email || p.email_verified !== true) {
const err = new Error('email not verified'); err.status = 403; throw err;
}
const domain = allowedDomain();
if (domain) {
// ONLY trust Google's `hd` (hosted-domain) claim — it's present iff the
// account is a member of a Google Workspace domain that Google itself
// has verified. The email-suffix fallback we used to allow let any
// non-Workspace account with a spoof-friendly email through; if a
// GOOGLE_ALLOWED_DOMAIN is set, the operator means "only this Workspace,"
// and consumer accounts (no hd) must be rejected.
const hd = (p.hd || '').toLowerCase();
if (hd !== domain) {
const err = new Error('domain not allowed'); err.status = 403; throw err;
}
}
return { sub: p.sub, email: p.email, name: p.name || p.email, hd: p.hd || null };
}

View file

@ -0,0 +1,58 @@
// Short-lived MFA tickets bridging the two login steps.
//
// When a user with TOTP enabled passes password auth, we don't create a session
// yet — we hand back an opaque ticket. The second request (code or recovery
// code) redeems the ticket to finish login. Tickets are single-use and expire
// fast so a stolen ticket is near-useless.
//
// Tickets are bound to the issuing request's IP and User-Agent (hashed). A
// stolen ticket replayed from a different origin redeems to null. This is
// defense in depth against ticket exfiltration via a logged proxy, browser
// extension, or shoulder-surf; it does not stop an attacker who is on the same
// IP and UA.
//
// In-memory + single-instance, matching the existing login rate-limiter
// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment
// the second step must hit the same node. Acceptable for Dragonflight's
// one-mam-api-per-node shape; revisit if that changes.
import { randomBytes, createHash } from 'node:crypto';
const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code
const tickets = new Map(); // id -> { userId, ipHash, uaHash, expiresAt }
function sweep() {
const now = Date.now();
for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id);
}
function hashBinding(value) {
return createHash('sha256').update(String(value || '')).digest('hex');
}
export function issueTicket(userId, { ip, userAgent } = {}) {
sweep();
const id = randomBytes(32).toString('hex');
tickets.set(id, {
userId,
ipHash: hashBinding(ip),
uaHash: hashBinding(userAgent),
expiresAt: Date.now() + TTL_MS,
});
return id;
}
// Redeem (and consume) a ticket. Returns the userId, or null if missing,
// expired, or the binding doesn't match the redeeming request.
export function redeemTicket(id, { ip, userAgent } = {}) {
if (!id) return null;
const t = tickets.get(id);
if (!t) return null;
tickets.delete(id); // single-use — burn even on binding mismatch so a
// wrong-binding probe can't be retried.
if (t.expiresAt <= Date.now()) return null;
// If a caller doesn't supply bindings (e.g. tests), accept — the issue side
// controls whether bindings get recorded.
if (ip !== undefined && t.ipHash !== hashBinding(ip)) return null;
if (userAgent !== undefined && t.uaHash !== hashBinding(userAgent)) return null;
return t.userId;
}

View file

@ -0,0 +1,19 @@
// Thin bcrypt wrapper. Cost 12 per the spec (NIST SP 800-63B-friendly).
// comparePassword must never throw on a malformed hash — that path is hit
// by the seeded dev user's placeholder hash and by any partially-imported
// row. Throwing here would 500 on a wrong-password attempt.
import bcrypt from 'bcrypt';
const COST = 12;
export async function hashPassword(plain) {
return bcrypt.hash(plain, COST);
}
export async function comparePassword(plain, hash) {
try {
return await bcrypt.compare(plain, hash);
} catch {
return false;
}
}

View file

@ -0,0 +1,24 @@
// Per-IP exponential backoff for /auth/login. Single-instance — fine for
// Dragonflight's deployment shape (one mam-api per node). Documented limitation.
const failures = new Map(); // ip -> count
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
const MAX_ENTRIES = 10000; // bounded to prevent unbounded growth under spray attacks
export const ipBackoff = {
delayMs(ip) {
const n = failures.get(ip) || 0;
if (n === 0) return 0;
return STEPS[Math.min(n - 1, STEPS.length - 1)];
},
recordFailure(ip) {
// Evict the oldest entry if we're at the cap. Map preserves insertion order,
// so .keys().next().value is the oldest.
if (failures.size >= MAX_ENTRIES && !failures.has(ip)) {
failures.delete(failures.keys().next().value);
}
failures.set(ip, (failures.get(ip) || 0) + 1);
},
recordSuccess(ip) { failures.delete(ip); },
reset(ip) { failures.delete(ip); },
};

View file

@ -0,0 +1,22 @@
import { randomBytes, createHash } from 'node:crypto';
const PREFIX = 'dfl_';
export function generateToken() {
return PREFIX + randomBytes(32).toString('hex');
}
export function hashToken(token) {
return createHash('sha256').update(token).digest('hex');
}
export function parseBearer(authorizationHeader) {
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
return m ? m[1] : null;
}
export const TOKEN_PREFIX_DISPLAY_LEN = 8; // for api_tokens.token_prefix
export function tokenDisplayPrefix(token) {
return token.slice(0, TOKEN_PREFIX_DISPLAY_LEN);
}

View file

@ -0,0 +1,118 @@
// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
//
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
// in test/auth/totp.test.js.
//
// Defaults match every mainstream authenticator app (Google Authenticator,
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const DIGITS = 6;
const STEP_SECONDS = 30;
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
export function base32Encode(buf) {
let bits = 0, value = 0, out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(str) {
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
let bits = 0, value = 0;
const out = [];
for (const ch of clean) {
const idx = RFC4648_B32.indexOf(ch);
if (idx === -1) continue; // skip stray chars
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
// SHA-1 key length).
export function generateSecret() {
return base32Encode(randomBytes(20));
}
// HOTP for a specific counter (RFC 4226).
function hotp(secretBuf, counter) {
const buf = Buffer.alloc(8);
// 64-bit big-endian counter.
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
buf.writeUInt32BE(counter >>> 0, 4);
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const code = ((hmac[offset] & 0x7f) << 24)
| ((hmac[offset + 1] & 0xff) << 16)
| ((hmac[offset + 2] & 0xff) << 8)
| (hmac[offset + 3] & 0xff);
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
}
// The TOTP code for a given time (defaults to now).
export function generateToken(base32Secret, atMs = Date.now()) {
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
return hotp(base32Decode(base32Secret), counter);
}
// Verify a user-supplied code, allowing ±`window` steps of clock drift
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
//
// Returns the matched counter on success (so callers can persist it for
// replay protection — RFC 6238 §5.2), or null on failure. Boolean truthiness
// still works for the common case (`if (verifyToken(...))`).
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
if (!base32Secret || !token) return null;
const cleaned = String(token).replace(/\s+/g, '');
if (!/^\d{6}$/.test(cleaned)) return null;
const secretBuf = base32Decode(base32Secret);
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
const want = Buffer.from(cleaned);
for (let w = -window; w <= window; w++) {
const candidate = Buffer.from(hotp(secretBuf, counter + w));
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return counter + w;
}
return null;
}
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
const label = encodeURIComponent(`${issuer}:${accountName}`);
const params = new URLSearchParams({
secret: base32Secret,
issuer,
algorithm: 'SHA1',
digits: String(DIGITS),
period: String(STEP_SECONDS),
});
return `otpauth://totp/${label}?${params.toString()}`;
}
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
// them before storage and shows the raw set to the user exactly once.
export function generateRecoveryCodes(n = 10) {
const codes = [];
for (let i = 0; i < n; i++) {
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
const hex = randomBytes(5).toString('hex');
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
}
return codes;
}

View file

@ -0,0 +1,7 @@
-- 2026-05: add 'live' to asset_status for growing-file ingest
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'live' AND enumtypid = 'asset_status'::regtype) THEN
ALTER TYPE asset_status ADD VALUE 'live' BEFORE 'ingesting';
END IF;
END $$;

View file

@ -0,0 +1,36 @@
-- Wild Dragon MAM Groups & API Tokens
-- Idempotent: safe to re-run (IF NOT EXISTS guards throughout)
-- User groups
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User ↔ group memberships
CREATE TABLE IF NOT EXISTS user_groups (
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
-- Personal API tokens (Bearer auth alternative to session cookies)
-- token_hash : SHA-256(raw_token) stored as hex
-- token_prefix: first 8 chars of raw token for display only
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_user_groups_user ON user_groups(user_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);

View file

@ -0,0 +1,74 @@
-- Wild Dragon MAM Editor sequences
-- Idempotent: safe to re-run (IF NOT EXISTS / DO $$ BEGIN guards throughout)
-- Named timelines within a project (multiple per project, like Premiere)
CREATE TABLE IF NOT EXISTS sequences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Sequence 1',
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
width INTEGER NOT NULL DEFAULT 1920,
height INTEGER NOT NULL DEFAULT 1080,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sequences_project_id ON sequences(project_id);
CREATE INDEX IF NOT EXISTS idx_sequences_updated_at ON sequences(updated_at DESC);
-- Clips placed on a sequence timeline
CREATE TABLE IF NOT EXISTS sequence_clips (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
track INTEGER NOT NULL DEFAULT 0 CHECK (track >= 0),
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
timeline_in_frames BIGINT NOT NULL,
timeline_out_frames BIGINT NOT NULL,
source_in_frames BIGINT NOT NULL DEFAULT 0,
source_out_frames BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_track_position ON sequence_clips(sequence_id, track, timeline_in_frames);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);
-- Unique sequence name per project
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_sequences_project_name' AND conrelid = 'sequences'::regclass
) THEN
ALTER TABLE sequences ADD CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name);
END IF;
END $$;
-- Timeline range constraints
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_timeline_range' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_source_range' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_track_valid' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_track_valid CHECK (track >= 0);
END IF;
END $$;

View file

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS cluster_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hostname TEXT NOT NULL,
ip_address TEXT,
role TEXT NOT NULL DEFAULT 'worker',
version TEXT,
api_url TEXT,
cpu_usage NUMERIC(5,2),
mem_used_mb INTEGER,
mem_total_mb INTEGER,
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB,
CONSTRAINT cluster_nodes_hostname_uq UNIQUE (hostname)
);

View file

@ -0,0 +1,4 @@
-- Add hardware capabilities column to cluster_nodes
-- Stores GPUs and capture cards detected/reported by node-agent
ALTER TABLE cluster_nodes
ADD COLUMN IF NOT EXISTS capabilities JSONB DEFAULT '{}';

View file

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View file

@ -0,0 +1,20 @@
-- 007 — De-duplicate cluster_nodes by hostname and enforce uniqueness.
--
-- Migration 004 created the table with `CREATE TABLE IF NOT EXISTS` and an
-- inline UNIQUE constraint; on deploys where the table predated 004 the
-- constraint was never applied, which let the same hostname accumulate
-- multiple rows (one per container restart in some setups).
--
-- This migration:
-- 1. Deletes older duplicates keeping only the most-recently-seen row
-- per hostname.
-- 2. Adds a UNIQUE INDEX on (hostname) which is idempotent and satisfies
-- the ON CONFLICT (hostname) upsert in routes/cluster.js.
DELETE FROM cluster_nodes a
USING cluster_nodes b
WHERE a.hostname = b.hostname
AND a.last_seen < b.last_seen;
CREATE UNIQUE INDEX IF NOT EXISTS cluster_nodes_hostname_uniq
ON cluster_nodes (hostname);

View file

@ -0,0 +1,26 @@
-- 008 — Extended codec controls for recorders.
--
-- Adds video bitrate, framerate, audio codec / bitrate / channels, and
-- container format columns to recorders so the UI can offer granular
-- control instead of the four-options dropdown. capture-manager.js reads
-- these via env vars and builds ffmpeg args from them.
ALTER TABLE recorders
ADD COLUMN IF NOT EXISTS recording_video_bitrate TEXT,
ADD COLUMN IF NOT EXISTS recording_framerate TEXT,
ADD COLUMN IF NOT EXISTS recording_audio_codec TEXT DEFAULT 'pcm_s24le',
ADD COLUMN IF NOT EXISTS recording_audio_bitrate TEXT,
ADD COLUMN IF NOT EXISTS recording_audio_channels INTEGER DEFAULT 2,
ADD COLUMN IF NOT EXISTS recording_container TEXT DEFAULT 'mov',
ADD COLUMN IF NOT EXISTS proxy_video_bitrate TEXT DEFAULT '8M',
ADD COLUMN IF NOT EXISTS proxy_framerate TEXT,
ADD COLUMN IF NOT EXISTS proxy_audio_codec TEXT DEFAULT 'aac',
ADD COLUMN IF NOT EXISTS proxy_audio_bitrate TEXT DEFAULT '192k',
ADD COLUMN IF NOT EXISTS proxy_audio_channels INTEGER DEFAULT 2,
ADD COLUMN IF NOT EXISTS proxy_container TEXT DEFAULT 'mp4',
ADD COLUMN IF NOT EXISTS node_id UUID,
ADD COLUMN IF NOT EXISTS device_index INTEGER;
-- node_id is the cluster_nodes.id the recorder is pinned to (for SDI
-- recorders this is the node hosting the DeckLink card). device_index is
-- the DeckLink port index on that node.

View file

@ -0,0 +1,32 @@
-- Recorder schedules
--
-- Lets operators schedule a recorder to start at a future time and stop
-- after a duration. The scheduler tick loop in mam-api (src/scheduler.js)
-- watches this table every 15s and triggers the existing /recorders/:id
-- start + stop endpoints when each schedule's window opens or closes.
--
-- recurrence: 'none' (one-shot) or 'daily' for the MVP. When a 'daily'
-- schedule completes, the tick loop clones it forward by 24h.
CREATE TABLE IF NOT EXISTS recorder_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
recorder_id UUID NOT NULL REFERENCES recorders(id) ON DELETE CASCADE,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
recurrence TEXT NOT NULL DEFAULT 'none',
status TEXT NOT NULL DEFAULT 'pending',
last_asset_id UUID,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_at > start_at),
CHECK (recurrence IN ('none','daily','weekly')),
CHECK (status IN ('pending','running','completed','failed','cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_recorder_schedules_status_start
ON recorder_schedules (status, start_at);
CREATE INDEX IF NOT EXISTS idx_recorder_schedules_recorder
ON recorder_schedules (recorder_id);

View file

@ -0,0 +1,22 @@
-- Asset comments — frame-anchored notes on the Asset Detail page.
--
-- Comments are scoped to an asset and optionally to a timecode within that
-- asset. `frame_ms` is the playhead position when the comment was posted.
-- `resolved` lets editors hide rolled-up notes once addressed.
--
-- User ID is optional (nullable) so comments still attach when AUTH_ENABLED
-- is off and there's no real session user.
CREATE TABLE IF NOT EXISTS asset_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL,
frame_ms INTEGER,
resolved BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_asset_comments_asset
ON asset_comments (asset_id, created_at);

View file

@ -0,0 +1,15 @@
-- 2026-05: YouTube importer — new job type + remember source URL on assets.
--
-- Job type enum gains 'youtube_import' so the Jobs screen can show imports
-- alongside proxy / thumbnail / conform. Assets gain source_url so an
-- imported asset remembers where it came from (used by the Asset Detail
-- page and, later, dedup checks).
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'youtube_import' AND enumtypid = 'job_type'::regtype) THEN
ALTER TYPE job_type ADD VALUE 'youtube_import';
END IF;
END $$;
ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT;

View file

@ -0,0 +1,31 @@
-- 2026-05: Advanced features — trim jobs, temp segments, conform tracking.
-- Idempotent: safe to re-run (IF NOT EXISTS / DO $$ BEGIN guards throughout).
-- 1. Add 'trim' to job_type enum
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'trim' AND enumtypid = 'job_type'::regtype) THEN
ALTER TYPE job_type ADD VALUE 'trim';
END IF;
END $$;
-- 2. Temp segments table — tracks per-clip trimmed hi-res segments
-- with a 24-hour TTL for auto-cleanup.
CREATE TABLE IF NOT EXISTS temp_segments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
clip_instance_id UUID NOT NULL,
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
s3_key TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_temp_segments_expires_at ON temp_segments(expires_at);
CREATE INDEX IF NOT EXISTS idx_temp_segments_job_id ON temp_segments(job_id);
CREATE INDEX IF NOT EXISTS idx_temp_segments_asset_id ON temp_segments(asset_id);
-- 3. Asset conform tracking — remember which sequence this asset was
-- conformed from so the UI can show lineage and prevent double-conform.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS conform_source_sequence_id UUID REFERENCES sequences(id);
CREATE INDEX IF NOT EXISTS idx_assets_conform_source ON assets(conform_source_sequence_id);

View file

@ -0,0 +1,3 @@
-- 2026-05: Add missing updated_at column to bins table.
-- The INSERT and PATCH handlers already reference updated_at.
ALTER TABLE bins ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();

View file

@ -0,0 +1,7 @@
-- Migration 014: Per-recorder growing_enabled override
-- Adds a nullable boolean to the recorders table so each recorder can
-- independently override the global growing_enabled setting. NULL means
-- "use global"; TRUE/FALSE means "force on/off for this recorder".
ALTER TABLE recorders
ADD COLUMN IF NOT EXISTS growing_enabled BOOLEAN DEFAULT NULL;

View file

@ -0,0 +1,6 @@
-- Migration 015: Add growing_retention_days to settings table
-- Default 30 days. ON CONFLICT DO NOTHING is idempotent -- safe to re-run.
INSERT INTO settings (key, value)
VALUES ('growing_retention_days', '30')
ON CONFLICT (key) DO NOTHING;

View file

@ -0,0 +1,37 @@
-- Migration 016: Fix job_type/job_status enums and add jobs TTL (#75, #70)
--
-- 1. Add 'proxy' and 'import' to job_type so queue names match enum values.
-- 2. Add 'completed' to job_status to match the trimWorker status string.
-- 3. Add expires_at column to jobs so stale trim rows auto-expire (#70).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'proxy' AND enumtypid = 'job_type'::regtype
) THEN
ALTER TYPE job_type ADD VALUE 'proxy';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'import' AND enumtypid = 'job_type'::regtype
) THEN
ALTER TYPE job_type ADD VALUE 'import';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'completed' AND enumtypid = 'job_status'::regtype
) THEN
ALTER TYPE job_status ADD VALUE 'completed';
END IF;
END $$;
-- Add TTL column to jobs (NULL = no expiry; trim jobs set 24h from creation)
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ DEFAULT NULL;
-- Backfill: give any existing trim rows a 24h TTL from their creation time
UPDATE jobs SET expires_at = created_at + INTERVAL '24 hours'
WHERE type = 'trim' AND expires_at IS NULL;

View file

@ -0,0 +1,13 @@
-- Migration 017: Partial unique index on live assets (#63)
--
-- Prevents two simultaneous captures from registering the same
-- (project_id, display_name) pair with status='live'. The INSERT in
-- POST /assets will fail with a unique-constraint violation instead of
-- silently overwriting the first capture's metadata.
--
-- Only applies to live rows — archived, ready, error etc. are unaffected,
-- so duplicate names are still allowed across historical recordings.
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_live_unique
ON assets (project_id, display_name)
WHERE status = 'live';

View file

@ -0,0 +1,7 @@
-- Migration 018: Add filmstrip_s3_key to assets
-- Stores the S3 path to a JSON array of base64 JPEG frames generated
-- server-side by the filmstrip worker. Allows the UI to fetch a pre-built
-- filmstrip instead of seeking through the proxy in the browser.
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS filmstrip_s3_key TEXT DEFAULT NULL;

View file

@ -0,0 +1,15 @@
-- Issue #106 — bind cluster tokens to a specific hostname so a compromised
-- worker token can't be used to hijack another node's `api_url` via
-- POST /cluster/heartbeat.
--
-- `bound_hostname` is NULL for ordinary user tokens (no binding) and set
-- to the node's hostname for node-agent tokens. The heartbeat handler
-- checks that body.hostname === token.bound_hostname when bound_hostname
-- is non-null.
ALTER TABLE api_tokens
ADD COLUMN IF NOT EXISTS bound_hostname TEXT;
CREATE INDEX IF NOT EXISTS api_tokens_bound_hostname_idx
ON api_tokens (bound_hostname)
WHERE bound_hostname IS NOT NULL;

View file

@ -0,0 +1,19 @@
-- Issue #77 — AMPP sync used to be fire-and-forget: failures were swallowed
-- with a console.error and never retried. Track the state of every asset's
-- AMPP sync so the scheduler tick can retry pending/failed rows on a
-- backoff schedule.
--
-- ampp_sync_status: 'pending' | 'synced' | 'failed' | 'disabled'
-- ampp_sync_attempts: count, used for exponential backoff
-- ampp_sync_next_attempt_at: when the scheduler should next try this asset
-- ampp_sync_last_error: short error message for the operator (truncated)
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS ampp_sync_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS ampp_sync_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS ampp_sync_next_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS ampp_sync_last_error TEXT;
CREATE INDEX IF NOT EXISTS assets_ampp_sync_idx
ON assets (ampp_sync_status, ampp_sync_next_attempt_at)
WHERE ampp_sync_status IN ('pending', 'failed');

View file

@ -0,0 +1,14 @@
-- connect-pg-simple's session store needs this table. It's defined in
-- schema.sql which only runs on first DB init via the postgres entrypoint;
-- on instances bootstrapped via migrations only (no entrypoint init), the
-- table never existed and every login silently failed to persist the
-- session — manifesting as a redirect loop after submitting valid creds.
-- Idempotent so this is safe to re-run.
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess JSONB NOT NULL,
expire TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expire ON sessions (expire);

View file

@ -0,0 +1,12 @@
-- 022-audio-metadata.sql
-- Store per-track audio metadata extracted by ffprobe during proxy generation.
-- Shape: JSON array of objects, one per audio stream, e.g.:
-- [
-- {"index":1,"codec":"pcm_s24le","channels":2,"channel_layout":"stereo",
-- "sample_rate":48000,"bit_depth":24,"bit_rate":2304000,"language":null},
-- {"index":2,"codec":"aac","channels":2,"channel_layout":"stereo",
-- "sample_rate":48000,"bit_depth":null,"bit_rate":128000,"language":"en"}
-- ]
-- NULL means the asset has not been probed yet or has no audio streams.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS audio_metadata JSONB;

View file

@ -0,0 +1,25 @@
-- Migration 023 — auth-related user timestamps + idempotent dev user.
--
-- See docs/superpowers/specs/2026-05-27-auth-system-design.md
--
-- password_updated_at + last_login_at are operator visibility, no logic depends on them yet.
-- The dev user is seeded with a fixed UUID so FK-bearing routes (api_tokens,
-- future audit fields) keep working when AUTH_ENABLED=false. The seeded
-- password_hash is a placeholder that no bcrypt.compare will accept, so the
-- dev row cannot be used to log in even if AUTH_ENABLED is later flipped on.
--
-- password_updated_at is backfilled with NOW() for existing rows at migration time;
-- treat values from before this deploy as approximate.
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
INSERT INTO users (id, username, password_hash, display_name, role)
VALUES (
'00000000-0000-4000-8000-000000000000',
'dev',
'!disabled-no-login!',
'Dev (AUTH_ENABLED=false)',
'admin'
)
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,13 @@
-- Migration 024: add 'deltacast' to the source_type enum
-- Allows recorders to be configured for Deltacast VideoMaster SDI cards.
-- ALTER TYPE ... ADD VALUE is not transactional in PG < 12 but is safe in PG 12+.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumtypid = 'source_type'::regtype
AND enumlabel = 'deltacast'
) THEN
ALTER TYPE source_type ADD VALUE 'deltacast';
END IF;
END $$;

View file

@ -0,0 +1,5 @@
-- HLS VOD playback: per-asset HLS rendition (fMP4) generated by the proxy
-- worker alongside the MP4 proxy. Presence of hls_s3_key means an HLS
-- playlist exists at hls/<asset_id>/playlist.m3u8 and /assets/:id/stream
-- should prefer it (type: 'hls') over the MP4 range-stitched /video path.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS hls_s3_key TEXT;

View file

@ -0,0 +1,30 @@
-- Migration 026 — per-project access grants (RBAC v2).
--
-- v1 auth is flat: any logged-in user can do everything. This adds per-project
-- scoping. A grant targets either a user or a group (polymorphic subject) and
-- carries a level: 'view' (read-only) or 'edit' (read-write). Admins bypass all
-- of this in code (authz.js) and need no rows here.
--
-- subject_id is intentionally NOT a foreign key — it points at either users.id
-- or groups.id depending on subject_type. Rows are cleaned up when the project
-- is deleted (FK cascade). A deleted user/group leaves an orphan row that
-- resolves to nobody (harmless); a later sweep can prune them if desired.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
CREATE TYPE access_level AS ENUM ('view', 'edit');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS project_access (
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
subject_id UUID NOT NULL,
level access_level NOT NULL DEFAULT 'view',
granted_by UUID REFERENCES users ON DELETE SET NULL,
granted_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, subject_type, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_project_access_subject
ON project_access (subject_type, subject_id);

View file

@ -0,0 +1,20 @@
-- Migration 027 — TOTP two-factor auth.
--
-- totp_secret holds the base32 shared secret once enrollment is confirmed
-- (NULL while disabled or mid-enrollment). totp_enabled flips true only after
-- the user verifies their first code, so a half-finished enrollment never locks
-- anyone out. Recovery codes are one-time bcrypt-hashed fallbacks; used_at marks
-- a code as spent.
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS user_recovery_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user ON user_recovery_codes(user_id);

View file

@ -0,0 +1,13 @@
-- Migration 028 — Google OAuth (OIDC) sign-in.
--
-- google_sub is Google's stable subject identifier — the join key for a linked
-- or auto-provisioned account (unique, but NULL for password-only users).
-- email is captured for display + domain checks. password_hash becomes nullable
-- so an OAuth-only account can exist without a local password; such an account
-- simply can't use the password login path until an admin sets one.
ALTER TABLE users ADD COLUMN IF NOT EXISTS google_sub TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub) WHERE google_sub IS NOT NULL;

View file

@ -0,0 +1,165 @@
-- Migration 029 — Playout / Master Control (MCR).
--
-- Adds a broadcast playout subsystem: take library assets, arrange them on a
-- playlist (Phase A) or a wall-clock timeline (Phase B), and play them out
-- continuously to SDI (DeckLink) / NDI / SRT / RTMP via a CasparCG sidecar.
--
-- This is the mirror of the capture path (input -> ffmpeg -> S3). A channel is
-- placed on a cluster node by capability the same way recorders claim input
-- ports; the engine container is spawned via the same Docker-socket /
-- node-agent orchestration. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md.
--
-- Tables:
-- playout_channels — a logical output (one channel -> one CasparCG instance -> one target)
-- playout_playlists — an ordered list of items bound to a channel (Phase A)
-- playout_items — one clip on a playlist OR one row on the timeline
-- playout_sidecars — running CasparCG sidecar registry (one per channel; health-checked)
-- playout_schedule — wall-clock day-ahead rows (Phase B; unused in A)
-- playout_as_run — append-only log of what actually played (compliance)
-- ── Channels ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS playout_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
node_id UUID REFERENCES cluster_nodes(id) ON DELETE SET NULL,
output_type TEXT NOT NULL DEFAULT 'srt',
-- output_config is consumer-shape-specific:
-- decklink: { "device_index": 1 }
-- ndi: { "ndi_name": "DRAGONFLIGHT CH1" }
-- srt: { "url": "srt://host:9000", "latency": 200 }
-- rtmp: { "url": "rtmp://host/live", "key": "streamkey" }
output_config JSONB NOT NULL DEFAULT '{}'::jsonb,
-- 1080p59.94 is the house standard (matches capture cadence, streaming-friendly,
-- accepted by current SDI gear). Per-channel override allowed.
video_format TEXT NOT NULL DEFAULT '1080p5994',
status TEXT NOT NULL DEFAULT 'stopped',
container_id TEXT,
-- For remote channels the node-agent reports the reachable host:port of the
-- sidecar HTTP shim; stored here so the API can proxy transport calls.
container_meta JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT,
-- Failover bookkeeping. Scheduler tick health-checks the sidecar; on N missed
-- checks the channel is re-placed on a healthy node (auto for ndi/srt/rtmp,
-- alert-only for decklink — device-index pinning makes re-placement non-trivial).
restart_count INTEGER NOT NULL DEFAULT 0,
last_restart_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
-- RBAC scoping: a NULL project_id resolves to admin-only (authz.js), the same
-- convention recorders use for unassigned resources.
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (output_type IN ('decklink','ndi','srt','rtmp')),
CHECK (status IN ('stopped','starting','running','error'))
);
CREATE INDEX IF NOT EXISTS idx_playout_channels_node ON playout_channels (node_id);
CREATE INDEX IF NOT EXISTS idx_playout_channels_project ON playout_channels (project_id);
-- ── Playlists ──────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS playout_playlists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
name TEXT NOT NULL,
loop BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_playout_playlists_channel ON playout_playlists (channel_id);
-- ── Items ──────────────────────────────────────────────────────────────────
-- One entry on a playlist (Phase A, ordered by sort_order) OR one entry on the
-- timeline (Phase B, ordered by scheduled_at). in/out points reuse the editor's
-- subclip trim model (seconds). media_status tracks the S3 -> /media staging
-- (see playout-stage worker job); a clip cannot go on air until 'ready'.
CREATE TABLE IF NOT EXISTS playout_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
playlist_id UUID REFERENCES playout_playlists(id) ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
scheduled_at TIMESTAMPTZ,
in_point NUMERIC,
out_point NUMERIC,
transition TEXT NOT NULL DEFAULT 'cut',
transition_ms INTEGER NOT NULL DEFAULT 0,
graphics JSONB,
media_status TEXT NOT NULL DEFAULT 'pending',
media_path TEXT,
-- Set when playout-stage has run loudnorm (EBU R128, -23 LUFS / -1 dBTP) on
-- the staged file. Re-stages skip the loudnorm pass when true.
audio_normalized BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (transition IN ('cut','mix','wipe')),
CHECK (media_status IN ('pending','staging','ready','error'))
);
CREATE INDEX IF NOT EXISTS idx_playout_items_playlist ON playout_items (playlist_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_playout_items_asset ON playout_items (asset_id);
-- ── Sidecars ───────────────────────────────────────────────────────────────
-- Running CasparCG container registry, one row per running channel. The
-- scheduler tick (src/scheduler.js) pings each sidecar's /status endpoint and
-- updates last_heartbeat_at; missed checks trigger the failover path in
-- routes/playout.js.
CREATE TABLE IF NOT EXISTS playout_sidecars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
node_id UUID REFERENCES cluster_nodes(id) ON DELETE SET NULL,
container_id TEXT NOT NULL,
sidecar_url TEXT, -- http://host:port for the shim
amcp_port INTEGER, -- in-container AMCP port (default 5250)
status TEXT NOT NULL DEFAULT 'running',
last_heartbeat_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (status IN ('starting','running','error','stopped'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_playout_sidecars_channel ON playout_sidecars (channel_id)
WHERE status IN ('starting','running');
CREATE INDEX IF NOT EXISTS idx_playout_sidecars_status ON playout_sidecars (status);
-- ── Schedule (Phase B) ─────────────────────────────────────────────────────
-- Wall-clock day-ahead timeline. The scheduler tick (src/scheduler.js, under
-- the existing PG advisory lock) drives transitions and gap-fill. Unused by the
-- Phase A playlist player but created now so the schema is stable.
CREATE TABLE IF NOT EXISTS playout_schedule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
asset_id UUID REFERENCES assets(id) ON DELETE SET NULL,
scheduled_at TIMESTAMPTZ NOT NULL,
in_point NUMERIC,
out_point NUMERIC,
transition TEXT NOT NULL DEFAULT 'cut',
transition_ms INTEGER NOT NULL DEFAULT 0,
is_filler BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'scheduled',
media_status TEXT NOT NULL DEFAULT 'pending',
media_path TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (transition IN ('cut','mix','wipe')),
CHECK (status IN ('scheduled','playing','played','skipped','error')),
CHECK (media_status IN ('pending','staging','ready','error'))
);
CREATE INDEX IF NOT EXISTS idx_playout_schedule_channel_time ON playout_schedule (channel_id, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_playout_schedule_status ON playout_schedule (status, scheduled_at);
-- ── As-run log ─────────────────────────────────────────────────────────────
-- Append-only record of what actually went to air. Never updated after insert.
CREATE TABLE IF NOT EXISTS playout_as_run (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
asset_id UUID REFERENCES assets(id) ON DELETE SET NULL,
item_id UUID,
clip_name TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
duration_s NUMERIC,
result TEXT NOT NULL DEFAULT 'played',
CHECK (result IN ('played','skipped','error'))
);
CREATE INDEX IF NOT EXISTS idx_playout_as_run_channel ON playout_as_run (channel_id, started_at DESC);

View file

@ -0,0 +1,9 @@
-- Migration 030 — TOTP replay protection.
--
-- RFC 6238 §5.2 hardening: track the last counter value we accepted for each
-- user and reject codes at counters ≤ the last one. Without this, the same
-- 6-digit code can be submitted N times within its 30s step. Low impact in
-- practice (the code is only valid for ~90s with ±1 drift) but standard.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS totp_last_counter BIGINT NOT NULL DEFAULT 0;

View file

@ -0,0 +1,10 @@
-- Migration 031 — Add last_seen_at to cluster_nodes
--
-- Playout failover (routes/playout.js restartChannel) queries cluster_nodes.last_seen_at
-- to find healthy nodes for channel re-placement. Column was missing from original
-- cluster schema; heartbeat endpoint updates it via /cluster/heartbeat.
ALTER TABLE cluster_nodes ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
-- Backfill existing nodes to NOW() so they're immediately eligible for failover
UPDATE cluster_nodes SET last_seen_at = NOW() WHERE last_seen_at IS NULL;

View file

@ -49,7 +49,8 @@ CREATE TABLE bins (
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
parent_id UUID REFERENCES bins ON DELETE SET NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Assets table
@ -138,7 +139,7 @@ CREATE INDEX idx_bins_project_id ON bins(project_id);
CREATE INDEX idx_sessions_expire ON sessions(expire);
-- Recorder source types
CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp');
CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp', 'deltacast');
-- Recorder instances table
-- NOTE: current_session_id is TEXT (stores a human-readable clip name, not a UUID)

View file

@ -2,13 +2,19 @@ import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import ConnectPgSimple from 'connect-pg-simple';
import connectPgSimple from 'connect-pg-simple';
const PgStore = connectPgSimple(session);
import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { requireAuth } from './middleware/auth.js';
import { requireAuth, requireUiHeader, requireAdmin } from './middleware/auth.js';
import { loadS3ConfigFromDb } from './s3/client.js';
// Routes
import authRouter from './routes/auth.js';
import tokensRouter from './routes/tokens.js';
import usersRouter from './routes/users.js';
// Routes
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js';
@ -16,72 +22,273 @@ import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js';
import playoutRouter from './routes/playout.js';
import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js';
import usersRouter from './routes/users.js';
import groupsRouter from './routes/groups.js';
import tokensRouter from './routes/tokens.js';
import sequencesRouter from './routes/sequences.js';
import systemRouter from './routes/system.js';
import clusterRouter from './routes/cluster.js';
import sdkRouter from './routes/sdk.js';
import schedulesRouter from './routes/schedules.js';
import metricsRouter from './routes/metrics.js';
import commentsRouter from './routes/comments.js';
import importsRouter from './routes/imports.js';
import storageRouter from './routes/storage.js';
import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js';
import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
const app = express();
const PORT = process.env.PORT || 3000;
// ── Middleware ─────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',').map(s => s.trim()).filter(Boolean);
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true);
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
console.warn('[cors] rejected origin:', origin);
return cb(null, false);
},
credentials: true,
}));
app.use(express.json({ limit: '50mb' }));
const PgSession = ConnectPgSimple(session);
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
if (process.env.AUTH_ENABLED === 'true') {
app.use((req, res, next) => {
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
}
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
process.exit(1);
}
app.use(session({
store: new PgSession({
pool,
tableName: 'sessions',
pruneSessionInterval: 3600,
}),
secret: process.env.SESSION_SECRET || 'change-me-in-production',
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
secret: process.env.SESSION_SECRET,
name: 'dragonflight.sid',
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.TRUST_PROXY === 'true',
path: '/',
maxAge: 8 * 3600 * 1000,
},
rolling: false,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24, // 24 h
},
}));
// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
// ── Auth routes (always open) ─────────────────────────────────────────────────
const UNAUTH_PATHS = new Set([
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
]);
app.use('/api/v1', requireUiHeader);
app.use('/api/v1', (req, res, next) => {
if (UNAUTH_PATHS.has(req.path)) return next();
return requireAuth(req, res, next);
});
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
app.use('/api/v1/users', requireAdmin, usersRouter);
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
app.use('/api/v1/assets', assetsRouter);
app.use('/api/v1/projects', projectsRouter);
app.use('/api/v1/bins', binsRouter);
app.use('/api/v1/jobs', jobsRouter);
app.use('/api/v1/capture', captureRouter);
app.use('/api/v1/upload', uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/playout', playoutRouter);
app.use('/api/v1/settings', settingsRouter);
app.use('/api/v1/ampp', amppRouter);
app.use('/api/v1/groups', requireAdmin, groupsRouter);
app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system', systemRouter);
app.use('/api/v1/cluster', clusterRouter);
app.use('/api/v1/sdk', sdkRouter);
app.use('/api/v1/schedules', schedulesRouter);
app.use('/api/v1/metrics', metricsRouter);
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
app.use('/api/v1/imports', importsRouter);
app.use('/api/v1/storage', storageRouter);
// ── Protected routes (requireAuth is a no-op unless AUTH_ENABLED=true) ────────
app.use('/api/v1/assets', requireAuth, assetsRouter);
app.use('/api/v1/projects', requireAuth, projectsRouter);
app.use('/api/v1/bins', requireAuth, binsRouter);
app.use('/api/v1/jobs', requireAuth, jobsRouter);
app.use('/api/v1/capture', requireAuth, captureRouter);
app.use('/api/v1/upload', requireAuth, uploadRouter);
app.use('/api/v1/recorders', requireAuth, recordersRouter);
app.use('/api/v1/settings', requireAuth, settingsRouter);
app.use('/api/v1/ampp', requireAuth, amppRouter);
// ── Admin routes (requireAuth + requireAdmin applied inside each router) ───────
app.use('/api/v1/users', usersRouter);
app.use('/api/v1/groups', groupsRouter);
// ── Personal token management ─────────────────────────────────────────────────
app.use('/api/v1/tokens', requireAuth, tokensRouter);
// ── Sequences ─────────────────────────────────────────────────────────────────
app.use('/api/v1/sequences', requireAuth, sequencesRouter);
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);
// ── Start ─────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
const authMode = process.env.AUTH_ENABLED === 'true'
? 'ENABLED'
: 'DISABLED (set AUTH_ENABLED=true to require login)';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
const dir = join(__dirnameMig, 'db', 'migrations');
let files = [];
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checksum_sha TEXT
)
`);
const force = process.env.MIGRATIONS_FORCE === '1';
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
const appliedRes = await pool.query('SELECT filename FROM schema_migrations');
const applied = new Set(appliedRes.rows.map(r => r.filename));
for (const f of files) {
if (!force && applied.has(f)) continue;
const sql = readFileSync(join(dir, f), 'utf8');
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query(
`INSERT INTO schema_migrations (filename) VALUES ($1)
ON CONFLICT (filename) DO UPDATE SET applied_at = NOW()`,
[f]
);
await client.query('COMMIT');
console.log('[migration] applied ' + f);
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('[migration] FAILED ' + f + ': ' + err.message);
client.release();
if (allowFailures) continue;
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
process.exit(1);
}
client.release();
}
}
await runMigrations();
await loadS3ConfigFromDb();
function getLocalIp() {
if (process.env.NODE_IP) return process.env.NODE_IP;
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
for (const iface of (ifaces[name] || [])) {
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
}
}
return '127.0.0.1';
}
function detectGpus() {
return new Promise(resolve => {
exec(
'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
{ timeout: 5000 },
(err, stdout) => {
if (err || !stdout.trim()) return resolve([]);
const gpus = stdout.trim().split('\n').map(line => {
const parts = line.split(',').map(s => s.trim());
return {
index: parseInt(parts[0], 10),
name: parts[1] || 'Unknown GPU',
memory_mb: parseInt(parts[2], 10) || 0,
};
}).filter(g => !isNaN(g.index));
resolve(gpus);
}
);
});
}
// Primary mam-api node self-registers in cluster_nodes every 30s. Must write
// BOTH last_seen (legacy column) and last_seen_at (added by mig 031, used by
// playout failover) — otherwise the primary appears stale to the failover
// query and channels get re-placed off it incorrectly.
async function selfHeartbeat() {
const load = os.loadavg()[0];
const total = os.totalmem();
const used = total - os.freemem();
const gpus = await detectGpus();
const capabilities = { gpus, blackmagic: [] };
pool.query(
`INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url,
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen, last_seen_at)
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW(),NOW())
ON CONFLICT (hostname) DO UPDATE SET
ip_address = EXCLUDED.ip_address,
cpu_usage = EXCLUDED.cpu_usage,
mem_used_mb = EXCLUDED.mem_used_mb,
mem_total_mb = EXCLUDED.mem_total_mb,
capabilities = EXCLUDED.capabilities,
last_seen_at = NOW(),
last_seen = NOW()`,
[
process.env.NODE_HOSTNAME || os.hostname(),
getLocalIp(),
process.env.npm_package_version || null,
`http://${getLocalIp()}:${PORT}`,
parseFloat(load.toFixed(2)),
Math.round(used / 1024 / 1024),
Math.round(total / 1024 / 1024),
JSON.stringify(capabilities),
]
).catch(err => console.error('[cluster] heartbeat failed:', err.message));
}
setInterval(selfHeartbeat, 30_000);
selfHeartbeat();
const server = app.listen(PORT, () => {
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED — dev mode (dev user attached to every request)';
console.log(`MAM API listening on port ${PORT}`);
console.log(`Authentication: ${authMode}`);
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
}
startSchedulerLoop();
startCleanupLoop();
});
let _shuttingDown = false;
async function gracefulShutdown(signal) {
if (_shuttingDown) return;
_shuttingDown = true;
console.log(`[shutdown] received ${signal} — closing gracefully…`);
try { stopSchedulerLoop(); } catch (_) {}
const killSwitch = setTimeout(() => {
console.error('[shutdown] forced exit after 25s timeout');
process.exit(1);
}, 25_000);
killSwitch.unref();
await new Promise(resolve => server.close(resolve));
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
console.log('[shutdown] clean exit');
process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
console.error('[fatal] uncaughtException:', err);
gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('[fatal] unhandledRejection:', reason);
});

View file

@ -1,63 +1,134 @@
/**
* Authentication middleware.
*
* When AUTH_ENABLED=true in the environment, every protected route requires
* either:
* - An active session (set by POST /api/v1/auth/login), or
* - A valid Bearer token in Authorization header (set by POST /api/v1/tokens)
*
* When AUTH_ENABLED is unset or any other value, all middleware is a no-op so
* the stack can be run without user accounts during development.
*/
import crypto from 'crypto';
import pool from '../db/pool.js';
import { parseBearer, hashToken } from '../auth/tokens.js';
export const requireAuth = async (req, res, next) => {
if (process.env.AUTH_ENABLED !== 'true') return next();
// In-process service token for the scheduler's loopback self-calls
// (scheduler.js -> /recorders|/playout). The scheduler runs in THIS process, so
// a per-boot random constant needs no env/compose config and is never exposed:
// it only travels over the loopback fetch inside the same process. Multi-replica
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself),
// matching that replica's token. Requests bearing it are treated as the seeded
// admin (DEV_USER) so RBAC + FK-bearing routes work.
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex');
const INTERNAL_HEADER = 'x-internal-token';
// ── Session-based auth ────────────────────────────────────────
if (req.session?.userId) {
req.user = {
id: req.session.userId,
username: req.session.username,
role: req.session.role,
};
return next();
function isInternalCall(req) {
const got = req.headers[INTERNAL_HEADER];
if (typeof got !== 'string' || got.length !== INTERNAL_TOKEN.length) return false;
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(INTERNAL_TOKEN));
}
// ── Bearer token auth ─────────────────────────────────────────
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const raw = authHeader.slice(7).trim();
const hash = crypto.createHash('sha256').update(raw).digest('hex');
try {
// Stable UUID matching migration 023's seeded dev user.
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
// RBAC v2 gates — matches migration 023's seeded dev row.
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
const ABSOLUTE_MS = 8 * 3600 * 1000;
const IDLE_MS = 1 * 3600 * 1000;
async function destroyAnd401(req, res) {
if (req.session?.destroy) {
await new Promise(r => req.session.destroy(() => r()));
}
return res.status(401).json({ error: 'unauthorized' });
}
async function loadUser(id) {
const { rows } = await pool.query(
`SELECT t.user_id AS id, u.username, u.role
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1
AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
[hash]
);
if (rows.length > 0) {
req.user = rows[0];
// Fire-and-forget last_used_at update
pool.query(
'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1',
[hash]
).catch(() => {});
`SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
return rows[0] || null;
}
export async function requireAuth(req, res, next) {
// Internal loopback self-call (scheduler). Acts as the seeded admin so RBAC
// and FK-bearing routes work, regardless of AUTH_ENABLED.
if (isInternalCall(req)) {
req.user = DEV_USER;
return next();
}
} catch (err) {
return next(err);
// Dev mode — attach the seeded dev user so FK-bearing routes work.
if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER;
return next();
}
// 1. Session
if (req.session?.user_id) {
const now = Date.now();
const first = req.session.first_seen_at || 0;
const last = req.session.last_seen_at || 0;
if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res);
if (now - last > IDLE_MS) return destroyAnd401(req, res);
const u = await loadUser(req.session.user_id);
if (!u) return destroyAnd401(req, res);
req.session.last_seen_at = now; // stamp only after user is confirmed; avoids extending idle window if loadUser throws or the user was deleted
req.user = u;
return next();
}
// 2. Bearer
const bearer = parseBearer(req.headers.authorization);
if (bearer) {
const hash = hashToken(bearer);
const { rows } = await pool.query(
`SELECT t.id AS token_id, t.user_id, t.expires_at, t.bound_hostname,
u.username, u.display_name, u.role
FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1`, [hash]);
if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) {
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id])
.catch(err => console.error('[auth] token last_used_at update failed:', err.message));
req.user = {
id: rows[0].user_id,
username: rows[0].username,
display_name: rows[0].display_name,
role: rows[0].role,
};
// Per migration 019: tokens with a bound_hostname can only be used by
// node-agents reporting that hostname. The /cluster/heartbeat handler
// enforces this; we just surface the binding here.
if (rows[0].bound_hostname) req.tokenBoundHostname = rows[0].bound_hostname;
return next();
}
}
return res.status(401).json({ error: 'Unauthorized' });
};
// 3. Nothing matched
return res.status(401).json({ error: 'unauthorized' });
}
export const requireAdmin = (req, res, next) => {
if (process.env.AUTH_ENABLED !== 'true') return next();
if (req.user?.role === 'admin') return next();
return res.status(403).json({ error: 'Admin access required' });
};
// Gate a route to admins only. requireAuth must run first (it sets req.user).
// 401 when unauthenticated, 403 when authenticated but not an admin.
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
return next();
}
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
// cookie sends, but a custom header that no <form> can produce hardens
// against the edge cases. Applied to mutating verbs only.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const REQUIRED_HEADER = 'dragonflight-ui';
// Paths exempt from the CSRF header check. The bearer-auth exemption (above)
// already covers node-agent because it sends Authorization: Bearer; this set
// is the belt for any future service path that might call us without a
// bearer header. Today it just lets an unauthenticated heartbeat probe
// surface as a clean 401 from requireAuth instead of a confusing 403 CSRF.
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
export function requireUiHeader(req, res, next) {
if (!MUTATING.has(req.method)) return next();
// Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
if (isInternalCall(req)) return next();
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
// browsers and can't be drive-by'd from another origin.
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
// Service path carve-outs (e.g. node-agent heartbeat — not a browser).
if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
return res.status(403).json({ error: 'missing X-Requested-With header' });
}

View file

@ -1,11 +1,73 @@
// Error & validation middleware.
//
// Issue #101 — the previous handler echoed every error's `.message` straight
// to the client, leaking raw Postgres column names, schema details, and
// invalid UUID syntax errors to anyone hitting a malformed route.
//
// Issue #102 — every /:id route was hitting Postgres with the raw param,
// returning a 500 (with a PG error in the body) instead of a clean 400.
//
// Both are addressed here: `validateUuid` checks param shape before the
// route runs; `errorHandler` keeps detailed messages server-side and only
// surfaces a generic message + the response status to the client.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function validateUuid(paramName = 'id') {
return (req, res, next) => {
const v = req.params[paramName];
if (!v || !UUID_RE.test(v)) {
return res.status(400).json({ error: `Invalid ${paramName} — must be a UUID` });
}
next();
};
}
// Patterns Postgres uses for its error codes that are operator-only noise.
const PG_LEAKY_CODES = new Set([
'22P02', // invalid_text_representation (bad UUID, etc.)
'23502', // not_null_violation
'23503', // foreign_key_violation
'23505', // unique_violation
'42703', // undefined_column
'42P01', // undefined_table
'42601', // syntax_error
]);
const GENERIC_MESSAGES = {
'22P02': 'Invalid input format',
'23502': 'Required field missing',
'23503': 'Referenced record not found',
'23505': 'Record already exists',
'42703': 'Internal database error',
'42P01': 'Internal database error',
'42601': 'Internal database error',
};
export const errorHandler = (err, req, res, next) => {
console.error('Error:', err);
// Log the full error server-side; operators get the detail.
console.error('[error]', req.method, req.originalUrl, err);
// Postgres errors carry a `.code` (string from SQLSTATE).
if (err && err.code && PG_LEAKY_CODES.has(err.code)) {
const generic = GENERIC_MESSAGES[err.code] || 'Database error';
const status = err.code === '22P02' || err.code === '23502' ? 400 : 409;
return res.status(status).json({ error: generic, code: err.code });
}
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({
error: message,
// 5xx — never let a raw Error.message escape; clients get a stable shape.
if (status >= 500) {
return res.status(status).json({
error: 'Internal Server Error',
status,
});
}
// 4xx — operator-authored messages are safe to surface.
return res.status(status).json({
error: err.message || 'Bad request',
status,
});
};

File diff suppressed because it is too large Load diff

View file

@ -1,142 +1,461 @@
/**
* Authentication routes
*
* POST /api/v1/auth/login exchange username+password for a session cookie
* POST /api/v1/auth/logout destroy the current session
* GET /api/v1/auth/me return the currently authenticated user
* POST /api/v1/auth/setup one-time admin bootstrap (disabled after first user exists)
*/
import express from 'express';
import bcrypt from 'bcrypt';
import pool from '../db/pool.js';
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
import { hashPassword, comparePassword } from '../auth/passwords.js';
import { ipBackoff } from '../auth/rate-limit.js';
import {
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
} from '../auth/totp.js';
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
import {
isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
} from '../auth/google-oauth.js';
import { randomBytes } from 'node:crypto';
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
const router = express.Router();
// ---------------------------------------------------------------------------
// POST /login
// ---------------------------------------------------------------------------
router.post('/login', async (req, res, next) => {
// Real users = anyone except the seeded dev row.
async function realUserCount() {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
return rows[0].n;
}
// GET /api/v1/auth/setup-required
// Cheap, no auth. Used by AuthGate to decide between Login and Setup screens.
router.get('/setup-required', async (_req, res, next) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const result = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username.trim().toLowerCase()]
);
if (result.rows.length === 0) {
// Timing-safe: still run compare on a dummy hash so response time is constant
await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session ID to prevent fixation attacks
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
res.json({
id: user.id,
username: user.username,
display_name: user.display_name,
role: user.role,
});
});
} catch (err) {
next(err);
}
res.json({ required: (await realUserCount()) === 0 });
} catch (err) { next(err); }
});
// ---------------------------------------------------------------------------
// POST /logout
// ---------------------------------------------------------------------------
router.post('/logout', (req, res, next) => {
req.session.destroy((err) => {
if (err) return next(err);
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
// ---------------------------------------------------------------------------
// GET /me
// ---------------------------------------------------------------------------
router.get('/me', async (req, res) => {
// When auth is disabled return a synthetic guest/admin user so the frontend
// auth-guard never receives a 401 and never redirects to login.html.
if (process.env.AUTH_ENABLED !== 'true') {
return res.json({ id: null, username: 'admin', display_name: 'Admin', role: 'admin' });
}
const MIN_PASSWORD_LEN = 12;
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const result = await pool.query(
'SELECT id, username, display_name, role FROM users WHERE id = $1',
[req.session.userId]
);
if (result.rows.length === 0) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'User not found' });
}
res.json(result.rows[0]);
} catch (err) {
// Fallback to session data if DB unreachable
res.json({
id: req.session.userId,
username: req.session.username,
role: req.session.role,
});
}
});
function badRequest(res, msg) { return res.status(400).json({ error: msg }); }
// ---------------------------------------------------------------------------
// POST /setup — one-time first-admin bootstrap
// ---------------------------------------------------------------------------
// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists.
router.post('/setup', async (req, res, next) => {
try {
const { username, password, display_name } = req.body;
const { username, password } = req.body || {};
if (!username || typeof username !== 'string') return badRequest(res, 'username required');
if (!password || typeof password !== 'string') return badRequest(res, 'password required');
if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters');
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
if ((await realUserCount()) > 0) {
return res.status(409).json({ error: 'setup already complete' });
}
// Block if any user already exists
const count = await pool.query('SELECT COUNT(*) FROM users');
if (parseInt(count.rows[0].count, 10) > 0) {
return res.status(403).json({
error: 'Setup is already complete. Use an existing admin account to add more users.',
});
}
const hash = await bcrypt.hash(password, 12);
const result = await pool.query(
const hash = await hashPassword(password);
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $3, 'admin')
RETURNING id, username, display_name, role`,
[username.trim().toLowerCase(), hash, display_name || username]
VALUES ($1, $2, $1, 'admin')
RETURNING id, username, display_name`,
[username.trim(), hash]
);
const user = rows[0];
res.status(201).json(result.rows[0]);
// Immediately log them in.
req.session.user_id = user.id;
req.session.first_seen_at = Date.now();
req.session.last_seen_at = Date.now();
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
res.json({ user });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
next(err);
}
});
// POST /api/v1/auth/login — authenticate an existing user by username + password.
router.post('/login', async (req, res, next) => {
try {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
const delay = ipBackoff.delayMs(ip);
if (delay > 0) await new Promise(r => setTimeout(r, delay));
const { username, password } = req.body || {};
if (!username || !password) {
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
}
const { rows } = await pool.query(
`SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`,
[username.trim(), DEV_USER_ID]
);
if (rows.length === 0) {
// Pre-computed bcrypt hash of a value that no real password input will match.
// Used to keep the user-not-found response time uniform with the wrong-password
// path (~180ms at cost 12) so user enumeration via timing isn't possible.
await comparePassword(password, DUMMY_PASSWORD_HASH);
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
}
const user = rows[0];
if (!(await comparePassword(password, user.password_hash))) {
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
}
// Second factor: if TOTP is enabled, don't create a session yet. Hand back
// a short-lived ticket the client redeems via /login/totp with a code.
// Crucially: do NOT clear the per-IP failure counter here. If we did, each
// /login retry would reset the backoff and let an attacker brute the 6-digit
// TOTP space (10^6) with no per-attempt delay. The counter is cleared
// inside establishSession() once MFA has actually passed.
if (user.totp_enabled) {
return res.json({
mfa_required: true,
ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }),
});
}
await establishSession(req, user, ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
} catch (err) { next(err); }
});
// Write the session and wait for it to persist before responding. Extracted so
// both the password-only and the MFA-completion paths share one implementation.
// Clears the per-IP failure counter only here — after every required factor has
// actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]).
async function establishSession(req, user, ip) {
req.session.user_id = user.id;
req.session.first_seen_at = Date.now();
req.session.last_seen_at = Date.now();
// The critical line — wait for the row to land in `sessions` before responding.
// Without this, the SPA's next request races the store write, hits 401, and
// the prior bounce-to-login logic produced an infinite loop.
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
if (ip) ipBackoff.recordSuccess(ip);
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
}
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
// request body (password-login path) or req.session.mfa_ticket (Google path).
router.post('/login/totp', async (req, res, next) => {
try {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
// Rate-limit the second factor with the same per-IP backoff as /login so
// the 6-digit code space can't be hammered.
const delay = ipBackoff.delayMs(ip);
if (delay > 0) await new Promise(r => setTimeout(r, delay));
const { ticket: bodyTicket, code } = req.body || {};
const ticket = bodyTicket || req.session?.mfa_ticket;
if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
// Bound to the issuing request's IP + UA — replays from a different origin
// redeem to null. See mfa-tickets.js for the binding model.
const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') });
if (!userId) {
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid or expired ticket' });
}
if (!code) return res.status(400).json({ error: 'code required' });
const { rows } = await pool.query(
`SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter
FROM users WHERE id = $1`, [userId]);
const user = rows[0];
if (!user || !user.totp_enabled || !user.totp_secret) {
return res.status(401).json({ error: 'invalid credentials' });
}
// verifyToken returns the matched counter on success. Reject codes at
// counters ≤ totp_last_counter to prevent replay within the same step.
// The CAS-style UPDATE makes this race-free under concurrent submissions.
const matchedCounter = verifyToken(user.totp_secret, code);
let ok = false;
if (matchedCounter !== null) {
const lastCounter = BigInt(user.totp_last_counter || 0);
if (BigInt(matchedCounter) > lastCounter) {
const upd = await pool.query(
`UPDATE users SET totp_last_counter = $1
WHERE id = $2 AND totp_last_counter < $1`,
[String(matchedCounter), user.id]
);
ok = upd.rowCount === 1;
}
// matchedCounter ≤ last → silent replay; falls through to recovery-code
// path which also fails → 401. Same UX as a wrong code, no info leak.
}
if (!ok) ok = await consumeRecoveryCode(user.id, code);
if (!ok) {
ipBackoff.recordFailure(ip);
// The ticket was single-use; the client must restart from /login.
return res.status(401).json({ error: 'invalid code' });
}
// recordSuccess is called by establishSession once the session lands —
// that's the first moment we know every required factor has passed.
await establishSession(req, user, ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
} catch (err) { next(err); }
});
// Check a recovery code against the user's unused codes; mark it spent on match.
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
// so two concurrent redemptions of the same code can't both succeed.
async function consumeRecoveryCode(userId, code) {
const cleaned = String(code).trim().toLowerCase();
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
const { rows } = await pool.query(
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
for (const row of rows) {
if (await comparePassword(cleaned, row.code_hash)) {
const upd = await pool.query(
`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
// Lost the race if another request already consumed it.
return upd.rowCount === 1;
}
}
return false;
}
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
router.post('/logout', (req, res) => {
if (!req.session) return res.status(204).end();
req.session.destroy(err => {
if (err) console.error('[auth] session destroy failed:', err.message);
res.clearCookie('dragonflight.sid', { path: '/' });
res.status(204).end();
});
});
// GET /api/v1/auth/me
router.get('/me', requireAuth, (req, res) => {
res.json({
id: req.user.id,
username: req.user.username,
display_name: req.user.display_name,
role: req.user.role,
totp_enabled: !!req.user.totp_enabled,
});
});
// POST /api/v1/auth/password { current_password, new_password }
router.post('/password', requireAuth, async (req, res, next) => {
try {
const { current_password, new_password } = req.body || {};
if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required');
if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters');
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
if (!(await comparePassword(current_password, rows[0].password_hash))) {
return badRequest(res, 'current password is incorrect');
}
const newHash = await hashPassword(new_password);
await pool.query(
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
[newHash, req.user.id]
);
res.status(204).end();
} catch (err) { next(err); }
});
// ── TOTP enrollment (all require an active session) ─────────────────────────
// POST /api/v1/auth/totp/setup — begin enrollment. Generates a fresh secret,
// stores it (but leaves totp_enabled=false), and returns the otpauth URI + the
// base32 secret for manual entry. Enrollment isn't active until /enable
// confirms a code, so a started-but-abandoned setup never locks the user out.
router.post('/totp/setup', requireAuth, async (req, res, next) => {
try {
const { rows } = await pool.query(`SELECT totp_enabled FROM users WHERE id = $1`, [req.user.id]);
if (rows[0]?.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
const secret = generateSecret();
await pool.query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret, req.user.id]);
const uri = otpauthURI(secret, req.user.username || 'user');
// QR rendering is optional — the otpauth URI + manual secret are sufficient
// to enroll. Render a data-URL QR only if the optional `qrcode` dep is
// present, so a missing dependency degrades instead of 500-ing.
let qr = null;
try {
const QRCode = (await import('qrcode')).default;
qr = await QRCode.toDataURL(uri);
} catch { /* qrcode not installed — client falls back to manual entry */ }
res.json({ secret, otpauth_uri: uri, qr });
} catch (err) { next(err); }
});
// POST /api/v1/auth/totp/enable { code } — confirm enrollment with a code from
// the authenticator. On success, flips totp_enabled and returns one-time
// recovery codes (shown exactly once).
router.post('/totp/enable', requireAuth, async (req, res, next) => {
try {
const { code } = req.body || {};
if (!code) return badRequest(res, 'code required');
const { rows } = await pool.query(
`SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, [req.user.id]);
const row = rows[0];
if (!row?.totp_secret) return badRequest(res, 'start setup first');
if (row.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
const enrollCounter = verifyToken(row.totp_secret, code);
if (enrollCounter === null) return badRequest(res, 'incorrect code');
const recovery = generateRecoveryCodes(10);
const hashes = await Promise.all(recovery.map(c => hashPassword(c)));
// Enable + seed totp_last_counter to the enrollment code's counter so the
// same code can't be reused on first login. Replace any stale recovery
// codes atomically.
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE users SET totp_enabled = TRUE, totp_last_counter = $2 WHERE id = $1`,
[req.user.id, String(enrollCounter)]
);
await client.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
for (const h of hashes) {
await client.query(
`INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)`, [req.user.id, h]);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally { client.release(); }
res.json({ enabled: true, recovery_codes: recovery });
} catch (err) { next(err); }
});
// POST /api/v1/auth/totp/disable { password } — turn off 2FA. Requires the
// account password as a confirmation so a hijacked live session can't silently
// strip the second factor.
router.post('/totp/disable', requireAuth, async (req, res, next) => {
try {
const { password } = req.body || {};
if (!password) return badRequest(res, 'password required');
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
if (!(await comparePassword(password, rows[0].password_hash))) {
return badRequest(res, 'incorrect password');
}
await pool.query(
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0 WHERE id = $1`,
[req.user.id]
);
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
res.status(204).end();
} catch (err) { next(err); }
});
// ── Google OAuth (OIDC) sign-in ─────────────────────────────────────────────
// All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and
// OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected.
// GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide
// whether to render the "Sign in with Google" button.
router.get('/google/enabled', (_req, res) => {
res.json({ enabled: googleConfigured() });
});
// GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state
// in the session and redirects to Google's consent screen.
router.get('/google', async (req, res, next) => {
try {
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
const state = randomBytes(16).toString('hex');
req.session.oauth_state = state;
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
res.redirect(await buildAuthUrl(state));
} catch (err) { next(err); }
});
// GET /api/v1/auth/google/callback — Google redirects back here with ?code&state.
// Verifies the ID token, enforces the allowed domain, auto-provisions a viewer
// on first login, establishes the session, then redirects to the SPA.
router.get('/google/callback', async (req, res, next) => {
try {
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
const { code, state } = req.query;
const expected = req.session.oauth_state;
delete req.session.oauth_state;
if (!code || !state || !expected || state !== expected) {
return res.status(400).json({ error: 'invalid oauth state' });
}
const profile = await exchangeAndVerify(code);
const user = await resolveGoogleUser(profile);
// If this account has TOTP enabled, Google is only the FIRST factor — route
// through the same second-factor step as password login. The ticket lives in
// the session (not the URL) and the SPA prompts for the code.
if (user.totp_enabled) {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
req.session.mfa_ticket = issueTicket(user.id, {
ip,
userAgent: req.get('user-agent'),
});
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
return res.redirect('/?mfa=1');
}
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
await establishSession(req, user, ip);
// Redirect to the SPA root; AuthGate will re-check /auth/me and render the app.
res.redirect('/');
} catch (err) {
// Surface a friendly message on the login screen rather than a raw 500.
if (err.status === 403) return res.redirect('/?auth_error=domain');
if (err.status === 401) return res.redirect('/?auth_error=google');
next(err);
}
});
// Map a verified Google profile to a Dragonflight user row.
//
// Resolution order:
// 1. Existing link by google_sub → that user.
// 2. Otherwise auto-provision a fresh 'viewer'.
//
// We deliberately do NOT auto-link to an existing account by matching email:
// that would let anyone who controls a Google address with the same email sign
// in as a pre-existing local (possibly admin) account, bypassing its password
// and TOTP. Linking an existing account to Google is an explicit, authenticated
// action (a future "connect Google" under Settings), not something a login does.
async function resolveGoogleUser(profile) {
const found = await pool.query(
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
if (found.rows.length) return found.rows[0];
const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user';
let username = base, n = 1;
while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) {
username = base + (++n);
}
try {
const ins = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role, email, google_sub)
VALUES ($1, NULL, $2, 'viewer', $3, $4)
RETURNING id, username, display_name, totp_enabled`,
[username, profile.name, profile.email, profile.sub]);
return ins.rows[0];
} catch (err) {
// Concurrent first-login race: the unique google_sub index rejected our
// INSERT because a sibling request just created the row. Re-resolve.
if (err.code === '23505') {
const retry = await pool.query(
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
if (retry.rows.length) return retry.rows[0];
}
throw err;
}
}
export default router;
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };

View file

@ -1,33 +1,76 @@
import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
router.use(requireAuth);
// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the
// project_id for mutating routes to escalate to 'edit'.
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' });
req.binProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.binProjectId, 'view');
next();
} catch (err) { next(err); }
});
// GET / - List bins for a project_id
async function requireBinEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.binProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// GET / - List bins. When project_id is supplied, scope to it (after an access
// check); otherwise return bins across every project the caller can access.
router.get('/', async (req, res, next) => {
try {
const { project_id } = req.query;
if (!project_id) {
return res.status(400).json({ error: 'project_id is required' });
}
if (project_id) {
await assertProjectAccess(req.user, project_id, 'view');
const result = await pool.query(
`SELECT * FROM bins WHERE project_id = $1 ORDER BY created_at DESC`,
`SELECT b.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
FROM bins b
LEFT JOIN projects p ON p.id = b.project_id
WHERE b.project_id = $1
ORDER BY b.created_at DESC`,
[project_id]
);
return res.json(result.rows);
}
const access = await accessibleProjectIds(req.user);
let where = '';
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
where = 'WHERE b.project_id = ANY($1::uuid[])';
params.push([...access.ids]);
}
const result = await pool.query(
`SELECT b.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
FROM bins b
LEFT JOIN projects p ON p.id = b.project_id
${where}
ORDER BY b.created_at DESC`,
params
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// POST / - Create bin
// POST / - Create bin (requires edit on the target project).
router.post('/', async (req, res, next) => {
try {
const { project_id, name, parent_id } = req.body;
@ -35,6 +78,7 @@ router.post('/', async (req, res, next) => {
if (!project_id || !name) {
return res.status(400).json({ error: 'project_id and name are required' });
}
await assertProjectAccess(req.user, project_id, 'edit');
const id = uuidv4();
@ -52,7 +96,7 @@ router.post('/', async (req, res, next) => {
});
// PATCH /:id - Update bin
router.patch('/:id', async (req, res, next) => {
router.patch('/:id', requireBinEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { name, parent_id } = req.body;
@ -98,7 +142,7 @@ router.patch('/:id', async (req, res, next) => {
});
// DELETE /:id - Delete bin
router.delete('/:id', async (req, res, next) => {
router.delete('/:id', requireBinEdit, async (req, res, next) => {
try {
const { id } = req.params;
@ -117,8 +161,8 @@ router.delete('/:id', async (req, res, next) => {
}
});
// POST /:id/assets - Add asset to bin
router.post('/:id/assets', async (req, res, next) => {
// POST /:id/assets - Add asset to bin (requires edit on the bin's project).
router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { asset_id } = req.body;
@ -127,10 +171,13 @@ router.post('/:id/assets', async (req, res, next) => {
return res.status(400).json({ error: 'asset_id is required' });
}
// Verify bin exists
const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
if (binCheck.rows.length === 0) {
return res.status(404).json({ error: 'Bin not found' });
// Asset must live in the bin's own project. Without this, an editor in
// project A (where the bin lives) could pull an asset from project B (no
// grant) into A's bin tree, exposing it in A's views.
const a = await pool.query('SELECT project_id FROM assets WHERE id = $1', [asset_id]);
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
if (a.rows[0].project_id !== req.binProjectId) {
return res.status(400).json({ error: 'asset belongs to a different project than the bin' });
}
// Update asset's bin_id
@ -149,8 +196,8 @@ router.post('/:id/assets', async (req, res, next) => {
}
});
// DELETE /:id/assets/:assetId - Remove asset from bin
router.delete('/:id/assets/:assetId', async (req, res, next) => {
// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit).
router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => {
try {
const { id, assetId } = req.params;

View file

@ -1,73 +1,65 @@
// authz: intentionally any-logged-in (no per-project scoping). This is a thin
// proxy to shared capture hardware with no project_id of its own; the resulting
// asset is scoped when it's registered via the /assets route. Gated by the
// global requireAuth in index.js, like the rest of /api/v1.
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';
// Helper to proxy requests
const proxyRequest = async (method, path, body = null) => {
async function proxyRequest(method, path, body = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(8000),
};
if (body) options.body = JSON.stringify(body);
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(`${CAPTURE_URL}${path}`, options);
const data = await response.json();
return { status: response.status, data };
} catch (err) {
console.error('Capture service error:', err);
throw err;
}
};
const text = await response.text();
// POST /start - Forward start request
let data;
try {
data = JSON.parse(text);
} catch {
// Capture service returned non-JSON (HTML error page, plain text, etc.)
data = { message: text.slice(0, 300) || '(empty response)' };
}
return { status: response.status, data };
}
// POST /start
router.post('/start', async (req, res, next) => {
try {
const { status, data } = await proxyRequest('POST', '/start', req.body);
res.status(status).json(data);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// POST /stop - Forward stop request
// POST /stop
router.post('/stop', async (req, res, next) => {
try {
const { status, data } = await proxyRequest('POST', '/stop', req.body);
res.status(status).json(data);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// GET /status - Forward status request
// GET /status
router.get('/status', async (req, res, next) => {
try {
const { status, data } = await proxyRequest('GET', '/status');
res.status(status).json(data);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// GET /devices - Forward devices request
// GET /devices
router.get('/devices', async (req, res, next) => {
try {
const { status, data } = await proxyRequest('GET', '/devices');
res.status(status).json(data);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
export default router;

View file

@ -0,0 +1,378 @@
import express from 'express';
import http from 'http';
import pool from '../db/pool.js';
const router = express.Router();
function pickIp(reportedIp, reqIp) {
const clean = (s) => (s || '').replace(/^::ffff:/, '');
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
const r = clean(reqIp);
if (!reportedIp) return r || null;
if (isDockerBridge(reportedIp) && r && !isDockerBridge(r)) return r;
return reportedIp;
}
function dockerRequest(path, method = 'GET', body = null) {
return new Promise((resolve, reject) => {
const opts = {
socketPath: '/var/run/docker.sock',
path: `/v1.41${path}`,
method,
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
};
const req = http.request(opts, (res) => {
let data = '';
res.on('data', d => { data += d; });
res.on('end', () => {
if (!data.trim()) return resolve(null);
try { resolve(JSON.parse(data)); }
catch (e) { resolve(null); }
});
});
req.on('error', reject);
req.setTimeout(5000, () => { req.destroy(); reject(new Error('Docker socket timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
router.get('/', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT *,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
ORDER BY registered_at ASC`
);
res.json(r.rows.map(row => ({
...row,
online: Number(row.stale_seconds) < 120,
})));
} catch (err) { next(err); }
});
router.get('/containers', async (req, res, next) => {
try {
const containers = await dockerRequest('/containers/json?all=true');
if (!Array.isArray(containers)) return res.json([]);
const out = containers.map(c => {
const rawName = (c.Names[0] || '').replace(/^\//, '');
const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, '');
const ports = (c.Ports || [])
.filter(p => p.PublicPort)
.map(p => `${p.PublicPort}${p.PrivatePort}`)
.join(', ');
return {
id: c.Id.slice(0, 12),
name,
image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40),
state: c.State,
uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(),
healthy: (c.Status || '').includes('healthy'),
ports,
cpu: 0,
mem: 0,
};
});
res.json(out);
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]);
next(err);
}
});
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
try {
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
res.json({ ok: true });
} catch (err) { next(err); }
});
router.post('/heartbeat', async (req, res, next) => {
try {
const {
hostname, ip_address,
role = 'worker', version, api_url,
cpu_usage, mem_used_mb, mem_total_mb,
capabilities, metadata, metrics,
} = req.body;
if (!hostname) return res.status(400).json({ error: 'hostname is required' });
if (process.env.AUTH_ENABLED === 'true') {
const bound = req.tokenBoundHostname;
if (bound && bound !== hostname) {
return res.status(403).json({
error: `Token is bound to "${bound}" but heartbeat reported "${hostname}"`,
});
}
if (!bound && req.user?.role !== 'admin') {
return res.status(403).json({
error: 'Heartbeat requires a node-bound token or admin session',
});
}
}
const effectiveIp = pickIp(ip_address, req.ip || req.socket?.remoteAddress);
const r = await pool.query(
`INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url,
cpu_usage, mem_used_mb, mem_total_mb, last_seen, last_seen_at, capabilities, metadata, metrics)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW(),$9,$10,$11)
ON CONFLICT (hostname) DO UPDATE SET
ip_address = EXCLUDED.ip_address,
role = EXCLUDED.role,
version = EXCLUDED.version,
api_url = EXCLUDED.api_url,
cpu_usage = EXCLUDED.cpu_usage,
mem_used_mb = EXCLUDED.mem_used_mb,
mem_total_mb = EXCLUDED.mem_total_mb,
last_seen = NOW(),
last_seen_at = NOW(),
capabilities = EXCLUDED.capabilities,
metadata = EXCLUDED.metadata,
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
RETURNING *`,
[
hostname,
effectiveIp,
role,
version || null,
api_url || null,
cpu_usage != null ? cpu_usage : null,
mem_used_mb != null ? mem_used_mb : null,
mem_total_mb != null ? mem_total_mb : null,
capabilities != null ? JSON.stringify(capabilities) : '{}',
metadata != null ? JSON.stringify(metadata) : null,
metrics != null ? JSON.stringify(metrics) : null,
]
);
res.json(r.rows[0]);
} catch (err) { next(err); }
});
router.get('/devices/blackmagic/signal', async (req, res, next) => {
try {
const nodesResult = await pool.query(
`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
);
const recResult = await pool.query(
`SELECT id, name, status, container_id, node_id, device_index,
source_config
FROM recorders
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
);
const recByPort = new Map();
for (const r of recResult.rows) {
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
recByPort.set(`${r.node_id}:${devIdx}`, r);
}
const tasks = [];
for (const node of nodesResult.rows) {
const nodeOnline = Number(node.stale_seconds) < 120;
const bm = (node.capabilities && node.capabilities.blackmagic) || [];
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
const localHostname = process.env.NODE_HOSTNAME || '';
const isRemote = node.api_url && node.hostname !== localHostname;
bm.forEach((d, idx) => {
const portIndex = d.index !== undefined ? d.index : idx;
const rec = recByPort.get(`${node.id}:${portIndex}`);
tasks.push((async () => {
const base = {
node_id: node.id, hostname: node.hostname, index: portIndex,
device: d.device || null, model, node_online: nodeOnline,
recorder_id: rec ? rec.id : null, recorder_name: rec ? rec.name : null,
recorder_status: rec ? rec.status : null,
signal: 'no-recorder', framesReceived: null, currentFps: null,
};
if (!rec || rec.status !== 'recording' || !rec.container_id) {
if (rec && rec.status !== 'recording') base.signal = 'idle';
return base;
}
try {
let live = null;
if (isRemote) {
const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
if (r.ok) live = (await r.json()).live;
} else {
const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
if (r.ok) live = await r.json();
}
if (live && live.signal) {
base.signal = live.signal;
base.framesReceived = live.framesReceived ?? null;
base.currentFps = live.currentFps ?? null;
} else { base.signal = 'connecting'; }
} catch (_) { base.signal = 'connecting'; }
return base;
})());
});
}
const results = await Promise.all(tasks);
res.json(results);
} catch (err) { next(err); }
});
router.get('/devices/blackmagic', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`
);
const out = [];
for (const row of r.rows) {
const online = Number(row.stale_seconds) < 120;
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
bm.forEach((d, idx) => {
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
role: row.role, online, model, index: d.index !== undefined ? d.index : idx, device: d.device });
});
}
res.json(out);
} catch (err) { next(err); }
});
router.get('/devices/deltacast', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`
);
const out = [];
for (const row of r.rows) {
const online = Number(row.stale_seconds) < 120;
const dc = (row.capabilities && row.capabilities.deltacast) || [];
const model = (row.capabilities && row.capabilities.deltacast_model) || null;
dc.forEach((d, idx) => {
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
role: row.role, online, model: model || 'Deltacast',
index: d.index !== undefined ? d.index : idx, device: d.device,
present: d.present !== false, port_count: dc.length });
});
}
res.json(out);
} catch (err) { next(err); }
});
router.get('/devices/deltacast/signal', async (req, res, next) => {
try {
const [nodesRes, recordersRes] = await Promise.all([
pool.query(`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`),
pool.query(`SELECT id, node_id, device_index, status, source_type, container_id
FROM recorders WHERE source_type = 'deltacast'`),
]);
const recByNodePort = {};
for (const rec of recordersRes.rows) {
recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec;
}
const results = [];
const fetchPromises = [];
for (const node of nodesRes.rows) {
const online = Number(node.stale_seconds) < 120;
const dc = (node.capabilities && node.capabilities.deltacast) || [];
const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast';
for (const port of dc) {
const idx = port.index !== undefined ? port.index : dc.indexOf(port);
const rec = recByNodePort[`${node.id}:${idx}`];
const base = { node_id: node.id, hostname: node.hostname, ip_address: node.ip_address,
online, model, index: idx, device: port.device, present: port.present !== false,
recorder_id: rec ? rec.id : null, recorder_status: rec ? rec.status : null,
signal: 'no-recorder', framesReceived: null, currentFps: null };
if (!rec) { results.push(base); continue; }
if (rec.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; }
const fetchIdx = results.length;
results.push(base);
fetchPromises.push((async () => {
try {
const url = node.api_url ? `${node.api_url}/sidecar/${rec.container_id}/status`
: `http://recorder-${rec.id}:3001/capture/status`;
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
if (r.ok) {
const live = await r.json();
if (live && live.signal) {
results[fetchIdx].signal = live.signal;
results[fetchIdx].framesReceived = live.framesReceived ?? null;
results[fetchIdx].currentFps = live.currentFps ?? null;
}
}
} catch (_) { results[fetchIdx].signal = 'connecting'; }
})());
}
}
await Promise.all(fetchPromises);
res.json(results);
} catch (err) { next(err); }
});
router.get('/:id/ping', async (req, res, next) => {
try {
const r = await pool.query('SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id]);
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
const node = r.rows[0];
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
const start = Date.now();
try {
const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
const latency_ms = Date.now() - start;
const body = await upstream.json().catch(() => ({}));
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
} catch (err) {
res.json({ reachable: false, latency_ms: Date.now() - start, reason: err.message });
}
} catch (err) { next(err); }
});
router.get('/metrics', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, role, last_seen,
cpu_usage, mem_used_mb, mem_total_mb,
capabilities, metrics,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes ORDER BY registered_at ASC`
);
const nodes = r.rows.map(row => {
const capGpus = (row.capabilities && row.capabilities.gpus) || [];
const liveGpus = (row.metrics && row.metrics.gpus) || [];
const gpus = capGpus.map((g, idx) => {
const live = liveGpus.find(l => l.index === g.index) || liveGpus[idx] || {};
return { name: g.name || null, util_pct: live.util_pct != null ? live.util_pct : null,
memory_used_mb: live.memory_used_mb != null ? live.memory_used_mb : null,
memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null) };
});
for (const lg of liveGpus) {
if (!capGpus.some(g => g.index === lg.index)) {
gpus.push({ name: lg.name || null, util_pct: lg.util_pct != null ? lg.util_pct : null,
memory_used_mb: lg.memory_used_mb != null ? lg.memory_used_mb : null,
memory_total_mb: lg.memory_total_mb != null ? lg.memory_total_mb : null });
}
}
return { id: row.id, hostname: row.hostname, role: row.role,
online: Number(row.stale_seconds) < 120, last_seen: row.last_seen,
cpu_util_pct: row.cpu_usage != null ? Number(row.cpu_usage) : null,
ram_used_mb: row.mem_used_mb != null ? row.mem_used_mb : null,
ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null, gpus };
});
res.json({ nodes });
} catch (err) { next(err); }
});
router.delete('/:id', async (req, res, next) => {
try {
const r = await pool.query('DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id]);
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
res.json({ ok: true });
} catch (err) { next(err); }
});
export default router;

View file

@ -0,0 +1,128 @@
// Asset-scoped comments for the Asset Detail page.
//
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
// Express's :assetId param flows through from the parent mount.
import express from 'express';
import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router({ mergeParams: true });
// Scope every comment route to the parent asset's project: resolve project_id
// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or
// unknown asset is a clean 404 before any access decision leaks its existence.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
router.use(async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]);
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view');
next();
} catch (err) { next(err); }
});
function rowToJson(r) {
return {
id: r.id,
asset_id: r.asset_id,
user_id: r.user_id,
body: r.body,
frame_ms: r.frame_ms,
resolved: r.resolved,
created_at: r.created_at,
updated_at: r.updated_at,
author_name: r.author_name || null,
author_initials: r.author_initials || null,
};
}
// GET /api/v1/assets/:assetId/comments
router.get('/', async (req, res, next) => {
try {
const { assetId } = req.params;
const result = await pool.query(
`SELECT c.*,
u.display_name AS author_name,
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
FROM asset_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.asset_id = $1
ORDER BY c.created_at ASC`,
[assetId]
);
res.json({ comments: result.rows.map(rowToJson) });
} catch (err) { next(err); }
});
// POST /api/v1/assets/:assetId/comments
router.post('/', async (req, res, next) => {
try {
const { assetId } = req.params;
const { body, frame_ms } = req.body || {};
if (!body || !String(body).trim()) {
return res.status(400).json({ error: 'body is required' });
}
// Author is the authenticated user (requireAuth sets req.user for both
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
const userId = req.user?.id || null;
const ins = await pool.query(
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[assetId, userId, String(body).trim(), frame_ms != null ? Math.max(0, Math.round(Number(frame_ms))) : null]
);
// Re-fetch with the author join so the response has the same shape as list.
const result = await pool.query(
`SELECT c.*,
u.display_name AS author_name,
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
FROM asset_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.id = $1`,
[ins.rows[0].id]
);
res.status(201).json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// PATCH /api/v1/assets/:assetId/comments/:id
router.patch('/:id', async (req, res, next) => {
try {
const { id, assetId } = req.params;
const { body, resolved } = req.body || {};
const fields = [];
const values = [];
let i = 1;
if (body !== undefined) { fields.push(`body = $${i++}`); values.push(String(body).trim()); }
if (resolved !== undefined) { fields.push(`resolved = $${i++}`); values.push(!!resolved); }
if (fields.length === 0) return res.status(400).json({ error: 'Nothing to update' });
fields.push('updated_at = NOW()');
values.push(id, assetId);
const result = await pool.query(
`UPDATE asset_comments SET ${fields.join(', ')}
WHERE id = $${i++} AND asset_id = $${i}
RETURNING *`,
values
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
res.json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// DELETE /api/v1/assets/:assetId/comments/:id
router.delete('/:id', async (req, res, next) => {
try {
const { id, assetId } = req.params;
const result = await pool.query(
`DELETE FROM asset_comments WHERE id = $1 AND asset_id = $2 RETURNING id`,
[id, assetId]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
res.json({ id });
} catch (err) { next(err); }
});
export default router;

View file

@ -11,10 +11,8 @@
*/
import express from 'express';
import pool from '../db/pool.js';
import { requireAuth, requireAdmin } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth, requireAdmin);
// ── List ──────────────────────────────────────────────────────
router.get('/', async (_req, res, next) => {

View file

@ -0,0 +1,97 @@
// External media imports — currently YouTube only.
//
// The flow mirrors upload.js: create the asset row up front with a placeholder
// filename (the worker fills in the real title once yt-dlp prints metadata),
// then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the
// same originals/{assetId}/... path uploads use, and hands off to the existing
// proxy queue — so an imported asset travels the same lifecycle as any upload.
import express from 'express';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router();
const parseRedisUrl = (url) => {
try {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
} catch {
return { host: 'localhost', port: 6379 };
}
};
const importQueue = new Queue('import', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
// Match the same three forms the client UI validates against. Server is the
// authoritative check — never trust the client to have validated.
const YT_PATTERNS = [
/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i,
/^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i,
/^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i,
];
function isYouTubeUrl(url) {
return typeof url === 'string' && YT_PATTERNS.some((re) => re.test(url));
}
// POST /api/v1/imports/youtube — body { url, projectId, binId? }
router.post('/youtube', async (req, res, next) => {
try {
const { url, projectId, binId } = req.body || {};
if (!url || !projectId) {
return res.status(400).json({ error: 'url and projectId are required' });
}
if (!isYouTubeUrl(url)) {
return res.status(400).json({ error: 'Invalid YouTube URL' });
}
// A playlist URL has `list=…` — yt-dlp's --no-playlist would still grab
// the single video, but the operator probably meant "import the list" and
// we don't support that yet. Reject so the intent is explicit.
if (/[?&]list=/i.test(url)) {
return res.status(400).json({ error: "Playlists aren't supported yet" });
}
const projCheck = await pool.query('SELECT id FROM projects WHERE id = $1', [projectId]);
if (projCheck.rows.length === 0) {
return res.status(404).json({ error: 'Project not found' });
}
// Importing writes an asset into the project — require edit access.
await assertProjectAccess(req.user, projectId, 'edit');
const assetId = uuidv4();
// Placeholder filename/display_name — the worker overwrites both once
// yt-dlp resolves the video title (usually within a second or two).
await pool.query(
`INSERT INTO assets (
id, project_id, bin_id, filename, display_name, status,
media_type, original_s3_key, source_url, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $4, 'ingesting', 'video', NULL, $5, NOW(), NOW())`,
[assetId, projectId, binId || null, url, url]
);
const bullJob = await importQueue.add('youtube', {
assetId,
url,
// Surface the URL in the Jobs screen until the worker fills in the title.
assetName: url,
});
res.status(202).json({
assetId,
jobId: `import:${bullJob.id}`,
status: 'queued',
});
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,11 +1,14 @@
import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router();
router.use(requireAuth);
// Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"),
// NOT UUIDs. The GET/:id, POST/:id/retry, and DELETE/:id handlers below split
// on the colon themselves and look up the queue. Adding a UUID validator
// here would 400 every BullMQ poll the panel makes (which is exactly what
// caused Export Timeline to stall "Rendering Hi-Res" forever — fixed 2026-05-28).
// ── Redis connection ──────────────────────────────────────────────────────────
const parseRedisUrl = (url) => {
@ -21,12 +24,18 @@ 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 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' },
];
// BullMQ state → API status mapping
@ -39,6 +48,9 @@ const STATE_MAP = {
paused: 'waiting',
};
// Ordered state buckets used for bulk fetch — avoids N+1 getState() calls.
const STATE_BUCKETS = ['active', 'waiting', 'completed', 'failed', 'delayed', 'paused'];
function normalizeJob(bullJob, type, apiStatus) {
const isCompleted = apiStatus === 'completed';
const isFailed = apiStatus === 'failed';
@ -58,28 +70,129 @@ function normalizeJob(bullJob, type, apiStatus) {
};
}
// Fetch all jobs from all queues in bulk by state bucket (no per-job getState() calls).
async function getAllBullMQJobs() {
const results = [];
for (const { queue, type } of QUEUES) {
for (const [bullState, apiStatus] of Object.entries(STATE_MAP)) {
for (const bucket of STATE_BUCKETS) {
try {
const jobs = await queue.getJobs([bullState], 0, 200);
const apiStatus = STATE_MAP[bucket] || bucket;
const jobs = await queue.getJobs([bucket], 0, 200);
for (const job of jobs) {
results.push(normalizeJob(job, type, apiStatus));
}
} catch {
// queue may be empty or unavailable for this state skip
// queue or bucket unavailable — skip
}
}
}
return results;
}
// ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
// Mutate `jobs` in place to fill in asset_name from the assets table for any
// job that has an assetId but no inline assetName in its payload. One bulk
// SQL query per refresh — cheap, and means we don't have to remember to pass
// assetName at every enqueue site (upload.js, capture stop, scheduler, etc.).
async function attachAssetNames(jobs) {
const idsNeedingLookup = [...new Set(
jobs.filter(j => j.asset_id && !j.asset_name).map(j => j.asset_id)
)];
if (idsNeedingLookup.length === 0) return;
let rows = [];
try {
const result = await pool.query(
'SELECT id, display_name, filename FROM assets WHERE id = ANY($1::uuid[])',
[idsNeedingLookup]
);
rows = result.rows;
} catch {
// If the lookup fails (DB down, bad UUID in a stale BullMQ payload), keep
// serving jobs without names rather than 500-ing the whole list.
return;
}
const byId = new Map(rows.map(r => [r.id, r.display_name || r.filename]));
for (const j of jobs) {
if (j.asset_id && !j.asset_name) {
const name = byId.get(j.asset_id);
if (name) j.asset_name = name;
}
}
}
// ── GET /events Server-Sent Events stream of live job updates ───────────────
router.get('/events', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
let closed = false;
req.on('close', () => { closed = true; });
const push = async () => {
if (closed) return;
try {
const jobs = await getAllBullMQJobs();
await attachAssetNames(jobs);
if (!closed) res.write(`data: ${JSON.stringify({ type: 'jobs', jobs })}\n\n`);
} catch (err) {
if (!closed) res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
}
if (!closed) setTimeout(push, 2000);
};
await push();
});
// Fetch DB-tracked jobs (e.g. trim) and normalize to the same shape as BullMQ jobs.
// Only returns non-expired rows.
async function getDbJobs() {
try {
const result = await pool.query(
`SELECT j.id, j.type, j.status, j.payload, j.created_at, j.updated_at,
ts.asset_id
FROM jobs j
LEFT JOIN temp_segments ts ON ts.job_id = j.id
WHERE (j.expires_at IS NULL OR j.expires_at > NOW())
ORDER BY j.created_at DESC
LIMIT 200`
);
// Dedupe — multiple temp_segments per job, take first asset_id found
const seen = new Map();
for (const row of result.rows) {
if (!seen.has(row.id)) {
seen.set(row.id, {
id: `trim:${row.id}`,
type: row.type,
status: row.status === 'completed' ? 'completed' : row.status,
progress: row.status === 'completed' ? 100 : (row.status === 'failed' ? 0 : 50),
asset_id: row.asset_id || null,
asset_name: null,
created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
started_at: null,
completed_at: row.status === 'completed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
failed_at: row.status === 'failed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
error: null,
metadata: row.payload || {},
});
}
}
return [...seen.values()];
} catch {
return [];
}
}
// ── GET / - List jobs (BullMQ queues + DB trim jobs) ─────────────────────────
router.get('/', async (req, res, next) => {
try {
const { type, status, asset_id } = req.query;
let jobs = await getAllBullMQJobs();
const dbJobs = await getDbJobs();
jobs = jobs.concat(dbJobs);
await attachAssetNames(jobs);
if (type) jobs = jobs.filter(j => j.type === type);
if (status) jobs = jobs.filter(j => j.status === status);
@ -96,7 +209,6 @@ router.get('/', async (req, res, next) => {
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
// id format: "type:bullId" e.g. "proxy:1"
const colonIdx = id.indexOf(':');
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
@ -108,7 +220,9 @@ router.get('/:id', async (req, res, next) => {
if (job) {
const state = await job.getState();
const apiStatus = STATE_MAP[state] || state;
return res.json(normalizeJob(job, type, apiStatus));
const normalized = normalizeJob(job, type, apiStatus);
await attachAssetNames([normalized]);
return res.json(normalized);
}
} catch { /* try next queue */ }
}
@ -118,8 +232,8 @@ router.get('/:id', async (req, res, next) => {
}
});
// ── DELETE /:id - Remove a job ────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
// ── POST /:id/retry - Retry a failed job ──────────────────────────────────────
router.post('/:id/retry', async (req, res, next) => {
try {
const { id } = req.params;
const colonIdx = id.indexOf(':');
@ -131,8 +245,8 @@ router.delete('/:id', async (req, res, next) => {
try {
const job = await queue.getJob(bullId);
if (job) {
await job.remove();
return res.json({ success: true });
await job.retry();
return res.json({ id, status: 'queued' });
}
} catch { /* try next queue */ }
}
@ -142,7 +256,65 @@ router.delete('/:id', async (req, res, next) => {
}
});
// ── POST /conform - Submit a conform job ──────────────────────────────────────
// ── DELETE /:id - Remove a job (also handles cancel for active jobs) ─────────
// BullMQ refuses job.remove() while a job is in the 'active' state. Before this
// fix the route caught that error and fell through to a misleading 404, so
// operators couldn't kill a stalled-active job from the UI. Now we detect the
// active state explicitly: moveToFailed with the magic '0' token bypasses the
// per-worker lock check and transitions active → failed (freeing the queue's
// concurrency slot), then remove() drops the row.
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const colonIdx = id.indexOf(':');
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
let lastErr = null;
for (const { queue, type } of QUEUES) {
if (qType && type !== qType) continue;
let job;
try {
job = await queue.getJob(bullId);
} catch (err) {
// Queue-level lookup error: remember it so we don't mask it with 404.
lastErr = err;
continue;
}
if (!job) continue;
const state = await job.getState();
if (state === 'active') {
// Token '0' tells BullMQ to skip the worker-lock check — necessary
// because the operator-side cancel doesn't hold the worker's lock.
try {
await job.moveToFailed(new Error('Cancelled by operator'), '0', false);
} catch (err) {
// Lock owned by a still-living worker; fall back to discard + remove
// so at least the result is thrown away and the row is gone.
try { await job.discard(); } catch (_) {}
}
}
try {
await job.remove();
} catch (err) {
// Last-resort obliteration of the job row via raw Redis. This is
// the path stalled jobs hit when moveToFailed couldn't transition
// them either.
const client = await queue.client;
const prefix = queue.toKey(bullId);
await client.del(prefix);
}
return res.json({ success: true, cancelled: state === 'active' });
}
if (lastErr) return next(lastErr);
res.status(404).json({ error: 'Job not found' });
} catch (err) {
next(err);
}
});
// ── POST /conform - Submit a conform (EDL export) job ────────────────────────
router.post('/conform', async (req, res, next) => {
try {
const { edl, project_id, output_format } = req.body;
@ -153,25 +325,17 @@ router.post('/conform', async (req, res, next) => {
});
}
const jobId = uuidv4();
// Conform writes back into a project — require edit on that project. Without
// this, any logged-in user could enqueue conform jobs targeting any project.
await assertProjectAccess(req.user, project_id, 'edit');
const result = await pool.query(
`INSERT INTO jobs (id, type, status, project_id, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
RETURNING *`,
[jobId, 'conform', 'pending', project_id, JSON.stringify({ edl, output_format })]
);
const job = result.rows[0];
await conformQueue.add('conform-task', {
jobId,
const bullJob = await conformQueue.add('conform-task', {
edl,
projectId: project_id,
outputFormat: output_format,
});
res.status(201).json(job);
res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
} catch (err) {
next(err);
}

View file

@ -0,0 +1,102 @@
// Real metrics for the Home page sparklines.
//
// Buckets the last N hours into N points, counting rows in each window.
// Returns a flat shape that's easy for the React Sparkline to consume.
import express from 'express';
import pool from '../db/pool.js';
const router = express.Router();
const DEFAULT_HOURS = 24;
const DEFAULT_POINTS = 13;
function bucketCountSQL(table, statusFilter) {
// Use date_trunc + generate_series so we always return `points` buckets
// (even hours with no rows show up as 0). All times are UTC.
return `
WITH series AS (
SELECT generate_series(
date_trunc('hour', NOW() - ($1 || ' hours')::interval),
date_trunc('hour', NOW()),
('1 hour')::interval
) AS bucket
)
SELECT s.bucket,
COALESCE(COUNT(t.created_at), 0)::int AS count
FROM series s
LEFT JOIN ${table} t
ON date_trunc('hour', t.created_at) = s.bucket
${statusFilter ? ` AND ${statusFilter}` : ''}
GROUP BY s.bucket
ORDER BY s.bucket ASC
`;
}
async function bucketSeries(table, hours, statusFilter = null) {
const result = await pool.query(bucketCountSQL(table, statusFilter), [hours]);
return result.rows.map(r => ({ t: r.bucket, v: r.count }));
}
router.get('/home', async (req, res, next) => {
try {
const hours = Math.min(parseInt(req.query.hours || DEFAULT_HOURS, 10), 168); // cap at 1 week
const [assets, jobsDone, jobsFailed, recordersTotal, recordersLive, jobsRunning, jobsDoneTotal, jobsFailedTotal] = await Promise.all([
bucketSeries('assets', hours),
bucketSeries('jobs', hours, `t.status = 'complete'`),
bucketSeries('jobs', hours, `t.status = 'failed'`),
pool.query(`SELECT COUNT(*)::int AS n FROM recorders`),
pool.query(`SELECT COUNT(*)::int AS n FROM recorders WHERE status = 'recording'`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status IN ('queued','processing')`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'complete'`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'failed'`),
]);
// Cluster snapshot — heartbeat freshness drives online/offline
const cluster = await pool.query(
`SELECT id, hostname, role,
EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
FROM cluster_nodes`
);
const nodes = cluster.rows.map(n => ({
id: n.id, hostname: n.hostname, role: n.role,
online: n.stale_seconds != null && n.stale_seconds < 120,
}));
res.json({
hours,
generated_at: new Date().toISOString(),
cards: {
assets: {
total: (await pool.query(`SELECT COUNT(*)::int AS n FROM assets`)).rows[0].n,
series: assets,
},
recorders: {
total: recordersTotal.rows[0].n,
live: recordersLive.rows[0].n,
// No historical "active" metric yet — synthesize as the live count
// replayed across the window so the card has *something* to graph.
series: assets.map(p => ({ t: p.t, v: recordersLive.rows[0].n })),
},
jobs: {
running: jobsRunning.rows[0].n,
done_total: jobsDoneTotal.rows[0].n,
failed_total: jobsFailedTotal.rows[0].n,
series_done: jobsDone,
series_failed: jobsFailed,
},
cluster: {
total: nodes.length,
online: nodes.filter(n => n.online).length,
nodes,
// Heartbeat liveness is binary — emit a 1/0 across the window keyed
// to current state so the sparkline shows a sensible bar shape.
series: assets.map(p => ({ t: p.t, v: nodes.filter(n => n.online).length })),
},
},
});
} catch (err) { next(err); }
});
export default router;

View file

@ -0,0 +1,620 @@
// Playout / Master Control routes.
//
// Control plane for the CasparCG-backed playout subsystem. Channels are placed
// on cluster nodes and their engine containers spawned via the same Docker-socket
// / node-agent path recorders use; the channel's transport (play / pause / skip)
// is proxied through to the sidecar's HTTP shim, which drives CasparCG over AMCP.
//
// RBAC: every channel carries a project_id (NULL = admin-only, the recorder
// convention). List routes filter by accessible projects; mutating routes assert
// 'edit'. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md.
import express from 'express';
import http from 'http';
import { Queue } from 'bullmq';
import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js';
import {
assertProjectAccess, accessibleProjectIds, isAdmin,
} from '../auth/authz.js';
const router = express.Router();
const parseRedisUrl = (url) => {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};
const stageQueue = new Queue('playout-stage', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
const PLAYOUT_SIDECAR_IMAGE = process.env.PLAYOUT_IMAGE || 'wild-dragon-playout:latest';
function dockerApi(method, path, body = null) {
return new Promise((resolve, reject) => {
const options = {
socketPath: '/var/run/docker.sock',
path: `/v1.43${path}`,
method,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {} }); }
catch { resolve({ status: res.statusCode, data }); }
});
});
req.on('error', reject);
req.setTimeout(10000, () => req.destroy(new Error('Docker API timeout after 10s')));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function resolveNodeTarget(nodeId) {
if (!nodeId) return { remote: false };
const r = await pool.query(
'SELECT hostname, ip_address, api_url FROM cluster_nodes WHERE id = $1', [nodeId]
);
if (r.rows.length === 0) return { remote: false };
const node = r.rows[0];
const localHostname = process.env.NODE_HOSTNAME || '';
if (!node.api_url || node.hostname === localHostname) return { remote: false };
return { remote: true, apiUrl: node.api_url, ip: node.ip_address };
}
const SIDECAR_HTTP_PORT = 3002;
function channelAlias(id) { return `playout-${id}`; }
function sidecarBaseUrl(channel) {
if (channel.container_meta && channel.container_meta.sidecar_url) {
return channel.container_meta.sidecar_url;
}
return `http://${channelAlias(channel.id)}:${SIDECAR_HTTP_PORT}`;
}
async function callSidecar(channel, path, method = 'POST', body = null) {
const url = `${sidecarBaseUrl(channel)}${path}`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(20000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`sidecar ${method} ${path} -> HTTP ${res.status}: ${text.slice(0, 200)}`);
}
return res.json().catch(() => ({}));
}
function channelToJson(r) {
return {
id: r.id,
name: r.name,
node_id: r.node_id,
output_type: r.output_type,
output_config: r.output_config,
video_format: r.video_format,
status: r.status,
container_id: r.container_id,
error_message: r.error_message,
project_id: r.project_id,
restart_count: r.restart_count ?? 0,
last_restart_at: r.last_restart_at,
last_heartbeat_at: r.last_heartbeat_at,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
const OUTPUT_TYPES = new Set(['decklink', 'ndi', 'srt', 'rtmp']);
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query(
'SELECT * FROM playout_channels WHERE id = $1', [req.params.id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
req.channel = rows[0];
await assertProjectAccess(req.user, req.channel.project_id, 'view');
next();
} catch (err) { next(err); }
});
async function requireChannelEdit(req, res, next) {
try { await assertProjectAccess(req.user, req.channel.project_id, 'edit'); next(); }
catch (err) { next(err); }
}
router.get('/channels', async (req, res, next) => {
try {
let rows;
if (isAdmin(req.user)) {
({ rows } = await pool.query('SELECT * FROM playout_channels ORDER BY created_at DESC'));
} else {
const ids = await accessibleProjectIds(req.user);
if (ids.length === 0) return res.json([]);
({ rows } = await pool.query(
'SELECT * FROM playout_channels WHERE project_id = ANY($1) ORDER BY created_at DESC', [ids]
));
}
res.json(rows.map(channelToJson));
} catch (err) { next(err); }
});
router.post('/channels', async (req, res, next) => {
try {
const { name, node_id = null, output_type = 'srt', output_config = {},
video_format = '1080p5994', project_id = null } = req.body || {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'name is required' });
}
if (!OUTPUT_TYPES.has(output_type)) {
return res.status(400).json({ error: `output_type must be one of: ${[...OUTPUT_TYPES].join(', ')}` });
}
if (project_id) await assertProjectAccess(req.user, project_id, 'edit');
else if (!isAdmin(req.user)) return res.status(403).json({ error: 'admin required for unassigned channel' });
const { rows } = await pool.query(
`INSERT INTO playout_channels (name, node_id, output_type, output_config, video_format, project_id)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[name.trim(), node_id, output_type, JSON.stringify(output_config), video_format, project_id]
);
res.status(201).json(channelToJson(rows[0]));
} catch (err) { next(err); }
});
router.patch('/channels/:id', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status === 'running') {
return res.status(409).json({ error: 'Cannot edit a running channel — stop it first' });
}
const allowed = ['name', 'node_id', 'output_type', 'output_config', 'video_format', 'project_id'];
const sets = [];
const vals = [];
let i = 1;
for (const k of allowed) {
if (req.body[k] === undefined) continue;
if (k === 'output_type' && !OUTPUT_TYPES.has(req.body[k])) {
return res.status(400).json({ error: 'invalid output_type' });
}
sets.push(`${k} = $${i++}`);
vals.push(k === 'output_config' ? JSON.stringify(req.body[k]) : req.body[k]);
}
if (sets.length === 0) return res.json(channelToJson(req.channel));
vals.push(req.channel.id);
const { rows } = await pool.query(
`UPDATE playout_channels SET ${sets.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`, vals
);
res.json(channelToJson(rows[0]));
} catch (err) { next(err); }
});
router.delete('/channels/:id', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status === 'running') {
return res.status(409).json({ error: 'Stop the channel before deleting it' });
}
await pool.query('DELETE FROM playout_channels WHERE id = $1', [req.channel.id]);
res.json({ deleted: true });
} catch (err) { next(err); }
});
async function assertDeckLinkFree(channel) {
if (channel.output_type !== 'decklink') return;
const idx = (channel.output_config && channel.output_config.device_index) || 1;
const chan = await pool.query(
`SELECT id FROM playout_channels
WHERE id <> $1 AND node_id IS NOT DISTINCT FROM $2 AND status = 'running'
AND output_type = 'decklink' AND (output_config->>'device_index')::int = $3`,
[channel.id, channel.node_id, idx]
);
if (chan.rows.length > 0) {
throw Object.assign(new Error(`DeckLink device ${idx} already in use by another channel on this node`), { httpStatus: 409 });
}
const rec = await pool.query(
`SELECT id FROM recorders
WHERE node_id IS NOT DISTINCT FROM $1 AND device_index = $2
AND status = 'recording' AND source_type = 'sdi'`,
[channel.node_id, idx]
);
if (rec.rows.length > 0) {
throw Object.assign(new Error(`DeckLink device ${idx} is in use by a recorder on this node`), { httpStatus: 409 });
}
}
async function spawnChannelSidecar(channel) {
await pool.query('UPDATE playout_channels SET status = $1, error_message = NULL WHERE id = $2', ['starting', channel.id]);
const env = [
`OUTPUT_TYPE=${channel.output_type}`,
`OUTPUT_CONFIG=${JSON.stringify(channel.output_config || {})}`,
`VIDEO_FORMAT=${channel.video_format}`,
`PORT=${SIDECAR_HTTP_PORT}`,
`CHANNEL_ID=${channel.id}`,
];
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(channel.node_id);
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
let containerId;
let containerMeta = {};
if (isRemote) {
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: PLAYOUT_SIDECAR_IMAGE, env,
capturePort: SIDECAR_HTTP_PORT,
sourceType: channel.output_type,
useGpu: false,
publishHttp: true,
}),
signal: AbortSignal.timeout(20000),
});
if (!sidecarRes.ok) {
const details = await sidecarRes.json().catch(() => ({}));
console.error('[playout] remote sidecar start failed:', JSON.stringify(details));
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
['error', 'remote node failed to start sidecar', channel.id]);
throw Object.assign(new Error('Remote node failed to start sidecar'), { httpStatus: 502 });
}
const data = await sidecarRes.json();
containerId = data.containerId;
if (data.sidecarUrl || data.host) {
containerMeta.sidecar_url = data.sidecarUrl || `http://${data.host}:${SIDECAR_HTTP_PORT}`;
}
} else {
const alias = channelAlias(channel.id);
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-media:/media'];
if (channel.output_type === 'decklink') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
const containerConfig = {
Image: PLAYOUT_SIDECAR_IMAGE,
Env: env,
HostConfig: {
// DeckLink SDI needs raw /dev access (privileged). SRT/NDI/RTMP/HLS run
// unprivileged — privileged exposes host GPUs to CasparCG, and the
// missing in-container NVIDIA driver crashes the engine within seconds.
Privileged: channel.output_type === 'decklink',
NetworkMode: dockerNetwork,
Binds: hostBinds,
},
NetworkingConfig: { EndpointsConfig: { [dockerNetwork]: { Aliases: [alias] } } },
Hostname: alias,
};
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
if (createRes.status !== 201) {
console.error('[playout] container create failed:', JSON.stringify(createRes.data));
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
['error', 'container create failed', channel.id]);
throw Object.assign(new Error('Failed to create container'), { httpStatus: 500 });
}
containerId = createRes.data.Id;
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
console.error('[playout] container start failed:', JSON.stringify(startRes.data));
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
['error', 'container start failed', channel.id]);
throw Object.assign(new Error('Failed to start container'), { httpStatus: 500 });
}
}
const { rows } = await pool.query(
`UPDATE playout_channels
SET status = 'running', container_id = $1, container_meta = $2, updated_at = NOW()
WHERE id = $3 RETURNING *`,
[containerId, JSON.stringify(containerMeta), channel.id]
);
return rows[0];
}
router.post('/channels/:id/start', requireChannelEdit, async (req, res, next) => {
try {
const channel = req.channel;
if (channel.status === 'running' || channel.status === 'starting') {
return res.status(409).json({ error: `Channel already ${channel.status}` });
}
await assertDeckLinkFree(channel);
const row = await spawnChannelSidecar(channel);
res.json(channelToJson(row));
} catch (err) {
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
next(err);
}
});
router.post('/channels/:id/stop', requireChannelEdit, async (req, res, next) => {
try {
const channel = req.channel;
if (channel.container_id) {
const { remote: isRemote, apiUrl } = await resolveNodeTarget(channel.node_id);
if (isRemote) {
await fetch(`${apiUrl}/sidecar/stop`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ containerId: channel.container_id }),
signal: AbortSignal.timeout(20000),
}).catch((e) => console.error('[playout] remote stop failed:', e.message));
} else {
await dockerApi('POST', `/containers/${channel.container_id}/stop?t=10`).catch(() => {});
await dockerApi('DELETE', `/containers/${channel.container_id}?force=true`).catch(() => {});
}
}
const { rows } = await pool.query(
`UPDATE playout_channels SET status = 'stopped', container_id = NULL, updated_at = NOW()
WHERE id = $1 RETURNING *`, [channel.id]
);
res.json(channelToJson(rows[0]));
} catch (err) { next(err); }
});
router.get('/channels/:id/status', async (req, res, next) => {
try {
if (req.channel.status !== 'running') {
return res.json({ running: false, status: req.channel.status });
}
const out = await callSidecar(req.channel, '/status', 'GET');
res.json({ running: true, status: req.channel.status, engine: out });
} catch (err) {
res.json({ running: true, status: req.channel.status, engine: null, engine_error: err.message });
}
});
async function transport(req, res, action, body = null) {
if (req.channel.status !== 'running') {
return res.status(409).json({ error: 'Channel is not running' });
}
try { res.json(await callSidecar(req.channel, action, 'POST', body)); }
catch (err) { res.status(502).json({ error: err.message }); }
}
router.post('/channels/:id/play', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status !== 'running') {
return res.status(409).json({ error: 'Start the channel before playing' });
}
const { playlist_id } = req.body || {};
if (!playlist_id) return res.status(400).json({ error: 'playlist_id is required' });
const pl = await pool.query('SELECT * FROM playout_playlists WHERE id = $1 AND channel_id = $2',
[playlist_id, req.channel.id]);
if (pl.rows.length === 0) return res.status(404).json({ error: 'Playlist not found for this channel' });
const items = await pool.query(
`SELECT i.*, a.filename AS clip_name, a.duration_ms AS asset_duration_ms
FROM playout_items i JOIN assets a ON a.id = i.asset_id
WHERE i.playlist_id = $1 ORDER BY i.sort_order ASC`, [playlist_id]);
const notReady = items.rows.filter((i) => i.media_status !== 'ready' || !i.media_path);
if (notReady.length > 0) {
return res.status(409).json({
error: 'Some items are not staged yet',
pending: notReady.map((i) => i.id),
});
}
const payload = {
loop: pl.rows[0].loop,
items: items.rows.map((i) => ({
id: i.id, asset_id: i.asset_id, media_path: i.media_path,
in_point: i.in_point ? Number(i.in_point) : null,
out_point: i.out_point ? Number(i.out_point) : null,
transition: i.transition, transition_ms: i.transition_ms,
clip_name: i.clip_name,
asset_duration_ms: i.asset_duration_ms != null ? Number(i.asset_duration_ms) : null,
})),
};
const out = await callSidecar(req.channel, '/playlist/load', 'POST', payload);
res.json(out);
} catch (err) { next(err); }
});
router.post('/channels/:id/pause', requireChannelEdit, (req, res) => transport(req, res, '/transport/pause'));
router.post('/channels/:id/resume', requireChannelEdit, (req, res) => transport(req, res, '/transport/resume'));
router.post('/channels/:id/skip', requireChannelEdit, (req, res) => transport(req, res, '/transport/skip'));
router.post('/channels/:id/stop-playback', requireChannelEdit, (req, res) => transport(req, res, '/channel/stop'));
router.get('/channels/:id/asrun', async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT * FROM playout_as_run WHERE channel_id = $1 ORDER BY started_at DESC LIMIT 500`,
[req.channel.id]);
res.json(rows);
} catch (err) { next(err); }
});
async function loadChannelForBody(req, res, next) {
const channelId = req.body.channel_id || req.query.channel_id;
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
try {
const { rows } = await pool.query('SELECT * FROM playout_channels WHERE id = $1', [channelId]);
if (rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
req.channel = rows[0];
await assertProjectAccess(req.user, req.channel.project_id, 'edit');
next();
} catch (err) { next(err); }
}
router.get('/playlists', async (req, res, next) => {
try {
const channelId = req.query.channel_id;
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
const ch = await pool.query('SELECT project_id FROM playout_channels WHERE id = $1', [channelId]);
if (ch.rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
await assertProjectAccess(req.user, ch.rows[0].project_id, 'view');
const { rows } = await pool.query(
'SELECT * FROM playout_playlists WHERE channel_id = $1 ORDER BY created_at ASC', [channelId]);
res.json(rows);
} catch (err) { next(err); }
});
router.post('/playlists', loadChannelForBody, async (req, res, next) => {
try {
const { name, loop = false } = req.body || {};
if (!name) return res.status(400).json({ error: 'name is required' });
const { rows } = await pool.query(
'INSERT INTO playout_playlists (channel_id, name, loop) VALUES ($1,$2,$3) RETURNING *',
[req.channel.id, name.trim(), !!loop]);
res.status(201).json(rows[0]);
} catch (err) { next(err); }
});
router.get('/playlists/:plid/items', validateUuid('plid'), async (req, res, next) => {
try {
const pl = await pool.query(
`SELECT p.*, c.project_id FROM playout_playlists p
JOIN playout_channels c ON c.id = p.channel_id WHERE p.id = $1`, [req.params.plid]);
if (pl.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
await assertProjectAccess(req.user, pl.rows[0].project_id, 'view');
const { rows } = await pool.query(
`SELECT i.*, a.filename AS clip_name, a.duration_ms AS asset_duration_ms
FROM playout_items i JOIN assets a ON a.id = i.asset_id
WHERE i.playlist_id = $1 ORDER BY i.sort_order ASC`, [req.params.plid]);
res.json(rows);
} catch (err) { next(err); }
});
async function loadPlaylistEdit(plid, user) {
const pl = await pool.query(
`SELECT p.*, c.project_id FROM playout_playlists p
JOIN playout_channels c ON c.id = p.channel_id WHERE p.id = $1`, [plid]);
if (pl.rows.length === 0) { throw Object.assign(new Error('Playlist not found'), { httpStatus: 404 }); }
await assertProjectAccess(user, pl.rows[0].project_id, 'edit');
return pl.rows[0];
}
router.post('/playlists/:plid/items', validateUuid('plid'), async (req, res, next) => {
try {
await loadPlaylistEdit(req.params.plid, req.user);
const { asset_id, in_point = null, out_point = null,
transition = 'cut', transition_ms = 0 } = req.body || {};
if (!asset_id) return res.status(400).json({ error: 'asset_id is required' });
const ord = await pool.query(
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM playout_items WHERE playlist_id = $1',
[req.params.plid]);
const { rows } = await pool.query(
`INSERT INTO playout_items (playlist_id, asset_id, sort_order, in_point, out_point, transition, transition_ms)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[req.params.plid, asset_id, ord.rows[0].next, in_point, out_point, transition, transition_ms]);
await stageQueue.add('stage', { itemId: rows[0].id, assetId: asset_id }).catch((e) =>
console.error('[playout] failed to enqueue stage job:', e.message));
res.status(201).json(rows[0]);
} catch (err) {
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
next(err);
}
});
router.put('/playlists/:plid/reorder', validateUuid('plid'), async (req, res, next) => {
const client = await pool.connect();
try {
await loadPlaylistEdit(req.params.plid, req.user);
const { order } = req.body || {};
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item ids' });
await client.query('BEGIN');
for (let i = 0; i < order.length; i++) {
await client.query(
'UPDATE playout_items SET sort_order = $1, updated_at = NOW() WHERE id = $2 AND playlist_id = $3',
[i, order[i], req.params.plid]);
}
await client.query('COMMIT');
res.json({ reordered: order.length });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
next(err);
} finally { client.release(); }
});
router.delete('/items/:itemId', validateUuid('itemId'), async (req, res, next) => {
try {
const it = await pool.query(
`SELECT i.id, c.project_id FROM playout_items i
JOIN playout_playlists p ON p.id = i.playlist_id
JOIN playout_channels c ON c.id = p.channel_id WHERE i.id = $1`, [req.params.itemId]);
if (it.rows.length === 0) return res.status(404).json({ error: 'Item not found' });
await assertProjectAccess(req.user, it.rows[0].project_id, 'edit');
await pool.query('DELETE FROM playout_items WHERE id = $1', [req.params.itemId]);
res.json({ deleted: true });
} catch (err) { next(err); }
});
router.post('/items/:itemId/stage', validateUuid('itemId'), async (req, res, next) => {
try {
const it = await pool.query(
`SELECT i.id, i.asset_id, c.project_id FROM playout_items i
JOIN playout_playlists p ON p.id = i.playlist_id
JOIN playout_channels c ON c.id = p.channel_id WHERE i.id = $1`, [req.params.itemId]);
if (it.rows.length === 0) return res.status(404).json({ error: 'Item not found' });
await assertProjectAccess(req.user, it.rows[0].project_id, 'edit');
await pool.query("UPDATE playout_items SET media_status = 'pending' WHERE id = $1", [req.params.itemId]);
await stageQueue.add('stage', { itemId: it.rows[0].id, assetId: it.rows[0].asset_id });
res.json({ queued: true });
} catch (err) { next(err); }
});
export async function restartChannel(channelId) {
const { rows } = await pool.query('SELECT * FROM playout_channels WHERE id = $1', [channelId]);
if (rows.length === 0) return { restarted: false, reason: 'channel not found' };
const channel = rows[0];
if (channel.output_type === 'decklink') {
return { restarted: false, reason: 'decklink channels are alert-only' };
}
if (channel.container_id) {
const { remote, apiUrl } = await resolveNodeTarget(channel.node_id);
if (remote && apiUrl) {
await fetch(`${apiUrl}/sidecar/stop`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ containerId: channel.container_id }),
signal: AbortSignal.timeout(10000),
}).catch(() => {});
} else {
await dockerApi('DELETE', `/containers/${channel.container_id}?force=true`).catch(() => {});
}
}
const nodes = await pool.query(
`SELECT id, hostname, api_url, last_seen_at FROM cluster_nodes
WHERE id <> $1 AND last_seen_at > NOW() - INTERVAL '60 seconds'
ORDER BY last_seen_at DESC LIMIT 1`,
[channel.node_id]
);
if (nodes.rows.length === 0) {
await pool.query(
"UPDATE playout_channels SET status = 'error', error_message = $1 WHERE id = $2",
['no healthy node available for failover', channel.id]
);
return { restarted: false, reason: 'no eligible node' };
}
const newNodeId = nodes.rows[0].id;
const { rows: moved } = await pool.query(
`UPDATE playout_channels
SET node_id = $1, status = 'stopped', container_id = NULL, container_meta = '{}'::jsonb,
restart_count = restart_count + 1, last_restart_at = NOW(),
error_message = NULL, updated_at = NOW()
WHERE id = $2 RETURNING *`,
[newNodeId, channel.id]
);
try {
await spawnChannelSidecar(moved[0]);
return { restarted: true, new_node_id: newNodeId };
} catch (err) {
return { restarted: false, reason: `respawn failed: ${err.message}` };
}
}
export default router;

View file

@ -1,11 +1,12 @@
import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { requireAdmin } from '../middleware/auth.js';
import { accessibleProjectIds, assertProjectAccess } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// Helper function to slugify
const slugify = (str) => {
@ -17,18 +18,29 @@ const slugify = (str) => {
.replace(/-+/g, '-');
};
// GET / - List all projects
// GET / - List projects the caller can access (admins see all).
router.get('/', async (req, res, next) => {
try {
const access = await accessibleProjectIds(req.user);
if (access.all) {
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
return res.json(result.rows);
}
if (access.ids.size === 0) return res.json([]);
const ids = [...access.ids];
const result = await pool.query(
`SELECT * FROM projects WHERE id = ANY($1::uuid[]) ORDER BY created_at DESC`,
[ids]
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// POST / - Create project
router.post('/', async (req, res, next) => {
// POST / - Create project (admin only; new projects have no grants, so a
// scoped user could never reach one they just made).
router.post('/', requireAdmin, async (req, res, next) => {
try {
const { name, description } = req.body;
@ -52,10 +64,11 @@ router.post('/', async (req, res, next) => {
}
});
// GET /:id - Single project with asset count
// GET /:id - Single project with asset count (requires view access).
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
await assertProjectAccess(req.user, id, 'view');
const result = await pool.query(
`SELECT p.*,
@ -77,10 +90,11 @@ router.get('/:id', async (req, res, next) => {
}
});
// PATCH /:id - Update project
// PATCH /:id - Update project (requires edit access).
router.patch('/:id', async (req, res, next) => {
try {
const { id } = req.params;
await assertProjectAccess(req.user, id, 'edit');
const { name, description } = req.body;
const updates = [];
@ -123,8 +137,9 @@ router.patch('/:id', async (req, res, next) => {
}
});
// DELETE /:id - Delete project and cascade
router.delete('/:id', async (req, res, next) => {
// DELETE /:id - Delete project and cascade (admin only — destructive, wipes
// every asset/bin/recorder under it).
router.delete('/:id', requireAdmin, async (req, res, next) => {
try {
const { id } = req.params;
@ -144,4 +159,78 @@ router.delete('/:id', async (req, res, next) => {
}
});
// ── Per-project access grants (admin only) ──────────────────────────────────
// GET /:id/access — list grants with resolved user/group display names.
router.get('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT pa.subject_type, pa.subject_id, pa.level, pa.granted_at,
CASE pa.subject_type
WHEN 'user' THEN u.display_name
WHEN 'group' THEN g.name
END AS subject_name,
CASE pa.subject_type
WHEN 'user' THEN u.username
ELSE NULL
END AS username
FROM project_access pa
LEFT JOIN users u ON pa.subject_type = 'user' AND u.id = pa.subject_id
LEFT JOIN groups g ON pa.subject_type = 'group' AND g.id = pa.subject_id
WHERE pa.project_id = $1
ORDER BY pa.subject_type, subject_name`,
[req.params.id]
);
res.json(rows);
} catch (err) { next(err); }
});
// POST /:id/access { subject_type, subject_id, level } — grant or update.
router.post('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { subject_type, subject_id, level } = req.body || {};
if (!['user', 'group'].includes(subject_type)) {
return res.status(400).json({ error: "subject_type must be 'user' or 'group'" });
}
if (!subject_id) return res.status(400).json({ error: 'subject_id required' });
const lvl = level || 'view';
if (!['view', 'edit'].includes(lvl)) {
return res.status(400).json({ error: "level must be 'view' or 'edit'" });
}
// Validate the subject actually exists so we don't create dead grants.
const tbl = subject_type === 'user' ? 'users' : 'groups';
const exists = await pool.query(`SELECT 1 FROM ${tbl} WHERE id = $1`, [subject_id]);
if (exists.rows.length === 0) {
return res.status(404).json({ error: subject_type + ' not found' });
}
const { rows } = await pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level, granted_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, subject_type, subject_id)
DO UPDATE SET level = EXCLUDED.level, granted_by = EXCLUDED.granted_by, granted_at = NOW()
RETURNING project_id, subject_type, subject_id, level, granted_at`,
[req.params.id, subject_type, subject_id, lvl, req.user?.id || null]
);
res.status(201).json(rows[0]);
} catch (err) { next(err); }
});
// DELETE /:id/access/:subjectType/:subjectId — revoke a grant.
router.delete('/:id/access/:subjectType/:subjectId', requireAdmin, async (req, res, next) => {
try {
const { id, subjectType, subjectId } = req.params;
if (!['user', 'group'].includes(subjectType)) {
return res.status(400).json({ error: "subjectType must be 'user' or 'group'" });
}
const { rowCount } = await pool.query(
`DELETE FROM project_access
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3`,
[id, subjectType, subjectId]
);
if (rowCount === 0) return res.status(404).json({ error: 'grant not found' });
res.status(204).end();
} catch (err) { next(err); }
});
export default router;

View file

@ -1,12 +1,43 @@
import express from 'express';
import http from 'http';
import fs from 'fs';
import net from 'net';
import dgram from 'dgram';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { getS3Bucket } from '../s3/client.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
router.use(requireAuth);
// Every /:id recorder route is scoped to the recorder's project. The param
// handler validates the UUID, resolves the owning project_id, and asserts the
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
// A recorder with a NULL project_id resolves to admin-only (assertProjectAccess
// throws 403 for non-admins on a null project).
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM recorders WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
req.recorderProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.recorderProjectId, 'view');
next();
} catch (err) { next(err); }
});
async function requireRecorderEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.recorderProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// Base port for on-demand SDI sidecar containers on remote worker nodes.
// Device index 0 → 7438, index 1 → 7439, etc.
const SIDECAR_PORT_BASE = 7438;
// Docker API helper function
function dockerApi(method, path, body = null) {
@ -29,11 +60,31 @@ function dockerApi(method, path, body = null) {
});
});
req.on('error', reject);
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
req.setTimeout(10000, () => {
req.destroy(new Error('Docker API timeout after 10s'));
});
if (body) req.write(JSON.stringify(body));
req.end();
});
}
// Look up the cluster node for a recorder and decide if it is remote.
// Returns { remote: false } when the node is local or unset;
// { remote: true, apiUrl, ip } when it is a different host.
async function resolveNodeTarget(nodeId) {
if (!nodeId) return { remote: false };
const r = await pool.query(
'SELECT hostname, ip_address, api_url FROM cluster_nodes WHERE id = $1',
[nodeId]
);
if (r.rows.length === 0) return { remote: false };
const node = r.rows[0];
const localHostname = process.env.NODE_HOSTNAME || '';
if (!node.api_url || node.hostname === localHostname) return { remote: false };
return { remote: true, apiUrl: node.api_url, ip: node.ip_address };
}
// Helper function to generate clip name with timestamp
function generateClipName(recorderName) {
const now = new Date();
@ -43,12 +94,31 @@ function generateClipName(recorderName) {
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`;
// Strip filesystem-hostile characters out of the recorder name (spaces
// become underscores, anything outside [A-Za-z0-9._-] is dropped) so the
// clipName flows cleanly through S3 keys, SMB paths, and ffmpeg args.
const safe = String(recorderName || 'rec')
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9._-]/g, '')
.slice(0, 40) || 'rec';
return `${safe}_${year}${month}${day}_${hours}${minutes}${seconds}`;
}
// Sanitize an operator-provided clip name so it's safe as both an S3 key
// segment and an SMB/POSIX filename. Allow letters, digits, dot, dash,
// underscore, and spaces; collapse runs of whitespace; cap at 80 chars.
function sanitizeClipName(raw) {
if (typeof raw !== 'string') return null;
const cleaned = raw
.replace(/[^A-Za-z0-9._\- ]+/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80);
return cleaned.length > 0 ? cleaned : null;
}
/**
* Build Docker PortBindings and ExposedPorts for listener-mode recorders.
* Returns { portBindings, exposedPorts } both empty objects for non-listener sources.
*/
function buildPortConfig(sourceType, sourceConfig) {
const portBindings = {};
@ -71,13 +141,79 @@ function buildPortConfig(sourceType, sourceConfig) {
return { portBindings, exposedPorts };
}
// Whitelist of recorder columns the API accepts on POST/PATCH. Keeping it
// explicit prevents accidental writes to status / container_id / timestamps.
const RECORDER_FIELDS = [
'name', 'source_type', 'source_config',
'recording_codec', 'recording_resolution',
'recording_video_bitrate', 'recording_framerate',
'recording_audio_codec', 'recording_audio_bitrate', 'recording_audio_channels',
'recording_container',
'proxy_enabled', 'proxy_codec', 'proxy_resolution',
'proxy_video_bitrate', 'proxy_framerate',
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
];
function pickRecorderFields(body) {
const out = {};
for (const k of RECORDER_FIELDS) {
if (body[k] !== undefined) out[k] = body[k];
}
return out;
}
// GET / - List all recorders
//
// Issue #121 — previous version fired N PG queries + N Docker inspects per
// list call. Now we resolve `live_asset_id` for every recording row in a
// single LATERAL JOIN, and the Docker `started_at` lookups are bounded by
// the number of currently-recording rows (typically <10) and run in
// parallel with a per-call timeout from `dockerApi`.
router.get('/', async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM recorders ORDER BY created_at DESC'
);
res.json(result.rows);
// Scope to recorders in projects the caller can access (admins unfiltered).
// Recorders with a NULL project are admin-only and never appear for scoped
// users (accessibleProjectIds never yields a null id).
const access = await accessibleProjectIds(req.user);
let scopeClause = '';
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
scopeClause = 'WHERE r.project_id = ANY($1::uuid[])';
params.push([...access.ids]);
}
const result = await pool.query(`
SELECT r.*, la.live_asset_id
FROM recorders r
LEFT JOIN LATERAL (
SELECT a.id AS live_asset_id
FROM assets a
WHERE r.status = 'recording'
AND a.project_id = r.project_id
AND a.display_name = r.current_session_id
AND a.status = 'live'
ORDER BY a.created_at DESC
LIMIT 1
) la ON TRUE
${scopeClause}
ORDER BY r.created_at DESC
`, params);
const rows = result.rows;
// Only inspect containers for recorders that actually claim to be recording.
const inspectable = rows.filter(r => r.status === 'recording' && r.container_id);
await Promise.all(inspectable.map(async (r) => {
try {
const insp = await dockerApi('GET', `/containers/${r.container_id}/json`);
if (insp.status === 200 && insp.data && insp.data.State) {
r.started_at = insp.data.State.StartedAt;
}
} catch (_) { /* leave started_at undefined */ }
}));
res.json(rows);
} catch (err) {
next(err);
}
@ -86,57 +222,51 @@ router.get('/', async (req, res, next) => {
// POST / - Create a new recorder
router.post('/', async (req, res, next) => {
try {
const {
name,
source_type,
source_config,
recording_codec,
recording_resolution,
proxy_enabled,
proxy_codec,
proxy_resolution,
project_id,
} = req.body;
const fields = pickRecorderFields(req.body);
if (!name || !source_type) {
if (!fields.name || !fields.source_type) {
return res
.status(400)
.json({ error: 'Name and source_type are required' });
}
const id = uuidv4();
// Creating a recorder writes into a project — require edit there. A recorder
// with no project_id is admin-only (assertProjectAccess denies non-admins on
// a null project).
await assertProjectAccess(req.user, fields.project_id ?? null, 'edit');
// Defaults — written on insert so the DB row is always self-contained.
const defaults = {
source_config: {},
recording_codec: 'hevc_nvenc',
recording_resolution: 'native',
recording_audio_codec: 'pcm_s24le',
recording_audio_channels: 2,
recording_container: 'mov',
proxy_enabled: true,
proxy_codec: 'h264',
proxy_resolution: '1920x1080',
proxy_video_bitrate: '2M',
proxy_audio_codec: 'aac',
proxy_audio_bitrate: '128k',
proxy_audio_channels: 2,
proxy_container: 'mp4',
};
const row = { id: uuidv4(), status: 'stopped', ...defaults, ...fields };
// Build INSERT dynamically so adding columns later means one place to update.
const cols = Object.keys(row);
const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
const values = cols.map(k => {
const v = row[k];
if (k === 'source_config') return v && typeof v === 'object' ? v : {};
return v;
});
const result = await pool.query(
`INSERT INTO recorders (
id,
name,
source_type,
source_config,
recording_codec,
recording_resolution,
proxy_enabled,
proxy_codec,
proxy_resolution,
project_id,
status,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
`INSERT INTO recorders (${cols.join(', ')}, created_at, updated_at)
VALUES (${placeholders}, NOW(), NOW())
RETURNING *`,
[
id,
name,
source_type,
source_config || {},
recording_codec || 'prores_hq',
recording_resolution || 'native',
proxy_enabled !== false,
proxy_codec || 'libx264',
proxy_resolution || '1920x1080',
project_id || null,
'stopped',
]
values
);
res.status(201).json(result.rows[0]);
@ -165,12 +295,51 @@ router.get('/:id', async (req, res, next) => {
}
});
// POST /:id/start - Start recording
router.post('/:id/start', async (req, res, next) => {
// PATCH /:id - Edit recorder settings
// Blocked while recorder is actively recording to prevent config drift.
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1',
[id]
);
if (recorderResult.rows.length === 0) {
return res.status(404).json({ error: 'Recorder not found' });
}
const recorder = recorderResult.rows[0];
if (recorder.status === 'recording') {
return res.status(409).json({ error: 'Cannot edit a recorder while it is recording — stop it first' });
}
const fields = pickRecorderFields(req.body);
const cols = Object.keys(fields);
if (cols.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
const setClause = cols.map((k, i) => `${k} = $${i + 1}`).join(', ');
const params = cols.map(k => fields[k]);
params.push(id);
const result = await pool.query(
`UPDATE recorders SET ${setClause}, updated_at = NOW() WHERE id = $${params.length} RETURNING *`,
params
);
res.json(result.rows[0]);
} catch (err) {
next(err);
}
});
// POST /:id/start - Start recording
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
// Get recorder config from DB
const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1',
[id]
@ -186,27 +355,67 @@ router.post('/:id/start', async (req, res, next) => {
return res.status(400).json({ error: 'Recorder is already recording' });
}
// Get S3 config from environment
const s3Endpoint = process.env.S3_ENDPOINT;
const s3Bucket = process.env.S3_BUCKET;
const s3Bucket = getS3Bucket(); // Use live config, not stale env snapshot (#61)
const s3AccessKey = process.env.S3_ACCESS_KEY;
const s3SecretKey = process.env.S3_SECRET_KEY;
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
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';
// Generate clip name with timestamp
const clipName = generateClipName(recorder.name);
// 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;
// Operator-supplied clip name wins over the auto-timestamped fallback.
// The Recorders UI passes this on the start request when the user types
// something into the "Clip name" field; otherwise it's blank and we
// generate `<recorder>_<timestamp>` as before.
const customClipName = sanitizeClipName(req.body && req.body.clipName);
const clipName = customClipName || generateClipName(recorder.name);
// Per-take project override: the Recorders UI can pass projectId on the
// start request to send clips to a different project than the recorder's
// default. Falls back to the recorder's configured project_id.
const takeProjectId = (req.body && req.body.projectId && typeof req.body.projectId === 'string')
? req.body.projectId
: recorder.project_id;
// requireRecorderEdit only covered the recorder's own project. If this take
// is being routed into a DIFFERENT project, the caller must have edit there
// too — otherwise edit on recorder A's project would let them write live
// assets into any project B.
if (takeProjectId !== recorder.project_id) {
await assertProjectAccess(req.user, takeProjectId, 'edit');
}
// live-asset: create the asset row right now (status='live') so the
// library shows the recording while it is happening.
const assetIdLive = uuidv4();
try {
const ext = recorder.recording_container || 'mov';
await pool.query(
`INSERT INTO assets (
id, project_id, bin_id, filename, display_name, status, media_type,
original_s3_key, created_at, updated_at
) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
[assetIdLive, takeProjectId, clipName, `projects/${takeProjectId}/masters/${clipName}.${ext}`]
);
} catch (e) {
console.warn('[recorders] could not pre-create live asset:', e.message);
}
// Determine source config and whether this is a listener-mode recorder
const sourceConfig = recorder.source_config || {};
const isListener = sourceConfig.mode === 'listener';
const sourceType = recorder.source_type;
const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
// Build port bindings for listener-mode SRT/RTMP containers
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
// Build container environment — pass all source params so the capture
// service can auto-start recording on container startup
// Build container env — all codec controls flow through here.
const env = [
`S3_ENDPOINT=${s3Endpoint}`,
`S3_BUCKET=${s3Bucket}`,
@ -217,16 +426,44 @@ router.post('/:id/start', async (req, res, next) => {
`RECORDER_ID=${id}`,
`SOURCE_TYPE=${sourceType}`,
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
`RECORDING_CODEC=${recorder.recording_codec}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution}`,
`PROXY_ENABLED=${recorder.proxy_enabled}`,
`PROXY_CODEC=${recorder.proxy_codec}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution}`,
`PROJECT_ID=${recorder.project_id}`,
`DEVICE_INDEX=${deviceIndex}`,
// Recording codec controls
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
`RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
`RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
// Proxy codec controls
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
`PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
`PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
`PROJECT_ID=${takeProjectId}`,
`CLIP_NAME=${clipName}`,
`ASSET_ID=${assetIdLive}`,
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
`GROWING_PATH=/growing`,
];
// Add source-specific env vars for SRT/RTMP
// Deltacast: pass port count so the capture container can enumerate
// test-card slots even without physical /dev/deltacast* nodes.
if (sourceType === 'deltacast') {
const dcCount = process.env.DELTACAST_PORT_COUNT || sourceConfig.port_count || '';
if (dcCount) env.push(`DELTACAST_PORT_COUNT=${dcCount}`);
}
if (sourceType === 'srt' || sourceType === 'rtmp') {
env.push(`LISTEN=${isListener ? '1' : '0'}`);
if (isListener) {
@ -239,42 +476,115 @@ router.post('/:id/start', async (req, res, next) => {
}
}
// Build container config
const containerConfig = {
Image: 'wild-dragon-capture:latest',
Env: env,
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
HostConfig: {
// GPU-accelerated codecs require the NVIDIA container runtime on the node.
// hevc_nvenc / h264_nvenc are the only two we currently support; extend
// this list if av1_nvenc or others are added later.
const GPU_CODECS = ['hevc_nvenc', 'h264_nvenc'];
const useGpu = GPU_CODECS.includes(recorder.recording_codec);
// Determine whether to spawn locally or via a remote node-agent.
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
// For remote sidecars, the capture container runs on the worker host network and cannot
// resolve the Docker-internal mam-api hostname — replace with the external URL.
if (isRemote) {
const idx = env.findIndex(e => e.startsWith('MAM_API_URL='));
if (idx !== -1) env[idx] = `MAM_API_URL=${externalMamApiUrl}`;
}
let containerId;
if (isRemote) {
// Remote node: delegate container lifecycle to that node's agent.
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType, useGpu }),
signal: AbortSignal.timeout(15000),
});
if (!sidecarRes.ok) {
// #105 — never proxy the remote node's raw response back to the
// browser; it could contain echoed env vars on bad-request paths.
const details = await sidecarRes.json().catch(() => ({}));
console.error('[recorders] remote sidecar start failed:', JSON.stringify(details));
return res.status(502).json({
error: 'Remote node failed to start sidecar',
details: (details && details.message) || 'see server logs',
});
}
const sidecarData = await sidecarRes.json();
containerId = sidecarData.containerId;
} else {
// Local spawn via Docker socket.
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
const alias = `recorder-${id}`;
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
if (sourceType === 'deltacast') {
// Bind each /dev/deltacast* device node the host has into the container.
// The capture service falls back to test-card if none are present.
try {
const { readdirSync } = await import('node:fs');
const dcEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
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');
const localEnv = [...env];
if (useGpu) {
localEnv.push('NVIDIA_VISIBLE_DEVICES=all');
localEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
}
const localHostConfig = {
Privileged: true,
NetworkMode: dockerNetwork,
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
},
Hostname: `recorder-${recorder.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`,
Binds: hostBinds,
...(useGpu && {
Runtime: 'nvidia',
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
}),
};
// Create container
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
const containerConfig = {
Image: 'wild-dragon-capture:latest',
Env: localEnv,
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
HostConfig: localHostConfig,
NetworkingConfig: {
EndpointsConfig: {
[dockerNetwork]: { Aliases: [alias] },
},
},
Hostname: alias,
};
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
if (createRes.status !== 201) {
// Issue #105 — log the full Docker error server-side, but never echo
// the create payload (which contains S3_ACCESS_KEY / STREAM_KEY in
// Env) back to the client. Send a short, generic message.
console.error('[recorders] container create failed:', JSON.stringify(createRes.data));
return res.status(500).json({
error: 'Failed to create container',
details: createRes.data,
details: (createRes.data && createRes.data.message) || 'see server logs',
});
}
const containerId = createRes.data.Id;
// Start container
containerId = createRes.data.Id;
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
console.error('[recorders] container start failed:', JSON.stringify(startRes.data));
return res.status(500).json({
error: 'Failed to start container',
details: startRes.data,
details: (startRes.data && startRes.data.message) || 'see server logs',
});
}
}
// Update recorder in DB
const updateResult = await pool.query(
`UPDATE recorders
SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW()
@ -290,11 +600,10 @@ router.post('/:id/start', async (req, res, next) => {
});
// POST /:id/stop - Stop recording
router.post('/:id/stop', async (req, res, next) => {
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
// Get recorder from DB
const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1',
[id]
@ -307,24 +616,41 @@ router.post('/:id/stop', async (req, res, next) => {
const recorder = recorderResult.rows[0];
if (!recorder.container_id) {
return res.status(400).json({ error: 'No container running' });
// No container tracked — reset stuck status gracefully.
const result = await pool.query(
`UPDATE recorders SET container_id = NULL, status = 'stopped', updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id]
);
return res.json(result.rows[0]);
}
// Stop container
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
if (isRemote) {
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
method: 'DELETE',
signal: AbortSignal.timeout(15000),
});
if (!stopRes.ok && stopRes.status !== 404) {
return res.status(502).json({ error: 'Remote node failed to stop sidecar' });
}
} else {
const stopRes = await dockerApi(
'POST',
`/containers/${recorder.container_id}/stop`
);
// 204 = stopped, 304 = already stopped — both are acceptable
if (stopRes.status !== 204 && stopRes.status !== 304) {
// 204 = stopped, 304 = already stopped, 404 = container gone — all acceptable.
if (stopRes.status !== 204 && stopRes.status !== 304 && stopRes.status !== 404) {
return res.status(500).json({
error: 'Failed to stop container',
details: stopRes.data,
});
}
// Remove container — 204 = removed, 404 = already gone (both acceptable)
// Only attempt remove if the container existed (not 404).
if (stopRes.status !== 404) {
const removeRes = await dockerApi(
'DELETE',
`/containers/${recorder.container_id}`
@ -336,8 +662,9 @@ router.post('/:id/stop', async (req, res, next) => {
details: removeRes.data,
});
}
}
}
// Update recorder in DB
const updateResult = await pool.query(
`UPDATE recorders
SET container_id = NULL, status = $1, updated_at = NOW()
@ -357,7 +684,6 @@ router.get('/:id/status', async (req, res, next) => {
try {
const { id } = req.params;
// Get recorder from DB
const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1',
[id]
@ -377,7 +703,30 @@ router.get('/:id/status', async (req, res, next) => {
});
}
// Query Docker API for container status
const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0);
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
let isRunning = false;
let duration = 0;
let signal = 'connecting';
let signalKnown = false;
let live = null;
if (isRemote) {
try {
const statusRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}/status`, {
signal: AbortSignal.timeout(4000),
});
if (statusRes.ok) {
const data = await statusRes.json();
isRunning = data.running;
if (data.startedAt) {
duration = Math.floor((Date.now() - new Date(data.startedAt).getTime()) / 1000);
}
live = data.live;
}
} catch (_) { /* node unreachable */ }
} else {
const inspectRes = await dockerApi(
'GET',
`/containers/${recorder.container_id}/json`
@ -392,14 +741,29 @@ router.get('/:id/status', async (req, res, next) => {
}
const container = inspectRes.data;
const startedAt = new Date(container.State.StartedAt).getTime();
const now = Date.now();
const duration = Math.floor((now - startedAt) / 1000);
isRunning = container.State.Running;
duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
try {
const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
if (captureRes.ok) live = await captureRes.json();
} catch (_) { /* not ready yet */ }
}
if (isRunning) signal = 'receiving';
if (!isRunning) signal = 'stopped';
if (live && live.signal) { signal = live.signal; signalKnown = true; }
res.json({
status: container.State.Running ? 'recording' : 'stopped',
status: isRunning ? 'recording' : 'stopped',
duration,
containerId: recorder.container_id,
signal,
signalKnown,
framesReceived: live ? live.framesReceived : null,
currentFps: live ? live.currentFps : null,
lastFrameAt: live ? live.lastFrameAt : null,
lastError: live ? live.lastError : null,
});
} catch (err) {
next(err);
@ -407,11 +771,10 @@ router.get('/:id/status', async (req, res, next) => {
});
// DELETE /:id - Delete recorder
router.delete('/:id', async (req, res, next) => {
router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
// Get recorder from DB
const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1',
[id]
@ -423,17 +786,23 @@ router.delete('/:id', async (req, res, next) => {
const recorder = recorderResult.rows[0];
// If recording, stop the container first
if (recorder.container_id) {
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
try {
if (isRemote) {
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
method: 'DELETE',
signal: AbortSignal.timeout(10000),
});
} else {
await dockerApi('POST', `/containers/${recorder.container_id}/stop`);
await dockerApi('DELETE', `/containers/${recorder.container_id}`);
}
} catch (err) {
console.error('Error stopping container during delete:', err);
}
}
// Delete from DB
const deleteResult = await pool.query(
'DELETE FROM recorders WHERE id = $1 RETURNING *',
[id]
@ -445,4 +814,161 @@ router.delete('/:id', async (req, res, next) => {
}
});
// Issue #104 — limit probe targets so an authed user can't scan the cluster's
// internal services (Docker socket, DB, metadata endpoints).
const ALLOWED_PROBE_SCHEMES = new Set(['srt', 'rtmp', 'rtmps', 'rtsp', 'udp', 'rtp']);
const BLOCKED_PROBE_PORTS = new Set([22, 25, 53, 80, 443, 5432, 6379, 9000, 9100, 9229]);
function isPrivateOrLoopback(host) {
if (!host) return true;
const h = host.toLowerCase();
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal')) return true;
// Hostname lookups happen later by the socket; here we just bail on the
// obvious cases. IPv4 private ranges + IPv6 link-local + AWS metadata IP.
if (/^127\./.test(h)) return true;
if (/^10\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return true;
if (/^169\.254\./.test(h)) return true; // link-local / AWS metadata
if (/^100\.6[4-9]\./.test(h) || /^100\.[7-9]\d\./.test(h) || /^100\.1[0-1]\d\./.test(h) || /^100\.12[0-7]\./.test(h)) return true;
if (/^0\./.test(h) || /^::1$/.test(h) || /^fe80:/.test(h) || /^fc/.test(h) || /^fd/.test(h)) return true;
return false;
}
function isAdmin(req) {
if (process.env.AUTH_ENABLED !== 'true') return true;
return req.user?.role === 'admin';
}
// POST /probe - Probe a source URL for reachability.
// Tries the capture service first; falls back to basic TCP/UDP connectivity
// check when capture is not running.
router.post('/probe', async (req, res) => {
const { source_type, url } = req.body || {};
// Validate URL up-front so we don't even let the capture service see junk.
let parsed = null;
if (url) {
try { parsed = new URL(url); }
catch { return res.status(400).json({ error: 'Invalid URL' }); }
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
}
// Non-admin users can only probe public hostnames. Admins may probe LAN.
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
}
}
// Try the capture service first (5s timeout)
try {
const r = await fetch('http://capture:3001/capture/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body || {}),
signal: AbortSignal.timeout(5000),
});
const data = await r.json().catch(() => ({}));
return res.status(r.status).json(data);
} catch (_) {
// capture service not running — fall through to basic connectivity probe
}
if (!parsed) {
return res.json({
reachable: false,
mode: 'basic',
note: 'Capture service offline. Provide a URL for connectivity check.',
});
}
const host = parsed.hostname;
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
const isUdp = proto === 'srt' || source_type === 'srt';
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
if (BLOCKED_PROBE_PORTS.has(port) && !isAdmin(req)) {
return res.status(403).json({ error: `Port ${port} is not permitted for probe (#104)` });
}
const reachable = await (isUdp ? probeUdp(host, port) : probeTcp(host, port));
return res.json({
reachable,
mode: 'basic',
note: `Capture service offline · ${isUdp ? 'UDP' : 'TCP'} connectivity check only`,
...(reachable
? { source: `${host}:${port}` }
: { error: `${host}:${port} did not respond` }
),
});
});
function probeTcp(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
sock.setTimeout(4000);
sock.connect(port, host, () => finish(true));
sock.on('error', () => finish(false));
sock.on('timeout', () => finish(false));
});
}
function probeUdp(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (ok) => {
if (done) return;
done = true;
try { sock.close(); } catch (_) {}
resolve(ok);
};
// ICMP port-unreachable will fire sock.on('error') within ~100ms if nothing is listening
sock.on('error', () => finish(false));
sock.send(Buffer.alloc(16, 0), 0, 16, port, host, (err) => {
if (err) return finish(false);
// No ICMP error after 2.5s → assume something is listening
setTimeout(() => finish(true), 2500);
});
setTimeout(() => finish(false), 5000);
});
}
// GET /:id/live/* — reverse-proxy the live HLS preview from the recorder's node.
// Remote recorders: segments live on the worker node, served by its node-agent
// (/live/...). Local recorders: served from this host's /live mount. Browser
// media requests carry the session cookie (same-origin) so auth passes.
router.get('/:id/live/:rest(*)', async (req, res, next) => {
try {
const { id } = req.params;
const rest = req.params.rest;
if (!rest || rest.includes('..')) return res.status(400).end();
const rec = await pool.query('SELECT node_id FROM recorders WHERE id = $1', [id]);
if (rec.rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
const ct = rest.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl'
: rest.endsWith('.ts') ? 'video/mp2t'
: 'application/octet-stream';
res.set('Cache-Control', 'no-cache');
res.set('Content-Type', ct);
const target = await resolveNodeTarget(rec.rows[0].node_id);
if (!target.remote) {
return fs.readFile('/live/' + rest, (err, data) => {
if (err) return res.status(404).end();
res.end(data);
});
}
const base = String(target.apiUrl).replace(/\/$/, '');
const upstream = await fetch(`${base}/live/${rest}`).catch(() => null);
if (!upstream || !upstream.ok) return res.status(upstream ? upstream.status : 502).end();
res.end(Buffer.from(await upstream.arrayBuffer()));
} catch (err) { next(err); }
});
export default router;

View file

@ -0,0 +1,157 @@
// Recorder scheduler — CRUD for upcoming + historic recording windows.
//
// The actual start/stop transitions happen in src/scheduler.js; this route
// just owns the recorder_schedules rows.
import express from 'express';
import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js';
const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
function rowToJson(r) {
return {
id: r.id,
name: r.name,
recorder_id: r.recorder_id,
recorder_name: r.recorder_name || null,
start_at: r.start_at,
end_at: r.end_at,
recurrence: r.recurrence,
status: r.status,
last_asset_id: r.last_asset_id,
error_message: r.error_message,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
const ALLOWED_STATUS_FILTER = new Set(['all', 'upcoming', 'past']);
// GET /api/v1/schedules?status=upcoming|past|all
router.get('/', async (req, res, next) => {
try {
const status = (req.query.status || 'all').toLowerCase();
if (!ALLOWED_STATUS_FILTER.has(status)) {
return res.status(400).json({ error: `status must be one of: ${[...ALLOWED_STATUS_FILTER].join(', ')}` });
}
let where = 'TRUE';
if (status === 'upcoming') where = `(s.status IN ('pending','running') OR s.end_at >= NOW() - INTERVAL '1 hour')`;
else if (status === 'past') where = `s.status IN ('completed','failed','cancelled') AND s.end_at < NOW()`;
const result = await pool.query(
`SELECT s.*, r.name AS recorder_name
FROM recorder_schedules s
LEFT JOIN recorders r ON r.id = s.recorder_id
WHERE ${where}
ORDER BY s.start_at ASC
LIMIT 200`
);
res.json({ schedules: result.rows.map(rowToJson) });
} catch (err) { next(err); }
});
// POST /api/v1/schedules
router.post('/', async (req, res, next) => {
try {
const { name, recorder_id, start_at, end_at, recurrence } = req.body || {};
if (!name || !recorder_id || !start_at || !end_at) {
return res.status(400).json({ error: 'name, recorder_id, start_at and end_at are required' });
}
const rec = (recurrence || 'none').toLowerCase();
if (!ALLOWED_RECURRENCE.has(rec)) {
return res.status(400).json({ error: `recurrence must be one of: ${[...ALLOWED_RECURRENCE].join(', ')}` });
}
if (new Date(end_at) <= new Date(start_at)) {
return res.status(400).json({ error: 'end_at must be after start_at' });
}
// Make sure the recorder exists before binding to it.
const rExists = await pool.query('SELECT id FROM recorders WHERE id = $1', [recorder_id]);
if (rExists.rows.length === 0) {
return res.status(400).json({ error: 'Unknown recorder_id' });
}
const ins = await pool.query(
`INSERT INTO recorder_schedules (name, recorder_id, start_at, end_at, recurrence, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
RETURNING *`,
[name.trim(), recorder_id, start_at, end_at, rec]
);
res.status(201).json(rowToJson(ins.rows[0]));
} catch (err) { next(err); }
});
// PUT /api/v1/schedules/:id — edit a not-yet-started schedule
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const current = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
if (current.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
if (current.rows[0].status === 'running') {
return res.status(400).json({ error: 'Cannot edit a running schedule; cancel it first' });
}
const fields = ['name','start_at','end_at','recurrence'];
const updates = [];
const values = [];
let i = 1;
for (const f of fields) {
if (req.body[f] !== undefined) {
if (f === 'recurrence' && !ALLOWED_RECURRENCE.has(String(req.body[f]).toLowerCase())) {
return res.status(400).json({ error: 'invalid recurrence' });
}
updates.push(`${f} = $${i++}`);
values.push(req.body[f]);
}
}
if (updates.length === 0) return res.json(rowToJson(current.rows[0]));
updates.push('updated_at = NOW()');
values.push(id);
const result = await pool.query(
`UPDATE recorder_schedules SET ${updates.join(', ')} WHERE id = $${i} RETURNING *`,
values
);
res.json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// POST /api/v1/schedules/:id/cancel — cancel a pending or running schedule
router.post('/:id/cancel', async (req, res, next) => {
try {
const { id } = req.params;
const cur = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
if (TERMINAL.has(cur.rows[0].status)) {
return res.status(400).json({ error: `Schedule is already ${cur.rows[0].status}` });
}
// Just mark as cancelled — the tick loop will stop the recorder if it's
// currently running and the schedule has just been cancelled.
const result = await pool.query(
`UPDATE recorder_schedules SET status = 'cancelled', updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id]
);
res.json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// DELETE /api/v1/schedules/:id — hard delete (terminal schedules only)
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const cur = await pool.query('SELECT status FROM recorder_schedules WHERE id = $1', [id]);
if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
if (!TERMINAL.has(cur.rows[0].status) && cur.rows[0].status !== 'pending') {
return res.status(400).json({ error: 'Cancel a running schedule before deleting' });
}
await pool.query('DELETE FROM recorder_schedules WHERE id = $1', [id]);
res.json({ message: 'Schedule deleted' });
} catch (err) { next(err); }
});
export default router;

View file

@ -0,0 +1,208 @@
// Capture SDK deployment — Blackmagic / AJA / Deltacast.
//
// Vendor SDKs are licensed and not redistributable, so they can't ship in
// the repo. This route lets the operator upload an SDK archive through the
// Settings UI; we extract it under /sdk/<vendor>/ (bind-mounted into mam-api)
// so the capture image build can pick the files up.
//
// Today the Dockerfile only wires Blackmagic into FFmpeg via patch_decklink.py;
// AJA and Deltacast files are staged for the next image revision but don't yet
// produce a working FFmpeg build — see the issue tracker.
import express from 'express';
import multer from 'multer';
import { promises as fs, createWriteStream } from 'fs';
import { spawn } from 'child_process';
import path from 'path';
const router = express.Router();
const SDK_ROOT = process.env.SDK_ROOT || '/sdk';
const VENDORS = {
blackmagic: {
name: 'Blackmagic DeckLink',
// Header that must be present once the archive is extracted
sentinel: 'DeckLinkAPI.h',
},
aja: {
name: 'AJA NTV2',
sentinel: 'ntv2card.h',
},
deltacast: {
name: 'Deltacast VideoMaster',
sentinel: 'VideoMasterHD_Core.h',
},
};
const upload = multer({
storage: multer.memoryStorage(),
// 512 MB ceiling — Blackmagic's full SDK is ~150 MB, plenty of headroom.
limits: { fileSize: 512 * 1024 * 1024 },
});
async function statusFor(vendor) {
const dir = path.join(SDK_ROOT, vendor);
try {
const entries = await listFilesRecursive(dir);
if (entries.length === 0) return { file_count: 0, uploaded_at: null };
const stat = await fs.stat(dir);
const sentinel = VENDORS[vendor].sentinel;
const hasSentinel = entries.some(p => p.endsWith('/' + sentinel) || p === sentinel);
return {
file_count: entries.length,
uploaded_at: stat.mtime.toISOString(),
sentinel_present: hasSentinel,
};
} catch {
return { file_count: 0, uploaded_at: null };
}
}
async function listFilesRecursive(dir, base = '') {
let out = [];
let entries;
try { entries = await fs.readdir(dir, { withFileTypes: true }); }
catch { return out; }
for (const e of entries) {
const full = path.join(dir, e.name);
const rel = base ? `${base}/${e.name}` : e.name;
if (e.isDirectory()) {
out = out.concat(await listFilesRecursive(full, rel));
} else if (e.isFile()) {
out.push(rel);
}
}
return out;
}
router.get('/', async (req, res, next) => {
try {
const out = {};
for (const vendor of Object.keys(VENDORS)) {
out[vendor] = await statusFor(vendor);
}
res.json(out);
} catch (err) { next(err); }
});
// Safe archive entry — only basic relative paths, no parent traversal, no symlinks.
function isUnsafeEntry(rel) {
if (!rel) return true;
if (path.isAbsolute(rel)) return true;
// Normalize without leaving the staging directory.
const normalized = path.posix.normalize(rel.replace(/\\/g, '/'));
if (normalized.startsWith('..') || normalized.includes('/../') || normalized === '..') return true;
return false;
}
router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
try {
const vendor = req.params.vendor;
if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
if (!req.file) return res.status(400).json({ error: 'No archive uploaded (field "archive")' });
const dir = path.join(SDK_ROOT, vendor);
const dirReal = path.resolve(dir);
// Wipe any previous staging so partial uploads don't leave stale headers.
await fs.rm(dir, { recursive: true, force: true });
await fs.mkdir(dir, { recursive: true });
// Issue #118 — never trust the client-supplied filename. Sanitise to a
// basename with no path separators, drop nul bytes, and force into `dir`.
const safeName = path.basename((req.file.originalname || 'sdk.bin').replace(/\u0000/g, '')) || 'sdk.bin';
const archivePath = path.join(dir, safeName);
await fs.writeFile(archivePath, req.file.buffer);
// Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz;
// unzip handles .zip. The capture container will be built separately on
// the host with a DeckLink/AJA/Deltacast card; this route just stages.
const lower = safeName.toLowerCase();
let cmd, args, listCmd, listArgs;
if (lower.endsWith('.zip')) {
cmd = 'unzip'; args = ['-q', '-o', archivePath, '-d', dir];
listCmd = 'unzip'; listArgs = ['-Z1', archivePath];
} else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
// --absolute-names=no would be ideal, but isn't portable. Block via
// post-extract scan + reject any entry with a parent-traversal path.
cmd = 'tar'; args = ['-xf', archivePath, '-C', dir];
listCmd = 'tar'; listArgs = ['-tf', archivePath];
} else {
return res.status(400).json({ error: 'Unsupported archive format — use .zip, .tar.gz, .tgz, or .tar' });
}
// Pre-flight: list entries and reject the upload if any escape the dir
// (zip-slip / tar-slip). Cheaper than extracting then deleting.
const entries = await new Promise((resolve, reject) => {
const child = spawn(listCmd, listArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', reject);
child.on('exit', code => {
if (code === 0) resolve(stdout.split('\n').map(s => s.trim()).filter(Boolean));
else reject(new Error(`${listCmd} listing exited ${code}: ${stderr.slice(0, 500)}`));
});
});
const bad = entries.find(isUnsafeEntry);
if (bad) {
await fs.unlink(archivePath).catch(() => {});
return res.status(400).json({ error: `Refusing archive with unsafe entry: ${bad}` });
}
await new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', reject);
child.on('exit', code => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited ${code}: ${stderr.slice(0, 500)}`));
});
});
// Defense-in-depth: walk the staged tree and remove anything that's not a
// regular file or directory (symlinks/device nodes can still escape).
async function walkAndSanitize(p) {
const entries = await fs.readdir(p, { withFileTypes: true });
for (const e of entries) {
const full = path.join(p, e.name);
const real = await fs.realpath(full).catch(() => null);
if (!real || !real.startsWith(dirReal + path.sep) && real !== dirReal) {
await fs.rm(full, { recursive: true, force: true });
continue;
}
if (e.isSymbolicLink() || (!e.isFile() && !e.isDirectory())) {
await fs.rm(full, { recursive: true, force: true });
continue;
}
if (e.isDirectory()) await walkAndSanitize(full);
}
}
await walkAndSanitize(dir);
// Best-effort: remove the archive after a successful extract so we only
// keep the unpacked headers/.so files on disk.
await fs.unlink(archivePath).catch(() => {});
const status = await statusFor(vendor);
res.json({ message: VENDORS[vendor].name + ' SDK staged.', status });
} catch (err) {
console.error('[sdk] upload failed:', err);
res.status(500).json({ error: err.message });
}
});
router.delete('/:vendor', async (req, res, next) => {
try {
const vendor = req.params.vendor;
if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
const dir = path.join(SDK_ROOT, vendor);
await fs.rm(dir, { recursive: true, force: true });
res.json({ message: VENDORS[vendor].name + ' SDK cleared.' });
} catch (err) { next(err); }
});
export default router;

View file

@ -2,38 +2,126 @@
import express from 'express';
import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js';
import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess } from '../auth/authz.js';
import { Queue } from 'bullmq';
const parseRedisUrl = (url) => {
try {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
} catch {
return { host: 'localhost', port: 6379 };
}
};
const conformQueue = new Queue('conform', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
const router = express.Router();
router.use(requireAuth);
// ── 59.94 DF timecode helpers (for EDL export) ────────────────────────────────
const NOM = 60; // nominal integer fps
const DROP = 4; // frames dropped per minute (except every 10th)
const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596
const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964
const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784
// Scope every /:id sequence route to its project: validate the UUID, resolve
// project_id, assert the 'view' baseline; mutators escalate via requireSequenceEdit.
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM sequences WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Sequence not found' });
req.sequenceProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.sequenceProjectId, 'view');
next();
} catch (err) { next(err); }
});
async function requireSequenceEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.sequenceProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// ── Row mapper ────────────────────────────────────────────────────────────────
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
// JS float before sending any sequence object to clients.
function mapSeq(row) {
if (!row) return row;
return { ...row, frame_rate: parseFloat(row.frame_rate) || 59.94 };
}
// ── Timecode helpers ──────────────────────────────────────────────────────────
//
// generateEDL emits CMX3600 timecode using the sequence's frame_rate.
//
// 29.97 fps → NTSC drop-frame (30 NOM, drop 2/min except every 10th) → ";"
// 59.94 fps → double-NTSC DF (60 NOM, drop 4/min except every 10th) → ";"
// all others → non-drop integer (24/25/30/50/60 …) → ":"
//
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }
function framesToTC(totalFrames) {
function framesToTC(totalFrames, fps) {
fps = parseFloat(fps) || 59.94;
const fc = Math.max(0, Math.round(totalFrames));
const h = Math.floor(fc / FRAMES_PER_HOUR);
let rem = fc % FRAMES_PER_HOUR;
const tm = Math.floor(rem / FRAMES_PER_10MIN);
rem = rem % FRAMES_PER_10MIN;
let m = 0;
if (rem >= DROP) {
m = Math.floor((rem - DROP) / FRAMES_PER_MIN) + 1;
rem = (rem - DROP) % FRAMES_PER_MIN;
// 29.97 DF ─ drop 2 frames per minute except every 10th
if (Math.abs(fps - 29.97) < 0.02) {
const NOM = 30, DROP = 2;
const FPM = NOM * 60 - DROP; // 1798
const FP10M = FPM * 10 + DROP; // 17982
const FPH = FP10M * 6; // 107892
const h = Math.floor(fc / FPH);
let rem = fc % FPH;
const tm = Math.floor(rem / FP10M);
rem = rem % FP10M;
let m, ss, ff;
if (rem < NOM * 60) {
m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM;
} else {
rem -= NOM * 60;
m = Math.floor(rem / FPM) + 1;
const adj = (rem % FPM) + DROP;
ss = Math.floor(adj / NOM); ff = adj % NOM;
}
const M = tm * 10 + m;
const s = Math.floor(rem / NOM);
const ff = rem % NOM;
return `${pad2(h)}:${pad2(M)}:${pad2(s)};${pad2(ff)}`;
return `${pad2(h)}:${pad2(tm * 10 + m)}:${pad2(ss)};${pad2(ff)}`;
}
function generateEDL(seqName, clips) {
// 59.94 DF ─ drop 4 frames per minute except every 10th
if (Math.abs(fps - 59.94) < 0.02) {
const NOM = 60, DROP = 4;
const FPM = NOM * 60 - DROP; // 3596
const FP10M = FPM * 10 + DROP; // 35964
const FPH = FP10M * 6; // 215784
const h = Math.floor(fc / FPH);
let rem = fc % FPH;
const tm = Math.floor(rem / FP10M);
rem = rem % FP10M;
let m, ss, ff;
if (rem < NOM * 60) {
m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM;
} else {
rem -= NOM * 60;
m = Math.floor(rem / FPM) + 1;
const adj = (rem % FPM) + DROP;
ss = Math.floor(adj / NOM); ff = adj % NOM;
}
return `${pad2(h)}:${pad2(tm * 10 + m)}:${pad2(ss)};${pad2(ff)}`;
}
// Non-drop frame (24, 23.976→24, 25, 30, 50, 60 …) ─ colon separator
const nomFps = Math.round(fps);
const ff = fc % nomFps;
const totalSec = Math.floor(fc / nomFps);
const ss = totalSec % 60;
const mm = Math.floor(totalSec / 60) % 60;
const hh = Math.floor(totalSec / 3600);
return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`;
}
function generateEDL(seqName, clips, fps) {
fps = parseFloat(fps) || 59.94;
const lines = [`TITLE: ${seqName}`, ''];
clips.forEach((c, i) => {
const num = String(i + 1).padStart(3, '0');
@ -43,10 +131,10 @@ function generateEDL(seqName, clips) {
.toUpperCase()
.substring(0, 32)
.padEnd(8);
const srcIn = framesToTC(c.source_in_frames);
const srcOut = framesToTC(c.source_out_frames);
const recIn = framesToTC(c.timeline_in_frames);
const recOut = framesToTC(c.timeline_out_frames);
const srcIn = framesToTC(c.source_in_frames, fps);
const srcOut = framesToTC(c.source_out_frames, fps);
const recIn = framesToTC(c.timeline_in_frames, fps);
const recOut = framesToTC(c.timeline_out_frames, fps);
lines.push(`${num} ${reel} V C ${srcIn} ${srcOut} ${recIn} ${recOut}`);
});
return lines.join('\n');
@ -57,11 +145,12 @@ router.get('/', async (req, res, next) => {
try {
const { project_id } = req.query;
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
await assertProjectAccess(req.user, project_id, 'view');
const r = await pool.query(
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
[project_id]
);
res.json(r.rows);
res.json(r.rows.map(mapSeq));
} catch (e) { next(e); }
});
@ -76,12 +165,13 @@ router.post('/', async (req, res, next) => {
height = 1080,
} = req.body;
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
await assertProjectAccess(req.user, project_id, 'edit');
const r = await pool.query(
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[project_id, name, frame_rate, width, height]
);
res.status(201).json(r.rows[0]);
res.status(201).json(mapSeq(r.rows[0]));
} catch (e) { next(e); }
});
@ -116,12 +206,12 @@ router.get('/:id', async (req, res, next) => {
})
);
res.json({ ...seqR.rows[0], clips });
res.json({ ...mapSeq(seqR.rows[0]), clips });
} catch (e) { next(e); }
});
// ── PUT /:id update sequence metadata ──────────────────────────────────────
router.put('/:id', async (req, res, next) => {
router.put('/:id', requireSequenceEdit, async (req, res, next) => {
try {
const { name, frame_rate, width, height } = req.body;
const updates = [];
@ -139,12 +229,12 @@ router.put('/:id', async (req, res, next) => {
params
);
if (!r.rows.length) return res.status(404).json({ error: 'Sequence not found' });
res.json(r.rows[0]);
res.json(mapSeq(r.rows[0]));
} catch (e) { next(e); }
});
// ── DELETE /:id ───────────────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
try {
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
@ -153,12 +243,14 @@ router.delete('/:id', async (req, res, next) => {
});
// ── PUT /:id/clips full replace of clip array (single transaction) ──────────
router.put('/:id/clips', async (req, res, next) => {
// Verify sequence exists first (before acquiring transaction client)
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
const clips = Array.isArray(req.body) ? req.body : [];
let client;
try {
// Verify sequence exists first (before acquiring transaction client).
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
const clips = Array.isArray(req.body) ? req.body : [];
for (const c of clips) {
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
@ -170,8 +262,22 @@ router.put('/:id/clips', async (req, res, next) => {
}
}
const client = await pool.connect();
try {
// Every referenced asset must belong to THIS sequence's project. Without this,
// a user with edit on the sequence could splice in assets from a project they
// can't access — and GET /:id would then hand back those assets' names and
// signed proxy URLs (cross-project leak).
const assetIds = [...new Set(clips.map(c => c.asset_id))];
if (assetIds.length) {
const owning = await pool.query(
`SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
[assetIds, req.sequenceProjectId]
);
if (owning.rows.length !== assetIds.length) {
return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
}
}
client = await pool.connect();
await client.query('BEGIN');
await client.query(
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
@ -198,10 +304,12 @@ router.put('/:id/clips', async (req, res, next) => {
await client.query('COMMIT');
res.json({ ok: true, count: clips.length });
} catch (e) {
await client.query('ROLLBACK');
// client is only set once we've connected; a failure in the pre-transaction
// queries (existence/validation/ownership) has no transaction to roll back.
if (client) await client.query('ROLLBACK').catch(() => {});
next(e);
} finally {
client.release();
if (client) client.release();
}
});
@ -210,7 +318,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
try {
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
const seq = seqR.rows[0];
const seq = mapSeq(seqR.rows[0]);
// Export V1 clips only (primary video track) sorted by position
const clipsR = await pool.query(
@ -222,7 +330,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
[req.params.id]
);
const edl = generateEDL(seq.name, clipsR.rows);
const edl = generateEDL(seq.name, clipsR.rows, seq.frame_rate);
const filename = `${seq.name.replace(/[^a-z0-9]/gi, '_')}.edl`;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
@ -230,4 +338,44 @@ router.post('/:id/export/edl', async (req, res, next) => {
} catch (e) { next(e); }
});
// ── POST /:id/conform conform sequence via FCP XML ─────────────────────────
// Accepts FCP XML content and encode settings from the Premiere plugin,
// queues a conform job in BullMQ, and returns the job ID for polling.
router.post('/:id/conform', requireSequenceEdit, async (req, res, next) => {
try {
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
const seq = mapSeq(seqR.rows[0]);
const { fcp_xml, codec = 'h264', quality = 'high', resolution = 'match', audio = 'include' } = req.body;
if (!fcp_xml) {
return res.status(400).json({ error: 'fcp_xml is required' });
}
const bullJob = await conformQueue.add('conform-task', {
fcpXml: fcp_xml,
sequenceId: req.params.id,
// The worker INSERTs the rendered output into the `assets` table at the
// end of the pipeline; project_id is NOT NULL on that table, so without
// this the conform finished successfully but failed at the very last
// step. Sequences live under projects, so the natural target for the
// rendered output is the sequence's own project.
projectId: seq.project_id,
sequenceName: seq.name,
frameRate: seq.frame_rate,
width: seq.width,
height: seq.height,
codec,
quality,
resolution,
audio,
});
res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,12 +1,127 @@
import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { getAmppConfig } from '../ampp/client.js';
import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';
const router = express.Router();
router.use(requireAuth);
// GET /api/v1/settings/ampp — Return current AMPP config (token value masked)
// ── S3 / Object Storage ───────────────────────────────────────────────────────
router.get('/s3', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT key, value FROM settings
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')`
);
// Env defaults — surface what's actually wired so the UI doesn't show
// "not configured" when the container was started with S3_* env vars.
const envEndpoint = process.env.S3_ENDPOINT || '';
const envBucket = process.env.S3_BUCKET || getS3Bucket();
const envAccessKey = process.env.S3_ACCESS_KEY || '';
const envSecretKey = process.env.S3_SECRET_KEY || '';
const envRegion = process.env.S3_REGION || 'us-east-1';
const out = {
s3_endpoint: envEndpoint,
s3_bucket: envBucket,
s3_access_key: envAccessKey,
s3_secret_key_exists: !!envSecretKey,
s3_region: envRegion,
source: envEndpoint ? 'env' : 'unset',
};
for (const { key, value } of result.rows) {
if (key === 's3_secret_key') {
if (value) { out.s3_secret_key_exists = true; out.source = 'db'; }
} else if (value) {
out[key] = value;
out.source = 'db';
}
}
res.json(out);
} catch (err) {
next(err);
}
});
router.put('/s3', async (req, res, next) => {
try {
const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
if (!s3_endpoint) return res.status(400).json({ error: 's3_endpoint is required' });
if (!s3_bucket) return res.status(400).json({ error: 's3_bucket is required' });
const keys = { s3_endpoint, s3_bucket, s3_region: s3_region || 'us-east-1' };
if (s3_access_key) keys.s3_access_key = s3_access_key;
if (s3_secret_key) keys.s3_secret_key = s3_secret_key;
for (const [key, value] of Object.entries(keys)) {
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, value.trim()]
);
}
// Fetch the full current config (including any previously saved secret)
const saved = await pool.query(
`SELECT key, value FROM settings
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
AND value IS NOT NULL AND value <> ''`
);
const cfg = {};
for (const { key, value } of saved.rows) {
switch (key) {
case 's3_endpoint': cfg.endpoint = value; break;
case 's3_bucket': cfg.bucket = value; break;
case 's3_access_key': cfg.accessKey = value; break;
case 's3_secret_key': cfg.secretKey = value; break;
case 's3_region': cfg.region = value; break;
}
}
rebuildS3Client(cfg);
res.json({ message: 'S3 settings saved and applied' });
} catch (err) {
next(err);
}
});
// Test with the values the browser just typed (before saving), or with saved
// creds if the fields are left blank. Secret is sent only if user typed it.
router.post('/s3/test', async (req, res, next) => {
try {
const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
// Merge submitted values with anything already saved in the DB
const saved = await pool.query(
`SELECT key, value FROM settings
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
AND value IS NOT NULL AND value <> ''`
);
const fromDb = {};
for (const { key, value } of saved.rows) fromDb[key] = value;
const cfg = {
endpoint: (s3_endpoint || fromDb.s3_endpoint || '').trim(),
bucket: (s3_bucket || fromDb.s3_bucket || getS3Bucket()).trim(),
accessKey: (s3_access_key || fromDb.s3_access_key || '').trim(),
secretKey: (s3_secret_key || fromDb.s3_secret_key || '').trim(),
region: (s3_region || fromDb.s3_region || 'us-east-1').trim(),
};
if (!cfg.endpoint) return res.status(400).json({ error: 'S3 endpoint is required' });
if (!cfg.bucket) return res.status(400).json({ error: 'S3 bucket is required' });
const client = buildTestClient(cfg);
const result = await testS3Connection(client, cfg.bucket);
res.json(result);
} catch (err) {
res.status(400).json({ ok: false, error: err.message });
}
});
// ── AMPP integration ───────────────────────────────────────────────────────────
router.get('/ampp', async (req, res, next) => {
try {
const result = await pool.query(
@ -15,7 +130,7 @@ router.get('/ampp', async (req, res, next) => {
const out = {};
for (const row of result.rows) {
if (row.key === 'ampp_token') {
out.ampp_token_exists = true; // Never return the raw token
out.ampp_token_exists = true;
} else {
out[row.key] = row.value;
}
@ -26,62 +141,186 @@ router.get('/ampp', async (req, res, next) => {
}
});
// PUT /api/v1/settings/ampp — Save AMPP credentials
router.put('/ampp', async (req, res, next) => {
try {
const { ampp_base_url, ampp_token } = req.body;
if (!ampp_base_url) {
return res.status(400).json({ error: 'ampp_base_url is required' });
}
if (!ampp_base_url) return res.status(400).json({ error: 'ampp_base_url is required' });
const baseUrl = ampp_base_url.trim().replace(/\/$/, '');
await pool.query(
`INSERT INTO settings (key, value, updated_at)
VALUES ('ampp_base_url', $1, NOW())
`INSERT INTO settings (key, value, updated_at) VALUES ('ampp_base_url', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
[baseUrl]
);
if (ampp_token) {
await pool.query(
`INSERT INTO settings (key, value, updated_at)
VALUES ('ampp_token', $1, NOW())
`INSERT INTO settings (key, value, updated_at) VALUES ('ampp_token', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
[ampp_token.trim()]
);
}
res.json({ message: 'AMPP settings saved' });
} catch (err) {
next(err);
}
});
// POST /api/v1/settings/ampp/test — Verify AMPP connectivity
router.post('/ampp/test', async (req, res, next) => {
try {
const config = await getAmppConfig();
if (!config) {
return res.status(400).json({ error: 'AMPP credentials not configured' });
}
if (!config) return res.status(400).json({ error: 'AMPP credentials not configured' });
const testUrl = `${config.ampp_base_url}/api/v1/store/folder/folders?limit=1`;
const testRes = await fetch(testUrl, {
headers: {
Authorization: `Bearer ${config.ampp_token}`,
'Content-Type': 'application/json',
},
headers: { Authorization: `Bearer ${config.ampp_token}`, 'Content-Type': 'application/json' },
});
if (!testRes.ok) {
return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
}
if (!testRes.ok) return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
res.json({ message: 'AMPP connection successful' });
} catch (err) {
res.status(400).json({ error: `Connection failed: ${err.message}` });
}
});
// ── Hardware inventory ────────────────────────────────────────────────────────
router.get('/hardware', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT id, hostname, role, ip_address, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
FROM cluster_nodes
ORDER BY role, hostname`
);
const nodes = result.rows.map(n => ({
id: n.id,
hostname: n.hostname,
role: n.role,
ip_address: n.ip_address,
online: n.stale_seconds < 120,
capabilities: n.capabilities || {},
}));
res.json({ nodes });
} catch (err) {
next(err);
}
});
// ── GPU / Transcoding ─────────────────────────────────────────────────────────
router.get('/transcoding', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT key, value FROM settings
WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps','gpu_node',
'gpu_extension','gpu_framerate','gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
);
const out = {
gpu_transcode_enabled: 'false',
gpu_codec: 'h264_nvenc',
gpu_preset: 'p4',
gpu_bitrate_mbps: '8',
gpu_node: '',
gpu_extension: 'mp4',
gpu_framerate: 'passthrough',
gpu_rc_mode: 'cbr',
gpu_audio_codec: 'aac',
gpu_audio_bitrate_kbps: '192',
};
for (const { key, value } of result.rows) out[key] = value;
res.json(out);
} catch (err) {
next(err);
}
});
router.put('/transcoding', async (req, res, next) => {
try {
const allowed = [
'gpu_transcode_enabled', 'gpu_codec', 'gpu_preset', 'gpu_bitrate_mbps', 'gpu_node',
'gpu_extension', 'gpu_framerate', 'gpu_rc_mode', 'gpu_audio_codec', 'gpu_audio_bitrate_kbps',
];
for (const key of allowed) {
if (req.body[key] !== undefined) {
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, String(req.body[key])]
);
}
}
res.json({ message: 'Transcoding settings saved' });
} catch (err) {
next(err);
}
});
// ── Growing files (SMB landing zone) ─────────────────────────────────────────
// Lets capture write its master output to a fast local SMB share instead of
// streaming directly to S3. Premiere can mount the share and edit the file
// 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'];
router.get('/growing', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`,
[GROWING_KEYS]
);
const out = {
growing_enabled: 'false',
growing_path: '/growing',
growing_smb_url: '',
growing_promote_after_seconds: '8',
};
for (const { key, value } of result.rows) out[key] = value;
res.json(out);
} catch (err) {
next(err);
}
});
router.put('/growing', async (req, res, next) => {
try {
for (const key of GROWING_KEYS) {
if (req.body[key] !== undefined) {
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, String(req.body[key])]
);
}
}
res.json({ message: 'Growing-files settings saved' });
} catch (err) {
next(err);
}
});
// ── Capture service routing ───────────────────────────────────────────────────
router.get('/capture-service', async (req, res, next) => {
try {
const result = await pool.query(
"SELECT value FROM settings WHERE key = 'capture_service_url'"
);
res.json({ capture_service_url: result.rows[0]?.value || '' });
} catch (err) {
next(err);
}
});
router.put('/capture-service', async (req, res, next) => {
try {
const url = (req.body.capture_service_url || '').trim().replace(/\/$/, '');
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ('capture_service_url', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
[url]
);
res.json({ message: 'Capture service URL saved' });
} catch (err) {
next(err);
}
});
export default router;

View file

@ -0,0 +1,142 @@
// Storage admin endpoints — unified diagnostics for the growing-files mount
// and the S3 object-storage bucket. Read-only; the actual settings editors
// continue to live under /settings/s3 and /settings/growing.
import express from 'express';
import fs from 'node:fs';
import { promisify } from 'node:util';
import { exec as execCb } from 'node:child_process';
import { HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import pool from '../db/pool.js';
import { s3Client, getS3Bucket } from '../s3/client.js';
const exec = promisify(execCb);
const router = express.Router();
// Defaults mirrored from settings.js so the overview never returns nulls.
const GROWING_DEFAULTS = {
growing_enabled: 'false',
growing_path: '/growing',
growing_smb_url: '',
growing_promote_after_seconds: '8',
};
async function readSettings(keys) {
const result = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`,
[keys]
);
const out = {};
for (const { key, value } of result.rows) out[key] = value;
return out;
}
// Probe a filesystem path: does it exist, is it writable, how much free space.
// All checks are best-effort — any failure becomes { ok: false, error }.
async function probeGrowingPath(path) {
const result = { path, exists: false, writable: false, free_bytes: null, total_bytes: null, error: null };
if (!path) { result.error = 'no path configured'; return result; }
try {
const stat = fs.statSync(path);
result.exists = stat.isDirectory();
if (!result.exists) { result.error = 'path is not a directory'; return result; }
} catch (err) {
result.error = err.code === 'ENOENT' ? 'path does not exist' : err.message;
return result;
}
try {
fs.accessSync(path, fs.constants.W_OK);
result.writable = true;
} catch (err) {
result.error = 'not writable: ' + err.message;
}
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
try {
const { stdout } = await exec(`df -PB1 ${JSON.stringify(path)}`, { timeout: 3000 });
const lines = stdout.trim().split('\n');
if (lines.length >= 2) {
const cols = lines[1].split(/\s+/);
// cols: [fs, total, used, available, capacity, mountpoint]
result.total_bytes = parseInt(cols[1], 10) || null;
result.free_bytes = parseInt(cols[3], 10) || null;
}
} catch (_err) {
// df not available or path inaccessible — leave free_bytes null.
}
return result;
}
async function probeS3Bucket() {
const bucket = getS3Bucket();
const out = { bucket, reachable: false, head_latency_ms: null, method: null, error: null };
if (!bucket) { out.error = 'no bucket configured'; return out; }
const started = Date.now();
try {
await s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
out.reachable = true;
out.method = 'HeadBucket';
} catch (headErr) {
// Fall back to a 0-key list for stores that don't expose HeadBucket.
try {
await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
out.reachable = true;
out.method = 'ListObjectsV2';
} catch (listErr) {
out.error = listErr.message || headErr.message;
}
}
out.head_latency_ms = Date.now() - started;
return out;
}
// GET /api/v1/storage/overview
// Consolidated read-only view of the storage subsystem for the admin UI.
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;
const containerPath = growingRaw.growing_path || '/growing';
const mount = await probeGrowingPath(containerPath);
// S3 — bucket name comes from the live client (env or DB-loaded), not
// a fresh DB read, so we report exactly what the running client uses.
const s3 = await probeS3Bucket();
const s3SettingsRaw = await readSettings(['s3_endpoint', 's3_region']);
res.json({
growing: {
enabled: growingEnabled,
container_path: containerPath,
// host_path isn't authoritatively known to the API container, but the
// existing deploy uses this symlink — surface it for operator context.
host_path: '/mnt/NVME/MAM/wild-dragon-growing',
smb_url: growingRaw.growing_smb_url || '',
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
exists: mount.exists,
writable: mount.writable,
free_bytes: mount.free_bytes,
total_bytes: mount.total_bytes,
error: mount.error,
},
s3: {
endpoint: s3SettingsRaw.s3_endpoint || process.env.S3_ENDPOINT || '',
bucket: s3.bucket,
region: s3SettingsRaw.s3_region || process.env.S3_REGION || 'us-east-1',
reachable: s3.reachable,
head_latency_ms: s3.head_latency_ms,
probe_method: s3.method,
error: s3.error,
},
});
} catch (err) {
next(err);
}
});
export default router;

View file

@ -0,0 +1,99 @@
import express from 'express';
import http from 'node:http';
const router = express.Router();
const DOCKER_SOCKET = '/var/run/docker.sock';
const COMPOSE_PROJECT = (process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon').split('_')[0];
function dockerGet(path) {
return new Promise((resolve, reject) => {
const req = http.request(
{ socketPath: DOCKER_SOCKET, path, method: 'GET' },
(res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
catch { resolve({ status: res.statusCode, body: data }); }
});
}
);
req.on('error', reject);
req.end();
});
}
function dockerPost(path) {
return new Promise((resolve, reject) => {
const req = http.request(
{ socketPath: DOCKER_SOCKET, path, method: 'POST', headers: { 'Content-Length': '0' } },
(res) => {
res.resume();
res.on('end', () => resolve({ status: res.statusCode }));
}
);
req.on('error', reject);
req.end();
});
}
// GET /containers list containers for this compose project
router.get('/containers', async (req, res, next) => {
try {
const r = await dockerGet('/containers/json?all=1');
if (r.status !== 200) return res.status(502).json({ error: 'Docker API error', code: r.status });
const containers = r.body
.filter(c => {
const labels = c.Labels || {};
// Match by compose project label; fall back to network name
if (labels['com.docker.compose.project'] === COMPOSE_PROJECT) return true;
return Object.keys(c.NetworkSettings?.Networks || {}).some(n => n.includes(COMPOSE_PROJECT));
})
.map(c => ({
id: c.Id.slice(0, 12),
name: (c.Names[0] || '').replace(/^\//, ''),
image: c.Image,
state: c.State,
status: c.Status,
created: c.Created,
service: (c.Labels || {})['com.docker.compose.service'] || '',
ports: (c.Ports || [])
.filter(p => p.PublicPort)
.map(p => `${p.PublicPort}:${p.PrivatePort}/${p.Type}`)
.join(', '),
}));
res.json(containers);
} catch (err) { next(err); }
});
// POST /containers/:id/restart
router.post('/containers/:id/restart', async (req, res, next) => {
try {
const r = await dockerPost(`/containers/${req.params.id}/restart`);
if (r.status !== 204) return res.status(502).json({ error: 'Failed to restart', code: r.status });
res.json({ ok: true });
} catch (err) { next(err); }
});
// POST /containers/:id/stop
router.post('/containers/:id/stop', async (req, res, next) => {
try {
const r = await dockerPost(`/containers/${req.params.id}/stop`);
if (r.status !== 204 && r.status !== 304) return res.status(502).json({ error: 'Failed to stop', code: r.status });
res.json({ ok: true });
} catch (err) { next(err); }
});
// POST /containers/:id/start
router.post('/containers/:id/start', async (req, res, next) => {
try {
const r = await dockerPost(`/containers/${req.params.id}/start`);
if (r.status !== 204 && r.status !== 304) return res.status(502).json({ error: 'Failed to start', code: r.status });
res.json({ ok: true });
} catch (err) { next(err); }
});
export default router;

Some files were not shown because too many files have changed in this diff Show more