Compare commits

...

64 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
111 changed files with 9539 additions and 1498 deletions

View file

@ -25,6 +25,13 @@ MAM_API_URL=http://mam-api:3000
# 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.
@ -36,3 +43,30 @@ ALLOWED_ORIGINS=
# 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

View file

@ -90,6 +90,16 @@ Never use `gradient text` (impeccable absolute ban). Emphasis via weight and siz
- 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:

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.

View file

@ -58,6 +58,7 @@ services:
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
@ -99,6 +100,31 @@ services:
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

@ -40,6 +40,7 @@ services:
- /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
@ -60,6 +61,9 @@ services:
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:
@ -94,8 +98,15 @@ services:
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
@ -108,8 +119,59 @@ services:
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
@ -119,12 +181,22 @@ services:
- "${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:

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.

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,4 +1,10 @@
# ── Stage 1: Build FFmpeg with DeckLink support ─────────────────────────────
# ── 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 \
@ -13,6 +19,11 @@ 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
@ -20,8 +31,15 @@ RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /
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 \
@ -32,13 +50,20 @@ RUN ./configure \
--enable-libsrt \
--enable-libzmq \
--enable-decklink \
--extra-cflags="-I/decklink-sdk" \
--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
@ -58,6 +83,11 @@ 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

View file

@ -28,7 +28,32 @@ const VIDEO_CODECS = {
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' },
hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], 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 = {
@ -128,25 +153,81 @@ class CaptureManager {
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)').
// 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 -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
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];
else deckLinkName = `DeckLink Duo (${idx + 1})`;
} catch (_) {
deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 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 {
@ -210,12 +291,16 @@ class CaptureManager {
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
}
// Network sources cannot be opened by two FFmpeg processes simultaneously
// (one socket = one consumer). For SRT/RTMP the BullMQ worker generates
// the proxy after the recording stops.
const proxyKey = (sourceType === 'sdi' && proxyEnabled)
? `projects/${projectId}/proxies/${clipName}.${proxyExt}`
: null;
// 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();
@ -241,12 +326,38 @@ class CaptureManager {
const hiresOutput = growingPath ? growingPath : 'pipe:1';
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
const hiresProcess = spawn('ffmpeg', [
// 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,
...sdiFilterArgs,
'-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,
hiresOutput,
], { stdio: hiresStdio });
// 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 hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
const hiresUpload = growingPath
? Promise.resolve({ growingPath })
@ -298,39 +409,8 @@ class CaptureManager {
}
});
// SDI only: spawn a second ffmpeg for the proxy.
// DeckLink cards allow concurrent reads; network sockets do not.
if (!isNetwork && proxyEnabled) {
const proxyCodecArgs = buildEncodeArgs({
codec: proxyVideoCodec,
videoBitrate: proxyVideoBitrate,
framerate: proxyFramerate,
audioCodec: proxyAudioCodec,
audioBitrate: proxyAudioBitrate,
audioChannels: proxyAudioChannels,
container: proxyContainer,
isNetwork: false,
isProxy: true,
});
console.log('[capture] proxy ffmpeg args:', proxyCodecArgs.join(' '));
const proxyProcess = spawn('ffmpeg', [
...inputArgs,
...sdiFilterArgs,
...proxyCodecArgs,
'-movflags', '+frag_keyframe+empty_moov',
'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}`);
});
}
// 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;

View file

@ -9,6 +9,7 @@ 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 || '';
app.use(cors());
app.use(express.json());
@ -128,17 +129,33 @@ async function gracefulShutdown(signal) {
try {
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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' },
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,

View file

@ -1,5 +1,6 @@
import express from 'express';
import { execSync, spawn } from 'child_process';
import { existsSync, readdirSync } from 'node:fs';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
@ -95,8 +96,8 @@ 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;
@ -118,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
@ -150,6 +202,28 @@ router.post('/probe', async (req, res) => {
}
}
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.' });
}

View file

@ -22,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,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,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

@ -139,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

@ -8,7 +8,7 @@ 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, requireUiHeader } from './middleware/auth.js';
import { requireAuth, requireUiHeader, requireAdmin } from './middleware/auth.js';
import { loadS3ConfigFromDb } from './s3/client.js';
import authRouter from './routes/auth.js';
@ -22,6 +22,7 @@ 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 groupsRouter from './routes/groups.js';
@ -40,18 +41,12 @@ import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
const app = express();
const PORT = process.env.PORT || 3000;
// ── Middleware ────────────────────────────────────────────────────────────────
// Tightened CORS — once cookies carry authority, `origin: true` would let
// any site forge requests with the cookie. Drive the allowlist from env.
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',').map(s => s.trim()).filter(Boolean);
app.use(cors({
origin: (origin, cb) => {
// No Origin header (same-origin or curl) — allow.
if (!origin) return cb(null, true);
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
// Reject cleanly: omit the Allow-Origin header so the browser surfaces
// a real CORS error instead of a 500 from a thrown Error in the callback.
console.warn('[cors] rejected origin:', origin);
return cb(null, false);
},
@ -59,14 +54,8 @@ app.use(cors({
}));
app.use(express.json({ limit: '50mb' }));
// Trust the reverse proxy only when explicitly told to (production HTTPS).
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
// HSTS — once a browser has seen this header over HTTPS for dragonflight.live,
// it auto-upgrades every future http:// request to https:// before hitting the
// wire. Cookies are Secure-only (below) and the CORS allowlist rejects HTTP,
// so without HSTS a user who lands on http:// silently can't log in.
// Only emit on actual HTTPS responses; req.secure honors trust proxy + X-Forwarded-Proto.
if (process.env.AUTH_ENABLED === 'true') {
app.use((req, res, next) => {
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
@ -74,17 +63,13 @@ if (process.env.AUTH_ENABLED === 'true') {
});
}
// Hard-fail when production-mode auth has no stable session secret. Without
// this, express-session falls back to an in-memory random secret which
// invalidates every session on restart and breaks multi-node deployments.
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
process.exit(1);
}
// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md.
app.use(session({
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 /* seconds = 15 min */ }),
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
secret: process.env.SESSION_SECRET,
name: 'dragonflight.sid',
cookie: {
@ -94,31 +79,26 @@ app.use(session({
path: '/',
maxAge: 8 * 3600 * 1000,
},
rolling: false, // sliding renewal handled in requireAuth so idle + absolute can be enforced separately
rolling: false,
resave: false,
saveUninitialized: false,
}));
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
// ── Auth gate ─────────────────────────────────────────────────────────────────
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
// node-agent now authenticates /cluster/heartbeat with a bound api_token
// (migration 019 + bound_hostname on the token). requireAuth handles the
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
// routes/cluster.js verifies body.hostname matches that binding.
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);
});
// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/auth/users', usersRouter);
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate
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);
@ -127,9 +107,10 @@ 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', groupsRouter);
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);
@ -140,21 +121,14 @@ app.use('/api/v1/assets/:assetId/comments', commentsRouter);
app.use('/api/v1/imports', importsRouter);
app.use('/api/v1/storage', storageRouter);
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);
// ── Start ────────────────────────────────────────────────────────────────────
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() {
// Issue #107 — previously the loop swallowed errors and let the server boot
// on a half-migrated schema. Now: track applied migrations in a table, run
// every pending one inside a transaction, and exit non-zero on failure so
// the orchestrator restarts (and so an operator notices) instead of serving
// 500s for the next month.
const dir = join(__dirnameMig, 'db', 'migrations');
let files = [];
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
@ -167,7 +141,6 @@ async function runMigrations() {
)
`);
// Allow forcing a re-run via env when iterating locally.
const force = process.env.MIGRATIONS_FORCE === '1';
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
@ -193,7 +166,6 @@ async function runMigrations() {
console.error('[migration] FAILED ' + f + ': ' + err.message);
client.release();
if (allowFailures) continue;
// Hard fail — better to crash now than serve traffic on a broken schema.
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
process.exit(1);
}
@ -202,13 +174,9 @@ async function runMigrations() {
}
await runMigrations();
// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb();
// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() {
// Prefer an explicit override — useful when running inside Docker where
// os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
if (process.env.NODE_IP) return process.env.NODE_IP;
const ifaces = os.networkInterfaces();
@ -220,9 +188,6 @@ function getLocalIp() {
return '127.0.0.1';
}
// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() {
return new Promise(resolve => {
exec(
@ -244,6 +209,10 @@ function detectGpus() {
});
}
// 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();
@ -255,14 +224,15 @@ async function selfHeartbeat() {
pool.query(
`INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url,
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
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(),
@ -287,39 +257,26 @@ const server = app.listen(PORT, () => {
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.');
}
// Boot the recorder scheduler tick loop after the HTTP server is live so
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
startSchedulerLoop();
// Boot the temp-segment cleanup loop (runs hourly).
startCleanupLoop();
});
// Issue #100 — graceful shutdown. Without this, `docker stop` (SIGTERM) killed
// the process mid-scheduler-tick, leaving Redis connections and Docker
// sockets dangling and producing partial DB writes. Now: stop the scheduler,
// finish in-flight HTTP requests, close PG/Redis pools, and exit cleanly
// (or hard-exit after 25 s if something is stuck).
let _shuttingDown = false;
async function gracefulShutdown(signal) {
if (_shuttingDown) return;
_shuttingDown = true;
console.log(`[shutdown] received ${signal} — closing gracefully…`);
// Stop accepting new requests + wind down the scheduler tick.
try { stopSchedulerLoop(); } catch (_) {}
// Force-exit watchdog so a hung connection can't keep us alive forever.
const killSwitch = setTimeout(() => {
console.error('[shutdown] forced exit after 25s timeout');
process.exit(1);
}, 25_000);
killSwitch.unref();
// Stop the HTTP server (waits for in-flight requests to finish).
await new Promise(resolve => server.close(resolve));
// Close DB pool + S3 client + any other resources. Best-effort.
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
console.log('[shutdown] clean exit');

View file

@ -1,10 +1,29 @@
import crypto from 'crypto';
import pool from '../db/pool.js';
import { parseBearer, hashToken } from '../auth/tokens.js';
// 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';
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));
}
// 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';
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' };
// 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;
@ -18,11 +37,18 @@ async function destroyAnd401(req, res) {
async function loadUser(id) {
const { rows } = await pool.query(
`SELECT id, username, display_name, role FROM users WHERE id = $1`, [id]);
`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();
}
// Dev mode — attach the seeded dev user so FK-bearing routes work.
if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER;
@ -73,6 +99,14 @@ export async function requireAuth(req, res, next) {
return res.status(401).json({ error: 'unauthorized' });
}
// 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.
@ -88,6 +122,8 @@ 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();

View file

@ -7,9 +7,36 @@ import pool from '../db/pool.js';
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { requireAdmin } from '../middleware/auth.js';
const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// Every /:id asset route is scoped to the asset's project. The param handler
// validates the UUID, resolves the owning project_id, and asserts at least
// 'view' access (the baseline for touching an asset at all). Mutating routes
// additionally assert 'edit' via req.assetProjectId. A missing asset is a clean
// 404 here rather than leaking existence to users without access.
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 assets WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
req.assetProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.assetProjectId, 'view');
next();
} catch (err) { next(err); }
});
// Route-level guard for mutating /:id endpoints — escalates the param handler's
// 'view' baseline to 'edit'. Reuses req.assetProjectId (already resolved).
async function requireAssetEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.assetProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// BullMQ queue connection (mirrors worker/src/index.js)
const parseRedisUrl = (url) => {
@ -33,6 +60,10 @@ const filmstripQueue = new Queue('filmstrip', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
const hlsQueue = new Queue('hls', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
// GET / - List assets with filtering
router.get('/', async (req, res, next) => {
try {
@ -62,6 +93,15 @@ router.get('/', async (req, res, next) => {
const params = [];
let paramCount = 1;
// Scope to projects the caller can access (admins are unfiltered). Without
// this, a granted user would see every asset across every project.
const access = await accessibleProjectIds(req.user);
if (!access.all) {
if (access.ids.size === 0) return res.json({ assets: [], total: 0 });
query += ` AND a.project_id = ANY($${paramCount++}::uuid[])`;
params.push([...access.ids]);
}
// Exclude archived unless explicitly requested — independent of status filter
if (include_archived !== 'true') {
query += ` AND a.status <> 'archived'`;
@ -128,6 +168,9 @@ router.post('/', async (req, res, next) => {
return res.status(400).json({ error: 'projectId and clipName are required' });
}
// Registering an asset writes into a project — require edit access there.
await assertProjectAccess(req.user, projectId, 'edit');
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
if (durationNum !== null && !Number.isFinite(durationNum)) {
return res.status(400).json({ error: 'duration must be a finite number (seconds)' });
@ -216,8 +259,8 @@ router.post('/', async (req, res, next) => {
}
});
// POST /cleanup-live
router.post('/cleanup-live', async (req, res, next) => {
// POST /cleanup-live — cross-project maintenance, admin only.
router.post('/cleanup-live', requireAdmin, async (req, res, next) => {
try {
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
const result = await pool.query(
@ -230,8 +273,8 @@ router.post('/cleanup-live', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /cleanup-live-orphans
router.post('/cleanup-live-orphans', async (_req, res, next) => {
// POST /cleanup-live-orphans — cross-project maintenance, admin only.
router.post('/cleanup-live-orphans', requireAdmin, async (_req, res, next) => {
try {
const liveRoot = process.env.LIVE_DIR || '/live';
let entries;
@ -273,10 +316,22 @@ router.get('/:id', async (req, res, next) => {
});
// PATCH /:id
router.patch('/:id', async (req, res, next) => {
router.patch('/:id', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { display_name, tags, notes, bin_id } = req.body;
// bin_id must reference a bin in the asset's OWN project — otherwise an
// editor in project A could stuff their asset into project B's bin tree.
// Null/empty clears the bin, which is always allowed.
if (bin_id) {
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [bin_id]);
if (bin.rows.length === 0) return res.status(400).json({ error: 'bin_id not found' });
if (bin.rows[0].project_id !== req.assetProjectId) {
return res.status(400).json({ error: 'bin_id belongs to a different project' });
}
}
const updates = [], params = [];
let paramCount = 1;
if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
@ -295,13 +350,32 @@ router.patch('/:id', async (req, res, next) => {
});
// POST /:id/copy
router.post('/:id/copy', async (req, res, next) => {
router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { binId, projectId } = req.body;
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const src = r.rows[0];
// Destination project defaults to source's. If the caller overrides it,
// assert edit on the target — without this, an editor in project A could
// clone any asset they can see into project B with no grant on B.
const destProjectId = projectId || src.project_id;
if (projectId && projectId !== src.project_id) {
await assertProjectAccess(req.user, destProjectId, 'edit');
}
// Destination bin (if any) must belong to the destination project — same
// class of bug as the PATCH bin_id hole.
const destBinId = binId === undefined ? src.bin_id : (binId || null);
if (destBinId) {
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [destBinId]);
if (bin.rows.length === 0) return res.status(400).json({ error: 'binId not found' });
if (bin.rows[0].project_id !== destProjectId) {
return res.status(400).json({ error: 'binId belongs to a different project than the destination' });
}
}
const newId = uuidv4();
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
// sharing S3 objects with the source. Set status to 'processing' so the copy
@ -316,8 +390,8 @@ router.post('/:id/copy', async (req, res, next) => {
$1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW()
) RETURNING *`,
[
newId, projectId || src.project_id,
binId === undefined ? src.bin_id : (binId || null),
newId, destProjectId,
destBinId,
src.filename, src.display_name, 'processing', src.media_type,
src.original_s3_key,
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
@ -342,7 +416,7 @@ router.post('/:id/copy', async (req, res, next) => {
});
// POST /:id/mark-empty
router.post('/:id/mark-empty', async (req, res, next) => {
router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
// Bug #66: first check the asset exists and what status it is in
@ -373,8 +447,66 @@ router.post('/:id/mark-empty', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /:id/finalize
// Capture sidecar calls this on a SUCCESSFUL recording stop to finalise the
// pre-created 'live' asset (created at recorder start, id passed as ASSET_ID).
// Previously the sidecar did POST / to create a NEW asset, which collided with
// the existing live row -> 409 -> asset stuck 'live', no jobs. Finalising by id
// flips it out of 'live', records duration + S3 keys, and kicks off the
// proxy -> thumbnail -> filmstrip job chain.
router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { hiresKey, proxyKey, duration } = req.body;
const check = await pool.query(`SELECT * FROM assets WHERE id = $1`, [id]);
if (check.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const current = check.rows[0].status;
// Already terminal — idempotent no-op (handles shutdown retries).
if (current === 'ready' || current === 'error') {
return res.status(200).json({ ...check.rows[0], skipped: true });
}
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
const durationMs = (durationNum !== null && Number.isFinite(durationNum)) ? Math.round(durationNum * 1000) : null;
const upd = await pool.query(
`UPDATE assets
SET status = 'processing',
original_s3_key = COALESCE($2, original_s3_key),
proxy_s3_key = COALESCE($3, proxy_s3_key),
duration_ms = COALESCE($4, duration_ms),
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[id, hiresKey || null, proxyKey || null, durationMs]
);
const asset = upd.rows[0];
const thumbnailKey = `thumbnails/${id}.jpg`;
if (asset.proxy_s3_key) {
// Proxy already produced by the capture sidecar — just build the
// thumbnail (which then chains filmstrip). Worker flips status->ready.
await thumbnailQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key, outputKey: thumbnailKey });
console.log(`[assets] finalize ${id}: queued thumbnail (proxy present)`);
} else if (asset.original_s3_key) {
// No proxy yet — generate it from the hi-res master. The proxy worker
// chains thumbnail -> filmstrip on completion.
const generatedProxyKey = `proxies/${id}.mp4`;
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: generatedProxyKey });
console.log(`[assets] finalize ${id}: queued proxy from master`);
} else {
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id]);
asset.status = 'ready';
}
console.log(`[assets] finalized live asset ${id} (${asset.display_name}) -> ${asset.status}`);
res.json(asset);
} catch (err) { next(err); }
});
// POST /:id/generate-proxy
router.post('/:id/generate-proxy', async (req, res, next) => {
router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
@ -390,8 +522,8 @@ router.post('/:id/generate-proxy', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /backfill-proxies
router.post('/backfill-proxies', async (_req, res, next) => {
// POST /backfill-proxies — cross-project maintenance, admin only.
router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => {
try {
const targets = await pool.query(
`SELECT id, original_s3_key FROM assets
@ -415,12 +547,12 @@ router.post('/backfill-proxies', async (_req, res, next) => {
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip
// Force-requeue a processing job regardless of current asset status.
router.post('/:id/reprocess', async (req, res, next) => {
router.post('/:id/reprocess', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
const type = req.query.type || 'proxy';
if (!['proxy', 'thumbnail', 'filmstrip'].includes(type)) {
return res.status(400).json({ error: 'type must be "proxy", "thumbnail", or "filmstrip"' });
if (!['proxy', 'thumbnail', 'filmstrip', 'hls'].includes(type)) {
return res.status(400).json({ error: 'type must be "proxy", "thumbnail", "filmstrip", or "hls"' });
}
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
@ -443,6 +575,12 @@ router.post('/:id/reprocess', async (req, res, next) => {
await filmstripQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key });
return res.json({ queued: 'filmstrip', assetId: id });
}
if (type === 'hls') {
// Backfill: remux the existing proxy MP4 into an HLS rendition (no re-encode).
if (!asset.proxy_s3_key) return res.status(400).json({ error: 'Asset has no proxy — generate proxy first' });
await hlsQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key });
return res.json({ queued: 'hls', assetId: id });
}
} catch (err) { next(err); }
});
@ -460,7 +598,7 @@ router.get('/:id/filmstrip', async (req, res, next) => {
});
// POST /:id/retry
router.post('/:id/retry', async (req, res, next) => {
router.post('/:id/retry', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
@ -479,7 +617,7 @@ router.post('/:id/retry', async (req, res, next) => {
});
// DELETE /:id
router.delete('/:id', async (req, res, next) => {
router.delete('/:id', requireAssetEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { hard } = req.query;
@ -527,6 +665,20 @@ router.get('/:id/stream', async (req, res, next) => {
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const a = r.rows[0];
if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
// `url` is the directly-downloadable MP4 proxy; `hls_url` is the HLS
// rendition for in-browser playback (whole-file segment GETs avoid the
// RustFS ranged-GET stitching the MP4 path needs). The Premiere plugin
// downloads `url` to a file and imports it, so `url` must NOT be the
// .m3u8 playlist — Premiere can't import a playlist ("unsupported
// compression type"). The web player prefers `hls_url` when present.
if (a.hls_s3_key) {
return res.json({
url: `/api/v1/assets/${id}/video`,
type: 'mp4',
source: a.proxy_s3_key ? 'proxy' : 'original',
hls_url: `/api/v1/assets/${id}/hls/playlist.m3u8`,
});
}
const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
const key = a.proxy_s3_key ||
(a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext))
@ -538,6 +690,42 @@ router.get('/:id/stream', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /:id/hls/:file — serve an HLS rendition file (playlist / init / segment).
// Whole-object passthrough from S3: no Range handling, so this sidesteps the
// RustFS ranged-GET bug entirely (every segment is a small, complete GET).
// :file is strictly validated to prevent path traversal into the bucket.
const HLS_FILE_RE = /^(playlist\.m3u8|init\.mp4|segment_\d+\.m4s)$/;
router.get('/:id/hls/:file', async (req, res, next) => {
try {
const { id, file } = req.params;
if (!HLS_FILE_RE.test(file)) return res.status(400).json({ error: 'Invalid HLS file' });
const r = await pool.query('SELECT hls_s3_key FROM assets WHERE id = $1', [id]);
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const playlistKey = r.rows[0].hls_s3_key;
if (!playlistKey) return res.status(404).json({ error: 'No HLS rendition for this asset' });
// Derive the prefix from the stored playlist key (hls/<id>/playlist.m3u8)
// and request the specific file under it.
const prefix = playlistKey.replace(/\/[^/]+$/, '');
const key = `${prefix}/${file}`;
const isPlaylist = file.endsWith('.m3u8');
const s3Res = await s3Client.send(new GetObjectCommand({ Bucket: getS3Bucket(), Key: key }));
res.writeHead(200, {
'Content-Type': isPlaylist ? 'application/vnd.apple.mpegurl' : 'video/mp4',
'Cache-Control': isPlaylist ? 'no-cache' : 'private, max-age=3600',
...(s3Res.ContentLength ? { 'Content-Length': String(s3Res.ContentLength) } : {}),
});
s3Res.Body.pipe(res);
} catch (err) {
if (err && (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404)) {
return res.status(404).json({ error: 'HLS file not found' });
}
next(err);
}
});
// GET /:id/live-path
router.get('/:id/live-path', async (req, res, next) => {
try {
@ -790,6 +978,15 @@ router.post('/batch-trim', async (req, res, next) => {
return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' });
}
}
// Authorize every source asset's project (edit) before queuing any work.
const trimAssetIds = [...new Set(clips.map(c => c.assetId))];
const owning = await pool.query('SELECT id, project_id FROM assets WHERE id = ANY($1::uuid[])', [trimAssetIds]);
const projById = new Map(owning.rows.map(r => [r.id, r.project_id]));
for (const aid of trimAssetIds) {
const pid = projById.get(aid);
if (!pid) return res.status(404).json({ error: 'Asset not found: ' + aid });
await assertProjectAccess(req.user, pid, 'edit');
}
const jobId = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query(

View file

@ -3,6 +3,14 @@ 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';
@ -76,7 +84,7 @@ router.post('/login', async (req, res, next) => {
}
const { rows } = await pool.query(
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
`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) {
@ -93,6 +101,29 @@ router.post('/login', async (req, res, next) => {
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();
@ -100,14 +131,93 @@ router.post('/login', async (req, res, next) => {
// 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));
}
ipBackoff.recordSuccess(ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); }
// 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();
@ -125,6 +235,7 @@ router.get('/me', requireAuth, (req, res) => {
username: req.user.username,
display_name: req.user.display_name,
role: req.user.role,
totp_enabled: !!req.user.totp_enabled,
});
});
@ -149,5 +260,202 @@ router.post('/password', requireAuth, async (req, res, next) => {
} 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 };
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };

View file

@ -1,25 +1,60 @@
import express from 'express';
import pool from '../db/pool.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.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// GET / - List bins. Filter by project_id when supplied; otherwise return
// every bin across every project so the Library / asset-context-menu can
// present a global "move to bin" picker.
// 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); }
});
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;
const params = [];
let where = '';
if (project_id) {
where = 'WHERE b.project_id = $1';
params.push(project_id);
await assertProjectAccess(req.user, project_id, 'view');
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 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
@ -29,14 +64,13 @@ router.get('/', async (req, res, next) => {
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;
@ -44,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();
@ -61,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;
@ -107,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;
@ -126,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;
@ -136,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
@ -158,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,3 +1,7 @@
// 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';
const router = express.Router();

View file

@ -4,10 +4,6 @@ import pool from '../db/pool.js';
const router = express.Router();
// If the agent reported Docker's default bridge IP (172.17.x) but the request
// itself came from a real LAN address, prefer the request source IP instead.
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918
// 172.16/12 block, since real LANs (e.g. 172.18.91.x) fall in that range.
function pickIp(reportedIp, reqIp) {
const clean = (s) => (s || '').replace(/^::ffff:/, '');
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
@ -41,7 +37,6 @@ function dockerRequest(path, method = 'GET', body = null) {
});
}
// GET / list all registered cluster nodes with online status
router.get('/', async (req, res, next) => {
try {
const r = await pool.query(
@ -57,7 +52,6 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /containers list all containers on the local Docker host
router.get('/containers', async (req, res, next) => {
try {
const containers = await dockerRequest('/containers/json?all=true');
@ -88,7 +82,6 @@ router.get('/containers', async (req, res, next) => {
}
});
// POST /containers/:nameOrId/restart
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
try {
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
@ -96,23 +89,17 @@ router.post('/containers/:nameOrId/restart', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /heartbeat upsert this node's registration (includes hardware capabilities)
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,
capabilities, metadata, metrics,
} = req.body;
if (!hostname) return res.status(400).json({ error: 'hostname is required' });
// Issue #106 — any authenticated user used to be able to POST a heartbeat
// for an arbitrary hostname and overwrite the primary node's `api_url`,
// effectively hijacking job dispatch. Now: if the caller's token is bound
// to a hostname (node-agent tokens are bound at issue time), the body
// hostname must match. Admin users with no binding are allowed for ops.
if (process.env.AUTH_ENABLED === 'true') {
const bound = req.tokenBoundHostname;
if (bound && bound !== hostname) {
@ -132,8 +119,8 @@ router.post('/heartbeat', async (req, res, next) => {
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, capabilities, metadata)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10)
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,
@ -143,8 +130,10 @@ router.post('/heartbeat', async (req, res, next) => {
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
metadata = EXCLUDED.metadata,
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
RETURNING *`,
[
hostname,
@ -157,48 +146,32 @@ router.post('/heartbeat', async (req, res, next) => {
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); }
});
// GET /devices/blackmagic/signal live video-presence state for every
// DeckLink port across the cluster. For each port we check whether there is
// an active SDI recorder assigned to it and, if so, query the capture
// container for its real signal state (receiving / lost / connecting /
// error). Ports without a recorder get signal = 'no-recorder'.
//
// Response shape (array):
// { node_id, hostname, index, device, model,
// signal, framesReceived, currentFps, recorder_id, recorder_status }
router.get('/devices/blackmagic/signal', async (req, res, next) => {
try {
// 1. Fetch all cluster nodes with DeckLink capabilities.
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`
);
// 2. Fetch all SDI recorders that are pinned to a node+device_index.
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`
);
// Build a fast lookup: "${node_id}:${device_index}" → recorder row.
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);
}
// 3. For each port, determine signal state. We fire all capture-container
// fetches concurrently so the endpoint stays fast even with many ports.
const tasks = [];
for (const node of nodesResult.rows) {
const nodeOnline = Number(node.stale_seconds) < 120;
@ -206,79 +179,51 @@ router.get('/devices/blackmagic/signal', async (req, res, next) => {
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,
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,
signal: 'no-recorder', framesReceived: null, currentFps: null,
};
if (!rec || rec.status !== 'recording' || !rec.container_id) {
// No active capture — if there's a recorder but it's not recording,
// report that; otherwise the port is unassigned.
if (rec && rec.status !== 'recording') base.signal = 'idle';
return base;
}
// Active recording — query the capture container for real signal.
try {
let live = null;
if (isRemote) {
const r = await fetch(
`${node.api_url}/sidecar/${rec.container_id}/status`,
{ signal: AbortSignal.timeout(2500) }
);
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) }
);
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';
}
} else { base.signal = 'connecting'; }
} catch (_) { base.signal = 'connecting'; }
return base;
})());
});
}
const results = await Promise.all(tasks);
res.json(results);
} catch (err) { next(err); }
});
// GET /devices/blackmagic flatten every node's DeckLink cards for the
// recorder picker. Returns one entry per device with the host node info.
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`
FROM cluster_nodes WHERE capabilities IS NOT NULL`
);
const out = [];
for (const row of r.rows) {
@ -286,39 +231,98 @@ router.get('/devices/blackmagic', async (req, res, next) => {
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,
});
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); }
});
// GET /:id/ping probe the node's api_url/health endpoint directly
router.get('/:id/ping', async (req, res, next) => {
router.get('/devices/deltacast', async (req, res, next) => {
try {
const r = await pool.query(
'SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1',
[req.params.id]
`SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`
);
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
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 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 });
@ -328,13 +332,44 @@ router.get('/:id/ping', async (req, res, next) => {
} catch (err) { next(err); }
});
// DELETE /:id deregister a node
router.delete('/:id', async (req, res, next) => {
router.get('/metrics', async (req, res, next) => {
try {
const r = await pool.query(
'DELETE FROM cluster_nodes WHERE id = $1 RETURNING id',
[req.params.id]
`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); }

View file

@ -5,9 +5,23 @@
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,
@ -49,8 +63,9 @@ router.post('/', async (req, res, next) => {
if (!body || !String(body).trim()) {
return res.status(400).json({ error: 'body is required' });
}
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
const userId = req.session?.userId || null;
// 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)

View file

@ -10,6 +10,7 @@ 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();
@ -60,6 +61,8 @@ router.post('/youtube', async (req, res, next) => {
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();

View file

@ -1,6 +1,7 @@
import express from 'express';
import pool from '../db/pool.js';
import { Queue } from 'bullmq';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router();
// Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"),
@ -324,6 +325,10 @@ router.post('/conform', async (req, res, next) => {
});
}
// 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 bullJob = await conformQueue.add('conform-task', {
edl,
projectId: project_id,

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,6 +1,8 @@
import express from 'express';
import pool from '../db/pool.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();
@ -16,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;
@ -51,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.*,
@ -76,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 = [];
@ -122,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;
@ -143,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,14 +1,39 @@
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 { 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.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// 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.
@ -148,6 +173,17 @@ function pickRecorderFields(body) {
// parallel with a per-call timeout from `dockerApi`.
router.get('/', async (req, res, next) => {
try {
// 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
@ -161,8 +197,9 @@ router.get('/', async (req, res, next) => {
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.
@ -193,10 +230,15 @@ router.post('/', async (req, res, next) => {
.json({ error: 'Name and source_type are required' });
}
// 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: 'prores_hq',
recording_codec: 'hevc_nvenc',
recording_resolution: 'native',
recording_audio_codec: 'pcm_s24le',
recording_audio_channels: 2,
@ -255,7 +297,7 @@ router.get('/:id', async (req, res, next) => {
// PATCH /:id - Edit recorder settings
// Blocked while recorder is actively recording to prevent config drift.
router.patch('/:id', async (req, res, next) => {
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
@ -294,7 +336,7 @@ router.patch('/:id', async (req, res, next) => {
});
// POST /:id/start - Start recording
router.post('/:id/start', async (req, res, next) => {
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
@ -318,6 +360,7 @@ router.post('/:id/start', async (req, res, next) => {
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';
// Growing-files mode is a global setting (settings table). When on, the
@ -336,6 +379,21 @@ router.post('/:id/start', async (req, res, next) => {
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();
@ -346,7 +404,7 @@ router.post('/:id/start', async (req, res, next) => {
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, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.${ext}`]
[assetIdLive, takeProjectId, clipName, `projects/${takeProjectId}/masters/${clipName}.${ext}`]
);
} catch (e) {
console.warn('[recorders] could not pre-create live asset:', e.message);
@ -391,13 +449,21 @@ router.post('/:id/start', async (req, res, next) => {
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
`PROJECT_ID=${recorder.project_id}`,
`PROJECT_ID=${takeProjectId}`,
`CLIP_NAME=${clipName}`,
`ASSET_ID=${assetIdLive}`,
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
`GROWING_PATH=/growing`,
];
// 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) {
@ -410,8 +476,20 @@ router.post('/:id/start', async (req, res, next) => {
}
}
// 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;
@ -421,7 +499,7 @@ router.post('/:id/start', async (req, res, next) => {
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 }),
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType, useGpu }),
signal: AbortSignal.timeout(15000),
});
if (!sidecarRes.ok) {
@ -443,18 +521,39 @@ router.post('/:id/start', async (req, res, next) => {
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 containerConfig = {
Image: 'wild-dragon-capture:latest',
Env: env,
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
HostConfig: {
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,
Binds: hostBinds,
},
...(useGpu && {
Runtime: 'nvidia',
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
}),
};
const containerConfig = {
Image: 'wild-dragon-capture:latest',
Env: localEnv,
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
HostConfig: localHostConfig,
NetworkingConfig: {
EndpointsConfig: {
[dockerNetwork]: { Aliases: [alias] },
@ -501,7 +600,7 @@ 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;
@ -672,7 +771,7 @@ 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;
@ -839,4 +938,37 @@ function probeUdp(host, port) {
});
}
// 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

@ -3,6 +3,7 @@ import express from 'express';
import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess } from '../auth/authz.js';
import { Queue } from 'bullmq';
const parseRedisUrl = (url) => {
@ -19,7 +20,27 @@ const conformQueue = new Queue('conform', {
});
const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// 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
@ -124,6 +145,7 @@ 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]
@ -143,6 +165,7 @@ 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 *`,
@ -188,7 +211,7 @@ router.get('/:id', async (req, res, next) => {
});
// ── 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 = [];
@ -211,7 +234,7 @@ router.put('/:id', async (req, res, next) => {
});
// ── 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' });
@ -220,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)) ||
@ -237,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`,
@ -265,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();
}
});
@ -300,7 +341,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
// ── 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', async (req, res, next) => {
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' });

View file

@ -14,6 +14,7 @@ import {
AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
const router = express.Router();
@ -138,16 +139,24 @@ function mediaTypeFromMime(mime = '') {
return 'document';
}
// GET /api/v1/upload - List in-progress uploads (#68)
// GET /api/v1/upload - List in-progress uploads (#68). Scoped to projects the
// caller can see — admins are unfiltered; a scoped viewer/editor only sees
// uploads for projects they have access to (no enumeration of other projects'
// in-flight filenames).
router.get('/', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
const access = await accessibleProjectIds(req.user);
let query = `SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
FROM assets
WHERE status = 'ingesting'
ORDER BY created_at DESC
LIMIT 50`
);
WHERE status = 'ingesting'`;
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
query += ` AND project_id = ANY($1::uuid[])`;
params.push([...access.ids]);
}
query += ` ORDER BY created_at DESC LIMIT 50`;
const result = await pool.query(query, params);
res.json(result.rows);
} catch (err) { next(err); }
});
@ -163,6 +172,17 @@ router.post('/init', async (req, res, next) => {
});
}
// Uploading creates an asset under a project — require edit on that project.
// Without this, any logged-in user could write into any project.
await assertProjectAccess(req.user, projectId, 'edit');
if (binId) {
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [binId]);
if (bin.rows.length === 0) return res.status(400).json({ error: 'binId not found' });
if (bin.rows[0].project_id !== projectId) {
return res.status(400).json({ error: 'binId belongs to a different project' });
}
}
const assetId = uuidv4();
const s3Key = `originals/${assetId}/${filename}`;
const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
@ -326,6 +346,20 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
});
}
// Same authz gate as /init.
await assertProjectAccess(req.user, projectId, 'edit');
if (binId) {
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [binId]);
if (bin.rows.length === 0) {
unlinkPart(tmpPath);
return res.status(400).json({ error: 'binId not found' });
}
if (bin.rows[0].project_id !== projectId) {
unlinkPart(tmpPath);
return res.status(400).json({ error: 'binId belongs to a different project' });
}
}
const assetId = uuidv4();
const s3Key = `originals/${assetId}/${filename}`;
const mimeType = contentType || req.file.mimetype;

View file

@ -3,10 +3,12 @@
import express from 'express';
import pool from '../db/pool.js';
import { hashPassword } from '../auth/passwords.js';
import { DEV_USER_ID } from '../middleware/auth.js';
import { DEV_USER_ID, requireAdmin } from '../middleware/auth.js';
import { accessibleProjectIds } from '../auth/authz.js';
const router = express.Router();
const MIN_PASSWORD_LEN = 12;
const ROLES = ['admin', 'editor', 'viewer'];
function bad(res, msg) { return res.status(400).json({ error: msg }); }
@ -14,7 +16,7 @@ function bad(res, msg) { return res.status(400).json({ error: msg }); }
router.get('/', async (_req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT id, username, display_name, role, last_login_at, created_at
`SELECT id, username, display_name, role, totp_enabled, last_login_at, created_at
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
res.json(rows);
} catch (err) { next(err); }
@ -26,6 +28,7 @@ router.post('/', async (req, res, next) => {
const { username, password, display_name, role } = req.body || {};
if (!username || typeof username !== 'string') return bad(res, 'username required');
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
if (role !== undefined && !ROLES.includes(role)) return bad(res, "role must be one of: " + ROLES.join(', '));
const hash = await hashPassword(password);
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
@ -76,7 +79,10 @@ router.patch('/:id', async (req, res, next) => {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
const sets = []; const vals = [];
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); }
if (typeof req.body?.role === 'string') {
if (!ROLES.includes(req.body.role)) return bad(res, "role must be one of: " + ROLES.join(', '));
sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role);
}
if (typeof req.body?.password === 'string') {
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
@ -93,4 +99,88 @@ router.patch('/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /:id/access — effective per-project access for one user (admin only).
// Reuses authz.accessibleProjectIds (MAX over direct user grant + every group the
// user belongs to). `via` is 'direct' for a user grant, 'group:<name>' otherwise.
// When the effective level comes from several sources we report the direct grant
// if present, else the first contributing group.
router.get('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { rows: urows } = await pool.query(
`SELECT id, role FROM users WHERE id = $1`, [req.params.id]);
if (urows.length === 0) return res.status(404).json({ error: 'user not found' });
const target = urows[0];
const { rows: groups } = await pool.query(
`SELECT g.id, g.name
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = $1 ORDER BY g.name`, [target.id]);
// Admins bypass scoping — every project at 'edit', via their role.
const access = await accessibleProjectIds(target);
if (access.all) {
const { rows: projects } = await pool.query(
`SELECT id, name FROM projects ORDER BY name`);
return res.json({
projects: projects.map(p => ({
project_id: p.id, project_name: p.name, level: 'edit', via: 'direct',
})),
groups,
});
}
const ids = [...access.ids];
if (ids.length === 0) return res.json({ projects: [], groups });
// Resolve names + the source of each grant. groupNameById lets us label a
// group-sourced grant; a direct user grant always wins the `via` label.
const groupNameById = new Map(groups.map(g => [g.id, g.name]));
const { rows: grants } = await pool.query(
`SELECT pa.project_id, pa.subject_type, pa.subject_id, pa.level, p.name AS project_name
FROM project_access pa JOIN projects p ON p.id = pa.project_id
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
))`,
[target.id]);
const byProject = new Map();
for (const g of grants) {
const eff = access.levelByProject.get(g.project_id); // already the MAX
const via = g.subject_type === 'user'
? 'direct'
: 'group:' + (groupNameById.get(g.subject_id) || g.subject_id);
const prev = byProject.get(g.project_id);
// Keep a row only if it carries the effective level; prefer a direct grant
// when both a direct and a group grant hit the same level.
if (g.level === eff && (!prev || (prev.via !== 'direct' && via === 'direct'))) {
byProject.set(g.project_id, {
project_id: g.project_id, project_name: g.project_name, level: eff, via,
});
}
}
res.json({
projects: [...byProject.values()].sort((a, b) => a.project_name.localeCompare(b.project_name)),
groups,
});
} catch (err) { next(err); }
});
// POST /:id/totp/disable — admin clears a locked-out user's 2FA WITHOUT their
// password (the self-service /auth/totp/disable needs the victim's own). Mirrors
// that handler's SQL but targets :id and skips the password check. Dev user blocked.
router.post('/:id/totp/disable', requireAdmin, async (req, res, next) => {
try {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
const { rowCount } = await pool.query(
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0
WHERE id = $1 AND id <> $2`,
[req.params.id, DEV_USER_ID]);
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.params.id]);
res.status(204).end();
} catch (err) { next(err); }
});
export default router;

View file

@ -1,3 +1,4 @@
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Upload } from '@aws-sdk/lib-storage';
@ -22,6 +23,9 @@ function buildClient(cfg) {
secretAccessKey: cfg.secretKey,
},
forcePathStyle: true,
// Hard request/connection timeouts so a stalled RustFS GET can't hang the
// /video and /hls endpoints forever (the original browser-playback hang).
requestHandler: new NodeHttpHandler({ requestTimeout: 30_000, connectionTimeout: 10_000 }),
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});

View file

@ -9,6 +9,8 @@
import pool from './db/pool.js';
import { syncToAmpp } from './routes/upload.js';
import { restartChannel } from './routes/playout.js';
import { INTERNAL_TOKEN } from './middleware/auth.js';
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
@ -19,7 +21,10 @@ let _interval = null;
async function callSelf(path, method = 'POST') {
const res = await fetch(`${SELF_URL}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-internal-token': INTERNAL_TOKEN,
},
signal: AbortSignal.timeout(30000),
});
if (!res.ok) {
@ -29,11 +34,7 @@ async function callSelf(path, method = 'POST') {
return res.json().catch(() => ({}));
}
// Issue #103 — every mam-api replica runs the same tick on the same interval,
// so a multi-node deploy would double-fire recorder starts/stops. We guard
// the whole tick with a PG advisory lock (1 = scheduler) so exactly one
// replica processes a given interval. Pure-Postgres, no extra infra.
const SCHEDULER_LOCK_KEY = 8210301; // arbitrary, must be stable across replicas
const SCHEDULER_LOCK_KEY = 8210301;
async function tryAcquireSchedulerLock(client) {
const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]);
@ -52,14 +53,9 @@ async function tick() {
try {
haveLock = await tryAcquireSchedulerLock(client);
if (!haveLock) {
// Another replica is processing this interval — bail silently.
return;
}
// 1) Atomically claim pending schedules whose window has opened. The
// UPDATE...RETURNING flips status to 'running' in the same statement
// so even if another replica got past the lock (it can't, but
// belt-and-braces) each row can only be claimed once.
const dueStart = await client.query(
`UPDATE recorder_schedules
SET status = 'starting', updated_at = NOW()
@ -92,7 +88,6 @@ async function tick() {
}
}
// 2) Atomically claim running schedules whose window has closed.
const dueStop = await client.query(
`UPDATE recorder_schedules
SET status = 'stopping', updated_at = NOW()
@ -115,7 +110,6 @@ async function tick() {
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
await enqueueNextOccurrence(s, client);
} catch (err) {
// Stop failed — flag as failed but don't keep trying forever.
await client.query(
`UPDATE recorder_schedules
SET status = 'failed', error_message = $2, updated_at = NOW()
@ -126,7 +120,6 @@ async function tick() {
}
}
// 3) If a schedule was cancelled while running, stop the recorder.
const cancelledRunning = await client.query(
`SELECT s.* FROM recorder_schedules s
JOIN recorders r ON r.id = s.recorder_id
@ -142,9 +135,6 @@ async function tick() {
}
}
// 4) Mark stale live assets as 'error' (#66).
// If a capture container crashes without calling mark-empty/mark-complete,
// the asset row stays status='live' indefinitely. Timeout after 2 hours.
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
const staleResult = await client.query(
`UPDATE assets
@ -161,9 +151,6 @@ async function tick() {
}
}
// 5) AMPP sync retry (#77). Pick up any pending/failed rows whose
// next-attempt time has arrived and retry them. Cap per tick so we
// don't burn budget on a single rough interval.
const ampps = await client.query(
`SELECT id, project_id, bin_id FROM assets
WHERE ampp_sync_status IN ('pending', 'failed')
@ -175,6 +162,8 @@ async function tick() {
for (const row of ampps.rows) {
await syncToAmpp(row.id, row.project_id, row.bin_id);
}
await playoutHealthTick(client);
} catch (err) {
console.error('[scheduler] tick error:', err);
} finally {
@ -201,11 +190,73 @@ async function enqueueNextOccurrence(schedule, client) {
console.log(`[scheduler] queued next "${schedule.name}" → ${start.toISOString()}`);
}
// ── Playout channel health + failover ────────────────────────────────────────
// Tick step 6. Reuses the same advisory lock so only one replica probes the
// sidecars. A missed probe is counted via last_heartbeat_at age: > 3 *
// TICK_INTERVAL means 3 consecutive misses.
//
// IMPORTANT: when last_heartbeat_at is NULL (channel just spawned, no
// successful tick yet), use updated_at as the grace anchor — otherwise the
// "0" fallback makes ageMs huge and the channel is instantly failover-killed
// before its first heartbeat can ever land.
async function playoutHealthTick(client) {
let channels;
try {
({ rows: channels } = await client.query(
`SELECT id, output_type, container_meta, node_id, last_heartbeat_at, updated_at, restart_count
FROM playout_channels WHERE status = 'running'`
));
} catch (err) {
if (err.code === '42P01') return;
throw err;
}
const TIMEOUT_MS = TICK_INTERVAL_MS * 3 + 5000;
for (const ch of channels) {
const sidecarUrl =
ch.container_meta && ch.container_meta.sidecar_url
? ch.container_meta.sidecar_url
: `http://playout-${ch.id}:3002`;
try {
const r = await fetch(`${sidecarUrl}/status`, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(`status HTTP ${r.status}`);
await client.query(
'UPDATE playout_channels SET last_heartbeat_at = NOW() WHERE id = $1', [ch.id]
);
} catch (err) {
const lastSeen = ch.last_heartbeat_at
? new Date(ch.last_heartbeat_at).getTime()
: new Date(ch.updated_at).getTime();
const ageMs = Date.now() - lastSeen;
if (ageMs < TIMEOUT_MS) continue;
if (ch.output_type === 'decklink') {
await client.query(
"UPDATE playout_channels SET status = 'error', error_message = $1 WHERE id = $2",
[`sidecar unreachable (${err.message}); decklink channels require manual recovery`, ch.id]
);
console.error(`[scheduler] decklink channel ${ch.id} unreachable — alert-only, no auto-failover`);
continue;
}
console.warn(`[scheduler] failover: channel ${ch.id} unreachable (${err.message}), restart #${ch.restart_count + 1}`);
try {
const res = await restartChannel(ch.id);
if (res.restarted) {
console.log(`[scheduler] failover: channel ${ch.id} re-placed on node ${res.new_node_id}`);
} else {
console.error(`[scheduler] failover: channel ${ch.id} restart skipped — ${res.reason}`);
}
} catch (err2) {
console.error(`[scheduler] failover error for ${ch.id}: ${err2.message}`);
}
}
}
}
export function startSchedulerLoop() {
if (_interval) return;
console.log(`[scheduler] tick loop started (interval=${TICK_INTERVAL_MS}ms)`);
// Fire once on startup so a window that opened while the API was down
// doesn't have to wait a full interval.
setTimeout(() => tick().catch(() => {}), 2000);
_interval = setInterval(() => tick().catch(() => {}), TICK_INTERVAL_MS);
}

View file

@ -0,0 +1,125 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import {
isAdmin,
accessibleProjectIds,
projectLevel,
assertProjectAccess,
} from '../../src/auth/authz.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
// ── isAdmin (pure, no DB) ───────────────────────────────────────────────────
test('isAdmin true only for role admin', () => {
assert.equal(isAdmin({ role: 'admin' }), true);
assert.equal(isAdmin({ role: 'editor' }), false);
assert.equal(isAdmin({ role: 'viewer' }), false);
assert.equal(isAdmin(null), false);
assert.equal(isAdmin(undefined), false);
});
// Seed helpers shared across the DB-backed cases.
async function seed(pool) {
const proj = async (name) =>
(await pool.query(
`INSERT INTO projects (name, s3_prefix) VALUES ($1, $1) RETURNING id`, [name]
)).rows[0].id;
const user = async (username, role) =>
(await pool.query(
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', $2) RETURNING id`,
[username, role]
)).rows[0].id;
const group = async (name) =>
(await pool.query(`INSERT INTO groups (name) VALUES ($1) RETURNING id`, [name])).rows[0].id;
const grantUser = (pid, uid, level) =>
pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
VALUES ($1, 'user', $2, $3)`, [pid, uid, level]);
const grantGroup = (pid, gid, level) =>
pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
VALUES ($1, 'group', $2, $3)`, [pid, gid, level]);
const addToGroup = (uid, gid) =>
pool.query(`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [uid, gid]);
return { proj, user, group, grantUser, grantGroup, addToGroup };
}
test('admin sees all projects, every project at edit', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
await s.proj('Alpha'); await s.proj('Beta');
const admin = { id: await s.user('adm', 'admin'), role: 'admin' };
const acc = await accessibleProjectIds(admin, pool);
assert.equal(acc.all, true);
assert.equal(await projectLevel(admin, '00000000-0000-4000-8000-000000000001', pool), 'edit');
await assertProjectAccess(admin, '00000000-0000-4000-8000-000000000001', 'edit', pool); // no throw
} finally { await pool.end(); }
});
test('direct user grant scopes access and respects level', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const beta = await s.proj('Beta');
const u = { id: await s.user('bob', 'editor'), role: 'editor' };
await s.grantUser(alpha, u.id, 'view');
const acc = await accessibleProjectIds(u, pool);
assert.equal(acc.all, false);
assert.deepEqual([...acc.ids], [alpha]);
assert.equal(await projectLevel(u, alpha, pool), 'view');
assert.equal(await projectLevel(u, beta, pool), null);
await assertProjectAccess(u, alpha, 'view', pool); // ok
await assert.rejects(() => assertProjectAccess(u, alpha, 'edit', pool), e => e.status === 403);
await assert.rejects(() => assertProjectAccess(u, beta, 'view', pool), e => e.status === 403);
} finally { await pool.end(); }
});
test('group grant flows through membership', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const u = { id: await s.user('carol', 'viewer'), role: 'viewer' };
const g = await s.group('broadcasters');
await s.addToGroup(u.id, g);
await s.grantGroup(alpha, g, 'edit');
assert.equal(await projectLevel(u, alpha, pool), 'edit');
const acc = await accessibleProjectIds(u, pool);
assert.deepEqual([...acc.ids], [alpha]);
await assertProjectAccess(u, alpha, 'edit', pool); // ok via group
} finally { await pool.end(); }
});
test('effective level is the max of direct + group grants', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const u = { id: await s.user('dan', 'editor'), role: 'editor' };
const g = await s.group('team');
await s.addToGroup(u.id, g);
await s.grantUser(alpha, u.id, 'view'); // direct: view
await s.grantGroup(alpha, g, 'edit'); // group: edit → wins
assert.equal(await projectLevel(u, alpha, pool), 'edit');
} finally { await pool.end(); }
});
test('user with no grants sees nothing', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
await s.proj('Alpha');
const u = { id: await s.user('eve', 'viewer'), role: 'viewer' };
const acc = await accessibleProjectIds(u, pool);
assert.equal(acc.ids.size, 0);
} finally { await pool.end(); }
});

View file

@ -0,0 +1,40 @@
// Unit tests for the config-gating + domain helpers in google-oauth.js. The
// token-exchange / ID-token-verify path requires Google's servers and is covered
// by manual verification (see .env.example); here we lock down the pure logic
// that decides whether the feature is on and which domain is allowed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isConfigured, allowedDomain } from '../../src/auth/google-oauth.js';
function withEnv(vars, fn) {
const saved = {};
for (const k of Object.keys(vars)) { saved[k] = process.env[k];
if (vars[k] === undefined) delete process.env[k]; else process.env[k] = vars[k]; }
try { return fn(); }
finally {
for (const k of Object.keys(vars)) {
if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k];
}
}
}
test('isConfigured is false unless client id, secret, and redirect are all set', () => {
withEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
assert.equal(isConfigured(), false);
});
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
assert.equal(isConfigured(), false);
});
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: undefined }, () => {
assert.equal(isConfigured(), false);
});
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: 'https://h/cb' }, () => {
assert.equal(isConfigured(), true);
});
});
test('allowedDomain normalizes and defaults to null', () => {
withEnv({ GOOGLE_ALLOWED_DOMAIN: undefined }, () => assert.equal(allowedDomain(), null));
withEnv({ GOOGLE_ALLOWED_DOMAIN: '' }, () => assert.equal(allowedDomain(), null));
withEnv({ GOOGLE_ALLOWED_DOMAIN: ' WildDragon.NET ' }, () => assert.equal(allowedDomain(), 'wilddragon.net'));
});

View file

@ -0,0 +1,49 @@
// MFA ticket binding tests — the second login step's tickets are bound to the
// issuing request's IP + User-Agent (hashed) so a stolen ticket replayed from
// a different origin can't complete the second factor.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { issueTicket, redeemTicket } from '../../src/auth/mfa-tickets.js';
test('ticket round-trips when ip + userAgent match', () => {
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
});
test('ticket rejects redemption from a different IP', () => {
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
});
test('ticket rejects redemption with a different User-Agent', () => {
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'Mozilla/5.0' }), null);
});
test('ticket is single-use even on binding mismatch', () => {
// A wrong-binding probe must still burn the ticket — otherwise an attacker
// could try multiple IPs/UAs against the same ticket.
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
// Same ticket with correct bindings now also fails — it was consumed.
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
});
test('redeemTicket returns null for missing or unknown id', () => {
assert.equal(redeemTicket(null), null);
assert.equal(redeemTicket(undefined), null);
assert.equal(redeemTicket(''), null);
assert.equal(redeemTicket('not-a-real-id', { ip: 'x', userAgent: 'y' }), null);
});
test('ticket is single-use on success', () => {
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
});
test('issueTicket without bindings still works (back-compat / tests)', () => {
const id = issueTicket('user-1');
// No bindings on redeem either — both sides skip the check.
assert.equal(redeemTicket(id), 'user-1');
});

View file

@ -0,0 +1,96 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
base32Encode, base32Decode, generateSecret, generateToken,
verifyToken, otpauthURI, generateRecoveryCodes,
} from '../../src/auth/totp.js';
// ── base32 round-trips ──────────────────────────────────────────────────────
test('base32 encode/decode round-trips arbitrary bytes', () => {
for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
const buf = Buffer.from(s);
assert.deepEqual(base32Decode(base32Encode(buf)), buf);
}
});
// ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the
// low 6 here, so compare the last 6 digits of each published value). ──────────
// The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and
// check the 6-digit code at each published timestamp.
test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => {
const secret = base32Encode(Buffer.from('12345678901234567890'));
// [unix seconds, full 8-digit code from the RFC] → expect last 6 digits.
const vectors = [
[59, '94287082'],
[1111111109, '07081804'],
[1111111111, '14050471'],
[1234567890, '89005924'],
[2000000000, '69279037'],
[20000000000, '65353130'],
];
for (const [secs, full8] of vectors) {
const got = generateToken(secret, secs * 1000);
assert.equal(got, full8.slice(-6), `t=${secs}`);
}
});
// ── verify with drift window ────────────────────────────────────────────────
// verifyToken returns the matched counter (truthy) or null (falsy).
test('verifyToken accepts the current code and ±1 step of drift', () => {
const secret = generateSecret();
const now = 1_700_000_000_000;
const code = generateToken(secret, now);
const baseCounter = Math.floor(now / 1000 / 30);
assert.equal(verifyToken(secret, code, now), baseCounter);
// 30s earlier / later still inside ±1 window — the *issued* code matches the
// baseCounter, but at now+30s we're in step baseCounter+1, so the issued
// code matches as drift = -1 step → returns baseCounter.
assert.equal(verifyToken(secret, code, now + 30_000), baseCounter);
assert.equal(verifyToken(secret, code, now - 30_000), baseCounter);
// 2 steps away → rejected.
assert.equal(verifyToken(secret, code, now + 90_000), null);
});
test('verifyToken rejects malformed / empty input without throwing', () => {
const secret = generateSecret();
assert.equal(verifyToken(secret, ''), null);
assert.equal(verifyToken(secret, 'abcdef'), null);
assert.equal(verifyToken(secret, '12345'), null); // too short
assert.equal(verifyToken(secret, '1234567'), null); // too long
assert.equal(verifyToken('', '123456'), null);
});
test('verifyToken tolerates spaces in the user-entered code', () => {
const secret = generateSecret();
const now = 1_700_000_000_000;
const code = generateToken(secret, now);
const expected = Math.floor(now / 1000 / 30);
assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), expected);
});
test('verifyToken returns the matched counter (for replay protection)', () => {
const secret = generateSecret();
const now = 1_700_000_000_000;
const code = generateToken(secret, now);
const counter = verifyToken(secret, code, now);
assert.ok(typeof counter === 'number' && counter > 0);
assert.equal(counter, Math.floor(now / 1000 / 30));
});
// ── otpauth URI ─────────────────────────────────────────────────────────────
test('otpauthURI embeds secret, issuer, and account', () => {
const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight');
assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/);
assert.match(uri, /secret=JBSWY3DPEHPK3PXP/);
assert.match(uri, /issuer=Dragonflight/);
assert.match(uri, /digits=6/);
assert.match(uri, /period=30/);
});
// ── recovery codes ──────────────────────────────────────────────────────────
test('generateRecoveryCodes returns N distinct formatted codes', () => {
const codes = generateRecoveryCodes(10);
assert.equal(codes.length, 10);
assert.equal(new Set(codes).size, 10);
for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/);
});

View file

@ -0,0 +1,79 @@
// Regression test: GET /api/v1/assets must be scoped to the caller's accessible
// projects. A pre-fix bug returned every asset across every project to any
// authenticated user, defeating RBAC v2.
//
// Like project-access.test.js, the assets router uses the singleton pool, so we
// point DATABASE_URL at TEST_DATABASE_URL and seed through the same pool.
// Skips when TEST_DATABASE_URL is unset.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithAssets(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
// Importing the assets router constructs BullMQ queues; they connect lazily,
// and the list route only touches Postgres, so no Redis is needed here.
const { default: assetsRouter } = await import('../../src/routes/assets.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/assets', assetsRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const asset = (pid, name) => pool.query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status)
VALUES ($1, $2, $2, 'video', 'ready')`, [pid, name]);
await asset(alpha, 'a1'); await asset(alpha, 'a2'); await asset(beta, 'b1');
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
await pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`,
[alpha, scoped.id]);
return { alpha, beta, admin, scoped };
}
test('GET /assets returns only assets in granted projects for scoped users', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { admin, scoped } = await seed(pool);
// Admin sees all three.
let a = await appWithAssets(admin);
let body = await (await fetch(a.baseUrl + '/api/v1/assets')).json();
assert.equal(body.assets.length, 3, 'admin should see every asset');
await a.close();
// Scoped viewer (granted only Alpha) sees the two Alpha assets, not Beta's.
let s = await appWithAssets(scoped);
body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
assert.equal(body.assets.length, 2, 'scoped user should see only granted-project assets');
assert.ok(body.assets.every(x => x.display_name !== 'b1'), 'must not leak ungranted-project asset');
await s.close();
} finally { await pool.end(); }
});
test('GET /assets returns nothing for a user with no grants', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await seed(pool);
const nobody = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('no','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
const s = await appWithAssets(nobody);
const body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
assert.deepEqual(body, { assets: [], total: 0 });
await s.close();
} finally { await pool.end(); }
});

View file

@ -0,0 +1,76 @@
// RBAC coverage for asset comments: the guard resolves the project via the
// asset, requiring view to read and edit to write. Also verifies the author id
// is recorded from req.user (regression for the old req.session.userId bug).
// Skips without TEST_DATABASE_URL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithComments(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const { default: commentsRouter } = await import('../../src/routes/comments.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
// Mirror index.js mount so :assetId flows through (mergeParams in the router).
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const asset = async (pid, name) => (await pool.query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`, [pid, name])).rows[0].id;
const assetA = await asset(alpha, 'a1');
const assetB = await asset(beta, 'b1');
const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]);
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]);
return { assetA, assetB, viewer, editor };
}
test('comments require view to read and block ungranted assets', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { assetA, assetB, viewer } = await seed(pool);
const s = await appWithComments(viewer);
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments')).status, 200);
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetB + '/comments')).status, 403);
await s.close();
} finally { await pool.end(); }
});
test('posting a comment requires edit and records the author id from req.user', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { assetA, viewer, editor } = await seed(pool);
// viewer (view-only) cannot post.
let s = await appWithComments(viewer);
let r = await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'hi' }) });
assert.equal(r.status, 403);
await s.close();
// editor can post, and the author id is the editor (not null).
let e = await appWithComments(editor);
r = await fetch(e.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'looks good' }) });
assert.equal(r.status, 201);
const created = await r.json();
assert.equal(created.user_id, editor.id, 'comment author must be req.user.id');
await e.close();
} finally { await pool.end(); }
});

View file

@ -0,0 +1,63 @@
// Security regression test for resolveGoogleUser: a Google sign-in must NEVER
// adopt a pre-existing local account by matching email (that would be account
// takeover). It links only by google_sub, otherwise provisions a fresh viewer.
// Skips without TEST_DATABASE_URL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import { hashPassword } from '../../src/auth/passwords.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function loadResolve() {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
return (await import('../../src/routes/auth.js?v=' + Date.now())).resolveGoogleUser;
}
test('a Google login with an email matching a local admin does NOT take over that account', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
// Pre-existing local admin with a password and the same email the attacker controls.
const adminId = (await pool.query(
`INSERT INTO users (username, password_hash, role, email)
VALUES ('boss', $1, 'admin', 'boss@wilddragon.net') RETURNING id`,
[await hashPassword('a-real-password-12')])).rows[0].id;
const resolveGoogleUser = await loadResolve();
const user = await resolveGoogleUser({ sub: 'google-attacker-sub', email: 'boss@wilddragon.net', name: 'Not The Boss' });
// Must be a brand-new account, NOT the admin.
assert.notEqual(user.id, adminId, 'Google login must not resolve to the existing admin');
const row = (await pool.query(`SELECT role, google_sub FROM users WHERE id = $1`, [user.id])).rows[0];
assert.equal(row.role, 'viewer', 'auto-provisioned account must be a viewer');
assert.equal(row.google_sub, 'google-attacker-sub');
// The admin row is untouched (no google_sub linked onto it).
const admin = (await pool.query(`SELECT google_sub FROM users WHERE id = $1`, [adminId])).rows[0];
assert.equal(admin.google_sub, null, 'the existing admin must not have been linked');
} finally { await pool.end(); }
});
test('a returning Google user resolves to the same account by google_sub', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const resolveGoogleUser = await loadResolve();
const first = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
const second = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
assert.equal(first.id, second.id, 'same google_sub must map to the same user');
const count = (await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE google_sub = 'sub-123'`)).rows[0].n;
assert.equal(count, 1, 'must not create a duplicate on second login');
} finally { await pool.end(); }
});
test('username collisions get a numeric suffix', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('sam', 'x', 'viewer')`);
const resolveGoogleUser = await loadResolve();
const u = await resolveGoogleUser({ sub: 'sub-sam', email: 'sam@wilddragon.net', name: 'Sam' });
assert.match(u.username, /^sam\d+$/, 'colliding username should get a numeric suffix');
} finally { await pool.end(); }
});

View file

@ -0,0 +1,125 @@
// Integration test for per-project RBAC: the grant-management API on the
// projects router + scoped enforcement on GET /projects and GET /projects/:id.
//
// NOTE: the routers use the singleton pool (src/db/pool.js), which reads
// DATABASE_URL. We point DATABASE_URL at the throwaway TEST_DATABASE_URL and
// seed through that same singleton pool so the router and the test share one
// database. Skips cleanly when TEST_DATABASE_URL is unset.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
// Build an app that injects a chosen user as req.user (simulating requireAuth),
// then mounts the real projects router with the same admin gate index.js uses.
async function appWithProjects(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const { default: projectsRouter } = await import('../../src/routes/projects.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/projects', projectsRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seedBaseline(pool) {
const alpha = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Alpha','alpha') RETURNING id`)).rows[0].id;
const beta = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Beta','beta') RETURNING id`)).rows[0].id;
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
return { alpha, beta, admin, scoped };
}
test('admin grants a project, scoped user then sees only it; revoke removes access', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, beta, admin, scoped } = await seedBaseline(pool);
// Scoped viewer initially sees nothing.
let s = await appWithProjects(scoped);
let list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
assert.equal(list.length, 0, 'scoped user should see no projects before any grant');
// And cannot read Alpha directly.
let direct = await fetch(s.baseUrl + '/api/v1/projects/' + alpha);
assert.equal(direct.status, 403);
await s.close();
// Admin grants the scoped user 'view' on Alpha.
const a = await appWithProjects(admin);
const grant = await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'view' }),
});
assert.equal(grant.status, 201);
const grantList = await (await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access')).json();
assert.equal(grantList.length, 1);
assert.equal(grantList[0].subject_id, scoped.id);
await a.close();
// Scoped viewer now sees exactly Alpha (not Beta), can GET it, cannot PATCH (view-only).
s = await appWithProjects(scoped);
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
assert.deepEqual(list.map(p => p.id), [alpha]);
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + alpha)).status, 200);
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + beta)).status, 403);
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'hacked' }),
});
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
await s.close();
// Admin revokes; scoped viewer goes back to seeing nothing.
const a2 = await appWithProjects(admin);
const del = await fetch(a2.baseUrl + '/api/v1/projects/' + alpha + '/access/user/' + scoped.id, { method: 'DELETE' });
assert.equal(del.status, 204);
await a2.close();
s = await appWithProjects(scoped);
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
assert.equal(list.length, 0);
await s.close();
} finally { await pool.end(); }
});
test('non-admin cannot reach the grant-management API', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, scoped } = await seedBaseline(pool);
const s = await appWithProjects(scoped);
// requireAdmin sits on the access sub-routes; a viewer is 403.
const r = await fetch(s.baseUrl + '/api/v1/projects/' + alpha + '/access');
assert.equal(r.status, 403);
await s.close();
} finally { await pool.end(); }
});
test('edit-level grant allows PATCH', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, admin, scoped } = await seedBaseline(pool);
const a = await appWithProjects(admin);
await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'edit' }),
});
await a.close();
const s = await appWithProjects(scoped);
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'updated by editor' }),
});
assert.equal(patch.status, 200);
await s.close();
} finally { await pool.end(); }
});

View file

@ -0,0 +1,107 @@
// RBAC coverage for recorders: list is scoped to accessible projects, /:id
// asserts view, mutators assert edit, null-project recorders are admin-only.
// Same harness as assets-access.test.js — singleton pool on TEST_DATABASE_URL,
// req.user injected, router dynamic-imported. Skips without TEST_DATABASE_URL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithRecorders(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const { default: recordersRouter } = await import('../../src/routes/recorders.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/recorders', recordersRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const rec = async (pid, name) => (await pool.query(
`INSERT INTO recorders (name, source_type, project_id) VALUES ($1, 'srt', $2) RETURNING id`, [name, pid])).rows[0].id;
const recA = await rec(alpha, 'Cam A');
const recB = await rec(beta, 'Cam B');
const recNull = (await pool.query(
`INSERT INTO recorders (name, source_type, project_id) VALUES ('Unassigned','srt',NULL) RETURNING id`)).rows[0].id;
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, scoped.id]);
return { alpha, beta, recA, recB, recNull, admin, scoped };
}
test('recorders list is scoped; admin sees all incl. null-project, scoped sees only granted', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { recA, admin, scoped } = await seed(pool);
let a = await appWithRecorders(admin);
let list = await (await fetch(a.baseUrl + '/api/v1/recorders')).json();
assert.equal(list.length, 3, 'admin sees all three recorders');
await a.close();
let s = await appWithRecorders(scoped);
list = await (await fetch(s.baseUrl + '/api/v1/recorders')).json();
assert.deepEqual(list.map(r => r.id), [recA], 'scoped editor sees only the granted-project recorder');
await s.close();
} finally { await pool.end(); }
});
test('recorder /:id enforces view; mutators enforce edit', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { recA, recB, recNull, scoped } = await seed(pool);
const s = await appWithRecorders(scoped);
// view-granted on Alpha: can GET recA, cannot GET recB (other project) or recNull (admin-only).
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recA)).status, 200);
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recB)).status, 403);
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recNull)).status, 403);
// view-only grant cannot PATCH (edit) or start.
const patch = await fetch(s.baseUrl + '/api/v1/recorders/' + recA, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x' }) });
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
await s.close();
} finally { await pool.end(); }
});
test('creating a recorder requires edit; null-project create is admin-only', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, admin, scoped } = await seed(pool);
// scoped editor has only 'view' on Alpha → create denied.
let s = await appWithRecorders(scoped);
let r = await fetch(s.baseUrl + '/api/v1/recorders', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New', source_type: 'srt', project_id: alpha }) });
assert.equal(r.status, 403);
// null project → admin-only, still denied for the editor.
r = await fetch(s.baseUrl + '/api/v1/recorders', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New2', source_type: 'srt' }) });
assert.equal(r.status, 403);
await s.close();
// admin can create in any project and with no project.
let a = await appWithRecorders(admin);
r = await fetch(a.baseUrl + '/api/v1/recorders', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'AdminRec', source_type: 'srt' }) });
assert.equal(r.status, 201);
await a.close();
} finally { await pool.end(); }
});

View file

@ -0,0 +1,103 @@
// RBAC coverage for sequences: list/create assert on the query/body project,
// /:id asserts view, mutators assert edit. Skips without TEST_DATABASE_URL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithSequences(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const { default: sequencesRouter } = await import('../../src/routes/sequences.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/sequences', sequencesRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const seqB = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'B seq') RETURNING id`, [beta])).rows[0].id;
const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]);
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]);
return { alpha, beta, seqB, viewer, editor };
}
const asset = (pool, pid, name) => pool.query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`,
[pid, name]).then(r => r.rows[0].id);
test('GET /sequences?project_id 403s on an ungranted project', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, beta, viewer } = await seed(pool);
const s = await appWithSequences(viewer);
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + alpha)).status, 200);
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + beta)).status, 403);
await s.close();
} finally { await pool.end(); }
});
test('POST /sequences requires edit; viewer denied, editor allowed; /:id view enforced', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, seqB, viewer, editor } = await seed(pool);
// viewer (view-only on Alpha) cannot create.
let s = await appWithSequences(viewer);
let r = await fetch(s.baseUrl + '/api/v1/sequences', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'X' }) });
assert.equal(r.status, 403);
// viewer cannot read a sequence in an ungranted project (Beta).
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences/' + seqB)).status, 403);
await s.close();
// editor can create in Alpha and then PUT it.
let e = await appWithSequences(editor);
r = await fetch(e.baseUrl + '/api/v1/sequences', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'Mine' }) });
assert.equal(r.status, 201);
const seqId = (await r.json()).id;
const put = await fetch(e.baseUrl + '/api/v1/sequences/' + seqId, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Renamed' }) });
assert.equal(put.status, 200);
await e.close();
} finally { await pool.end(); }
});
test('PUT /:id/clips rejects assets from outside the sequence project (cross-project leak guard)', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, beta, editor } = await seed(pool);
const seqA = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'A seq') RETURNING id`, [alpha])).rows[0].id;
const assetA = await asset(pool, alpha, 'a-clip');
const assetB = await asset(pool, beta, 'b-clip'); // editor has NO access to project Beta
const e = await appWithSequences(editor);
const clip = (aid) => ({ asset_id: aid, track: 0, timeline_in_frames: 0, timeline_out_frames: 10, source_in_frames: 0, source_out_frames: 10 });
// Same-project asset is accepted.
let r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA)]) });
assert.equal(r.status, 200);
// Splicing in a Beta asset must be rejected — it would leak B's media via GET /:id.
r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA), clip(assetB)]) });
assert.equal(r.status, 400, 'foreign-project asset must be rejected');
await e.close();
} finally { await pool.end(); }
});

View file

@ -0,0 +1,143 @@
// Integration test for the TOTP two-step login + recovery codes.
//
// Mounts the real auth router with a session store on the throwaway test DB.
// Drives: enroll (setup → enable) → logout → password login returns mfa_required
// → complete with a generated code → and the recovery-code single-use path.
// Skips when TEST_DATABASE_URL is unset.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import { hashPassword } from '../../src/auth/passwords.js';
import { generateToken } from '../../src/auth/totp.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithAuth(pool) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
process.env.SESSION_SECRET = 'test';
const ConnectPg = (await import('connect-pg-simple')).default(session);
const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use(session({
store: new ConnectPg({ pool, tableName: 'sessions' }),
secret: 'test', name: 'dragonflight.sid',
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
app.use('/api/v1/auth', authRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
const J = (cookie, body) => ({
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
body: JSON.stringify(body),
});
async function loginPassword(baseUrl, username, password) {
const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password }));
const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
return { r, body: await r.json().catch(() => ({})), cookie };
}
test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
// 1. Password login (no TOTP yet) → 200 with a session cookie.
const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
assert.equal(first.r.status, 200);
assert.ok(!first.body.mfa_required);
const cookie = first.cookie;
// 2. Enroll: setup returns a secret; enable confirms with a live code.
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json();
assert.match(setup.secret, /^[A-Z2-7]+$/);
const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) }));
assert.equal(enableRes.status, 200);
const enableBody = await enableRes.json();
assert.equal(enableBody.enabled, true);
assert.equal(enableBody.recovery_codes.length, 10);
// 3. Fresh password login now returns mfa_required + a ticket, NO session cookie.
const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
assert.equal(second.r.status, 200);
assert.equal(second.body.mfa_required, true);
assert.ok(second.body.ticket);
assert.ok(!second.cookie, 'no session cookie should be set before the second factor');
// 4. Wrong code → 401; the ticket is now spent.
const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' }));
assert.equal(bad.status, 401);
// 5. New login + correct code → 200 with a session cookie.
const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) }));
assert.equal(ok.status, 200);
assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/);
await close();
} finally { await pool.end(); }
});
test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json();
const recovery = enableBody.recovery_codes[0];
// Use a recovery code to complete a fresh login.
const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery }));
assert.equal(use1.status, 200, 'recovery code should complete login once');
// The same recovery code must NOT work a second time.
const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery }));
assert.equal(use2.status, 401, 'a spent recovery code must be rejected');
await close();
} finally { await pool.end(); }
});
test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }));
// Disabling requires the password.
const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' }));
assert.equal(wrongPw.status, 400);
const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' }));
assert.equal(disabled.status, 204);
// Password login is single-factor again.
const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
assert.equal(relog.r.status, 200);
assert.ok(!relog.body.mfa_required);
await close();
} finally { await pool.end(); }
});

View file

@ -8,7 +8,7 @@ const NODE_ROLE = process.env.NODE_ROLE || 'worker';
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7436', 10);
const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || '30000', 10);
const LIVE_DIR = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
const VERSION = '1.2.0';
const VERSION = '1.3.0';
// Pick the host's LAN IP. Inside a bridge-mode container,
// os.networkInterfaces() returns the container's docker-bridge IP (172.x),
@ -87,19 +87,54 @@ async function handleSidecarStart(body, res) {
env = [],
capturePort = 3001,
sourceType = 'sdi',
// useGpu: true → attach NVIDIA runtime + NVIDIA_VISIBLE_DEVICES so the
// sidecar can call hevc_nvenc / h264_nvenc inside capture ffmpeg.
// Only set this when the recorder codec is GPU-accelerated; CPU codecs
// (ProRes, DNxHR, libx264) don't need it and it avoids a hard dep on the
// NVIDIA container runtime on nodes that have no GPU.
useGpu = false,
} = body;
const binds = [`${LIVE_DIR}:/live`];
if (sourceType === 'sdi') binds.unshift('/dev/blackmagic:/dev/blackmagic');
if (sourceType === 'deltacast') {
// Bind each /dev/deltacast* node that exists on the host into the container.
// If none exist the capture container falls back to test-card (lavfi) mode.
try {
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
for (const d of dcEntries) binds.push(`/dev/${d}:/dev/${d}`);
} catch (_) { /* /dev always exists */ }
}
const spec = {
Image: image,
Env: [...env, `PORT=${capturePort}`],
HostConfig: {
// Build the sidecar environment, injecting NVIDIA vars when GPU is requested.
const sidecarEnv = [...env, `PORT=${capturePort}`];
if (useGpu) {
// NVIDIA_VISIBLE_DEVICES=all exposes every GPU on the host.
// For a single-GPU node (zampp2 / L4) this is equivalent to pinning GPU 0.
// When we later store per-recorder GPU affinity in the DB we can pass a
// specific UUID here instead.
sidecarEnv.push('NVIDIA_VISIBLE_DEVICES=all');
sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
}
const hostConfig = {
NetworkMode: 'host',
Privileged: true,
Binds: binds,
},
};
if (useGpu) {
// Tell Docker to use the NVIDIA container runtime for this container.
// Equivalent to `docker run --gpus all` / `--runtime=nvidia`.
hostConfig.Runtime = 'nvidia';
hostConfig.DeviceRequests = [
{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] },
];
}
const spec = {
Image: image,
Env: sidecarEnv,
HostConfig: hostConfig,
};
const createRes = await dockerApi('POST', '/containers/create', spec);
@ -108,6 +143,9 @@ async function handleSidecarStart(body, res) {
}
const containerId = createRes.data.Id;
const _u = (env.find(e => e.startsWith('MAM_API_URL=')) || '').slice(12);
const _tok = env.some(e => e.startsWith('MAM_API_TOKEN=') && e.length > 14);
console.log(`[sidecar-start] ${containerId} image=${image} src=${sourceType} MAM_API_URL=${_u} token=${_tok}`);
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
@ -120,12 +158,40 @@ async function handleSidecarStart(body, res) {
}
}
async function fetchContainerLogs(containerId) {
return await new Promise((resolve) => {
const options = {
socketPath: '/var/run/docker.sock',
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=1&tail=200`,
method: 'GET',
};
const req = http.request(options, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').replace(/[\x00-\x08]/g, '')));
});
req.on('error', () => resolve('(log fetch failed)'));
req.end();
});
}
async function handleSidecarStop(containerId, res) {
try {
await dockerApi('POST', `/containers/${containerId}/stop`).catch(() => {});
console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`);
// Grace period must exceed the capture container's shutdown work
// (finalise ffmpeg session + register asset via callback). Default
// docker stop is only 10s, which SIGKILLs capture mid-finalise and
// loses the POST /assets callback -> asset stuck 'live', no jobs.
await dockerApi('POST', `/containers/${containerId}/stop?t=180`).catch(() => {});
// Dump the capture container's shutdown logs into our persistent log
// BEFORE removing it, so failed callbacks are diagnosable.
const logs = await fetchContainerLogs(containerId);
console.log(`[sidecar-stop] ==== capture logs for ${containerId} ====\n${logs}\n[sidecar-stop] ==== end logs ====`);
// Container has now exited gracefully (or hit the 180s cap); remove it.
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
jsonResponse(res, 200, { ok: true });
} catch (err) {
console.error(`[sidecar-stop] error: ${err.message}`);
jsonResponse(res, 500, { error: err.message });
}
}
@ -180,6 +246,71 @@ function sampleCpu() {
});
}
// -- Live GPU utilization sampling -----------------------------------------
// Spawns a short-lived nvidia container via Docker API on each heartbeat call.
// Returns array of { index, util_pct, mem_used_mb, mem_total_mb } per GPU,
// or [] if no GPUs / nvidia runtime unavailable.
async function sampleGpuUtil() {
if (!_gpuCache || _gpuCache.length === 0) return [];
const QUERY = '--query-gpu=index,utilization.gpu,memory.used,memory.total';
const FMT = '--format=csv,noheader,nounits';
let containerId;
try {
const createRes = await dockerApi('POST', '/containers/create', {
Image: 'ubuntu:22.04',
Cmd: ['nvidia-smi', QUERY, FMT],
HostConfig: {
AutoRemove: false,
Runtime: 'nvidia',
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
},
});
if (createRes.status !== 201) return [];
containerId = createRes.data.Id;
await dockerApi('POST', `/containers/${containerId}/start`);
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 400));
const inspect = await dockerApi('GET', `/containers/${containerId}/json`);
if (!inspect.data?.State?.Running) break;
}
const logRes = await new Promise((resolve, reject) => {
const options = {
socketPath: '/var/run/docker.sock',
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=0`,
method: 'GET',
};
const req = http.request(options, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
req.end();
});
const text = logRes.replace(/[\x00-\x07].{7}/g, '').trim();
const lines = text.split('\n').filter(l => /^\d+,/.test(l.trim()));
return lines.map(line => {
const [idx, util, memUsed, memTotal] = line.split(',').map(s => parseInt(s.trim(), 10));
return { index: idx, util_pct: util, mem_used_mb: memUsed, mem_total_mb: memTotal };
});
} catch (err) {
console.warn('[gpu-util] sampling failed:', err.message);
return [];
} finally {
if (containerId) {
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
}
}
}
// ── Hardware detection ────────────────────────────────────────────────────
// GPU_COUNT / BMD_COUNT env vars override filesystem detection when /dev isn't mapped
// Cached GPU info from nvidia-smi — populated once at startup via Docker API.
@ -264,7 +395,7 @@ async function probeGpusViaSmi() {
}
function detectHardware() {
const capabilities = { gpus: [], blackmagic: [] };
const capabilities = { gpus: [], blackmagic: [], deltacast: [] };
// Issue #108 — previously GPU_COUNT short-circuited the entire detection
// path, throwing away the nvidia-smi enrichment (model, memory, driver
@ -311,12 +442,39 @@ function detectHardware() {
// to render the correct card layout (Duo 2, Quad 2, Mini Recorder, ...).
if (process.env.BMD_MODEL) capabilities.blackmagic_model = process.env.BMD_MODEL;
// Deltacast SDI cards — enumerate /dev/deltacast* device nodes.
// DELTACAST_PORT_COUNT env overrides when devices aren't mapped (test/dev mode).
const dcOverride = parseInt(process.env.DELTACAST_PORT_COUNT || '-1', 10);
try {
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
if (dcEntries.length > 0) {
capabilities.deltacast = dcEntries.map((d, i) => ({
device: `/dev/${d}`,
index: i,
present: true,
}));
} else if (dcOverride >= 0) {
// No device nodes but count is configured — test-card mode.
for (let i = 0; i < dcOverride; i++) {
capabilities.deltacast.push({ device: `/dev/deltacast${i}`, index: i, present: false });
}
}
} catch (_) {
if (dcOverride >= 0) {
for (let i = 0; i < dcOverride; i++) {
capabilities.deltacast.push({ device: `/dev/deltacast${i}`, index: i, present: false });
}
}
}
if (process.env.DELTACAST_MODEL) capabilities.deltacast_model = process.env.DELTACAST_MODEL;
return capabilities;
}
// ── Heartbeat ─────────────────────────────────────────────────────────────
async function heartbeat() {
const cpu_usage = await sampleCpu();
const gpu_util = await sampleGpuUtil();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const ip_address = getIp();
@ -332,6 +490,7 @@ async function heartbeat() {
mem_used_mb: Math.round((totalMem - freeMem) / 1048576),
mem_total_mb: Math.round(totalMem / 1048576),
capabilities,
gpu_util,
};
const headers = { 'Content-Type': 'application/json' };
@ -347,9 +506,10 @@ async function heartbeat() {
if (res.ok) {
const gpuStr = capabilities.gpus.length ? ` gpu=${capabilities.gpus.length}` : '';
const bmdStr = capabilities.blackmagic.length ? ` bmd=${capabilities.blackmagic.length}` : '';
const dcStr = capabilities.deltacast.length ? ` dc=${capabilities.deltacast.length}` : '';
process.stdout.write(
`[hb] ${payload.hostname} ip=${ip_address || '?'} cpu=${cpu_usage}% ` +
`mem=${payload.mem_used_mb}/${payload.mem_total_mb}MB${gpuStr}${bmdStr}\n`
`mem=${payload.mem_used_mb}/${payload.mem_total_mb}MB${gpuStr}${bmdStr}${dcStr}\n`
);
} else {
const txt = await res.text().catch(() => '');
@ -367,6 +527,22 @@ probeGpusViaSmi().then(() => {
setInterval(heartbeat, HEARTBEAT_MS);
});
// Serve the local HLS live-preview files (written by the capture sidecar to
// LIVE_DIR) so the primary can reverse-proxy them to the browser. Read-only.
function serveLiveFile(pathname, res) {
const rel = decodeURIComponent(pathname.slice('/live/'.length));
if (!rel || rel.includes('..') || rel.startsWith('/')) { res.writeHead(403); return res.end(); }
const filePath = LIVE_DIR + '/' + rel;
fs.readFile(filePath, (err, data) => {
if (err) { res.writeHead(404); return res.end(); }
const ct = filePath.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl'
: filePath.endsWith('.ts') ? 'video/mp2t'
: 'application/octet-stream';
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*' });
res.end(data);
});
}
// ── HTTP server ───────────────────────────────────────────────────────────
const server = http.createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
@ -396,6 +572,9 @@ const server = http.createServer((req, res) => {
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
handleSidecarStatus(id, res);
} else if (req.method === 'GET' && pathname.startsWith('/live/')) {
serveLiveFile(pathname, res);
} else {
res.writeHead(404);
res.end();

View file

@ -0,0 +1,58 @@
# Wild Dragon Playout sidecar — CasparCG Server + Node AMCP control shim.
FROM ubuntu:22.04
ARG CASPAR_VERSION=2.4.0-stable
ARG CASPAR_URL=https://github.com/CasparCG/server/releases/download/v2.4.0-stable/casparcg-server-v2.4.0-stable-ubuntu22.zip
ARG NDI_SDK_URL=
ENV DEBIAN_FRONTEND=noninteractive
# CEF (HTML producer) needs libnss3 + chromium runtime deps. Without these the
# server starts fine but SIGABRTs ~30s in when it lazy-inits CEF (NSS -8023).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl unzip tar xz-utils gnupg \
xvfb libgl1-mesa-dri libglu1-mesa fonts-dejavu-core \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libasound2 libatspi2.0-0 \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/caspar
RUN set -eux; \
curl -fsSL "$CASPAR_URL" -o caspar.zip; \
unzip -q caspar.zip -d /opt; \
chmod +x /opt/casparcg_server/bin/casparcg /opt/casparcg_server/scanner 2>/dev/null || true; \
ls /opt/casparcg_server/; \
test -x /opt/casparcg_server/bin/casparcg; \
ln -sfn /opt/casparcg_server /opt/casparcg; \
echo "caspar binary: /opt/casparcg_server/bin/casparcg"; \
cd /; rm -rf /tmp/caspar
RUN if [ -n "$NDI_SDK_URL" ]; then \
mkdir -p /opt/ndi-lib && \
curl -fsSL "$NDI_SDK_URL" -o /tmp/ndi.tar.gz && \
tar xzf /tmp/ndi.tar.gz -C /tmp && \
find /tmp -name 'libndi*.so*' -exec cp -a {} /opt/ndi-lib/ \; && \
rm -f /tmp/ndi.tar.gz && ldconfig /opt/ndi-lib || true; \
fi
ENV NDI_RUNTIME_DIR_V6=/opt/ndi-lib
RUN mkdir -p /media
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
COPY casparcg.config /opt/casparcg/casparcg.config
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 3002 5250
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<paths>
<media-path>/media/</media-path>
<log-path>/media/casparcg/log/</log-path>
<data-path>/media/casparcg/data/</data-path>
<template-path>/media/templates/</template-path>
</paths>
<channels>
<channel>
<video-mode>1080i5994</video-mode>
<consumers>
</consumers>
</channel>
</channels>
<controllers>
<tcp>
<port>5250</port>
<protocol>AMCP</protocol>
</tcp>
</controllers>
</configuration>

View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
if [ -z "${DISPLAY:-}" ]; then
echo "[entrypoint] starting Xvfb on :99"
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
export DISPLAY=:99
for i in $(seq 1 20); do
[ -e /tmp/.X11-unix/X99 ] && break
sleep 0.25
done
fi
if [ -n "${CHANNEL_ID:-}" ]; then
mkdir -p "/media/live/${CHANNEL_ID}"
fi
mkdir -p /media/casparcg/log /media/casparcg/data /media/templates
# CEF (HTML producer) initialises an NSS database at /root/.pki/nssdb and
# Chrome caches under HOME. Pre-create writable dirs so CEF doesn't SIGABRT
# ~30s into the run when it first lazily inits.
mkdir -p /root/.pki/nssdb /root/.cache /tmp/cef-cache
chmod 700 /root/.pki/nssdb
export HOME=/root
# 2.4.x zip bundles its own .so files under lib/ — add to LD_LIBRARY_PATH.
export LD_LIBRARY_PATH="/opt/casparcg/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
cd /opt/casparcg
CASPAR_CFG=/opt/casparcg/casparcg.config
# 2.4.x: binary at bin/casparcg. 2.5.x: symlinked to casparcg at root.
if [ -x "./bin/casparcg" ]; then CASPAR_BIN="./bin/casparcg";
elif [ -x "./casparcg" ]; then CASPAR_BIN="./casparcg";
elif [ -x "./CasparCG Server" ]; then CASPAR_BIN="./CasparCG Server";
elif command -v casparcg >/dev/null; then CASPAR_BIN="casparcg";
else echo "[entrypoint] ERROR: casparcg binary not found"; exit 1; fi
echo "[entrypoint] launching CasparCG: $CASPAR_BIN $CASPAR_CFG"
"$CASPAR_BIN" "$CASPAR_CFG" &
CASPAR_PID=$!
term() {
echo "[entrypoint] terminating CasparCG ($CASPAR_PID)"
kill -TERM "$CASPAR_PID" 2>/dev/null || true
wait "$CASPAR_PID" 2>/dev/null || true
exit 0
}
trap term SIGTERM SIGINT
cd /app
node src/index.js &
NODE_PID=$!
wait -n "$CASPAR_PID" "$NODE_PID"
term

View file

@ -0,0 +1,18 @@
{
"name": "wild-dragon-playout",
"version": "1.0.0",
"description": "Wild Dragon MAM playout sidecar — wraps a CasparCG Server instance and drives it over AMCP for master-control playout (SDI / NDI / SRT / RTMP).",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.18.0",
"cors": "^2.8.0",
"dotenv": "^16.4.0"
}
}

View file

@ -0,0 +1,182 @@
import net from 'node:net';
// Minimal AMCP (Advanced Media Control Protocol) client for CasparCG.
//
// AMCP is a line-based TCP protocol: each command is a single CRLF-terminated
// line, and the server replies with a status line ("201 PLAY OK\r\n") optionally
// followed by data lines. We keep one persistent socket per CasparCG instance
// and serialize commands through a FIFO queue — CasparCG processes one command
// at a time per connection, so interleaving replies would otherwise be
// ambiguous.
//
// We only implement the subset the playout sidecar needs (PLAY / LOADBG / STOP /
// CLEAR / INFO / ADD / REMOVE). Responses are returned raw; callers parse the
// status code where they care.
const CRLF = '\r\n';
export class AmcpClient {
constructor({ host = '127.0.0.1', port = 5250 } = {}) {
this.host = host;
this.port = port;
this.socket = null;
this.connected = false;
this._buffer = '';
this._queue = []; // pending { command, resolve, reject, timer }
this._active = null; // command currently awaiting a reply
this._reconnectTimer = null;
}
connect() {
if (this.socket) return;
const socket = net.createConnection({ host: this.host, port: this.port });
socket.setEncoding('utf8');
socket.setKeepAlive(true, 10000);
socket.on('connect', () => {
this.connected = true;
console.log(`[amcp] connected to ${this.host}:${this.port}`);
});
socket.on('data', (chunk) => this._onData(chunk));
socket.on('error', (err) => {
console.error(`[amcp] socket error: ${err.message}`);
});
socket.on('close', () => {
this.connected = false;
this.socket = null;
// Fail any in-flight + queued commands so callers don't hang.
const pending = this._active ? [this._active, ...this._queue] : [...this._queue];
this._active = null;
this._queue = [];
for (const p of pending) {
clearTimeout(p.timer);
p.reject(new Error('AMCP connection closed'));
}
this._scheduleReconnect();
});
this.socket = socket;
}
_scheduleReconnect() {
if (this._reconnectTimer) return;
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
console.log('[amcp] reconnecting...');
this.connect();
}, 2000);
}
// Wait until the socket is usable, up to timeoutMs.
async waitReady(timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (this.connected) return true;
if (!this.socket) this.connect();
await new Promise((r) => setTimeout(r, 250));
}
throw new Error('AMCP not ready within timeout');
}
_onData(chunk) {
this._buffer += chunk;
// A CasparCG reply is a status line, optionally followed by data lines.
// The simplest robust framing: a command's reply is complete when we see a
// status line AND (for 2-line "200" multi-line replies) the terminating
// blank line. For our command subset, single-status-line replies dominate;
// we treat a reply as complete at each newline and let the active command
// decide whether it has enough. To keep this correct for INFO (multi-line),
// we accumulate until the buffer ends with a known terminator.
if (!this._active) {
// Unsolicited data (e.g. connection banner) — discard.
this._buffer = '';
return;
}
// CasparCG ends multi-line replies with CRLF on an empty line. Single-line
// replies (201/202/4xx/5xx) end with a single CRLF. Resolve when we have at
// least one complete line; for "200 ... OK" (list follows) wait for the
// blank-line terminator.
const firstLineEnd = this._buffer.indexOf(CRLF);
if (firstLineEnd === -1) return;
const statusLine = this._buffer.slice(0, firstLineEnd);
const code = parseInt(statusLine, 10);
if (code === 200) {
// Multi-line: data lines until an empty line.
const term = this._buffer.indexOf(CRLF + CRLF);
if (term === -1) return; // wait for more
const full = this._buffer.slice(0, term);
this._buffer = this._buffer.slice(term + 4);
this._finishActive(null, full);
return;
}
if (code === 201 || code === 202) {
// 201: one data line follows the status line. 202: status only.
if (code === 201) {
const secondLineEnd = this._buffer.indexOf(CRLF, firstLineEnd + 2);
if (secondLineEnd === -1) return;
const full = this._buffer.slice(0, secondLineEnd);
this._buffer = this._buffer.slice(secondLineEnd + 2);
this._finishActive(null, full);
} else {
const full = this._buffer.slice(0, firstLineEnd);
this._buffer = this._buffer.slice(firstLineEnd + 2);
this._finishActive(null, full);
}
return;
}
// 4xx / 5xx error, or any other single-line status.
const full = this._buffer.slice(0, firstLineEnd);
this._buffer = this._buffer.slice(firstLineEnd + 2);
if (code >= 400) this._finishActive(new Error(`AMCP error: ${full}`), full);
else this._finishActive(null, full);
}
_finishActive(err, data) {
const active = this._active;
this._active = null;
if (active) {
clearTimeout(active.timer);
if (err) active.reject(err);
else active.resolve(data);
}
this._pump();
}
_pump() {
if (this._active || this._queue.length === 0) return;
const next = this._queue.shift();
this._active = next;
try {
this.socket.write(next.command + CRLF);
} catch (err) {
this._active = null;
clearTimeout(next.timer);
next.reject(err);
}
}
// Send a single AMCP command and resolve with the raw reply string.
send(command, { timeoutMs = 15000 } = {}) {
return new Promise((resolve, reject) => {
const entry = { command, resolve, reject, timer: null };
entry.timer = setTimeout(() => {
// Drop from queue if still pending; if active, detach so the next
// reply doesn't get misrouted.
if (this._active === entry) this._active = null;
else this._queue = this._queue.filter((e) => e !== entry);
reject(new Error(`AMCP command timed out: ${command}`));
}, timeoutMs);
this._queue.push(entry);
this._pump();
});
}
close() {
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
if (this.socket) { try { this.socket.destroy(); } catch (_) {} this.socket = null; }
this.connected = false;
}
}

View file

@ -0,0 +1,85 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import playoutManager from './playout-manager.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3002;
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Start the channel's output consumer. Body: { outputType, outputConfig, videoFormat }
app.post('/channel/start', async (req, res) => {
try {
const out = await playoutManager.startChannel(req.body || {});
res.json(out);
} catch (err) {
console.error('[playout] /channel/start error:', err.message);
res.status(500).json({ error: err.message });
}
});
app.post('/channel/stop', async (req, res) => {
try { res.json(await playoutManager.stopChannel()); }
catch (err) { res.status(500).json({ error: err.message }); }
});
// Load + start a playlist. Body: { items: [...], loop }
app.post('/playlist/load', async (req, res) => {
try {
const { items = [], loop = false } = req.body || {};
res.json(await playoutManager.loadPlaylist({ items, loop }));
} catch (err) {
console.error('[playout] /playlist/load error:', err.message);
res.status(500).json({ error: err.message });
}
});
app.post('/transport/skip', async (req, res) => { try { res.json(await playoutManager.skip()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.post('/transport/pause', async (req, res) => { try { res.json(await playoutManager.pause()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.post('/transport/resume', async (req, res) => { try { res.json(await playoutManager.resume()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.get('/status', (req, res) => res.json(playoutManager.getStatus()));
// Auto-start: when the sidecar is spawned by mam-api with channel env, bring up
// the output consumer immediately so the container is "on air idle" (black/slate)
// the moment it boots, mirroring the capture sidecar's bootstrap pattern.
async function bootstrap() {
const outputType = process.env.OUTPUT_TYPE;
if (!outputType) {
console.log('[bootstrap] no OUTPUT_TYPE — on-demand sidecar, waiting for /channel/start');
return;
}
let outputConfig = {};
try { outputConfig = JSON.parse(process.env.OUTPUT_CONFIG || '{}'); }
catch (err) { console.error('[bootstrap] bad OUTPUT_CONFIG json:', err.message); }
const videoFormat = process.env.VIDEO_FORMAT || '1080i5994';
try {
await playoutManager.startChannel({ outputType, outputConfig, videoFormat });
} catch (err) {
console.error('[bootstrap] channel start failed:', err.message);
}
}
const server = app.listen(PORT, () => {
console.log(`Wild Dragon Playout Service listening on port ${PORT}`);
// Give CasparCG a moment to come up (started by the container entrypoint).
playoutManager.amcp.connect();
bootstrap();
});
function shutdown(sig) {
console.log(`[playout] ${sig} — shutting down`);
playoutManager.stopChannel().catch(() => {}).finally(() => {
playoutManager.amcp.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5000);
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

View file

@ -0,0 +1,316 @@
import { AmcpClient } from './amcp.js';
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
//
// One sidecar container == one CasparCG Server == one logical channel (channel
// index 1 in CasparCG terms). We add the output consumer (DeckLink / NDI / SRT
// / RTMP) at start, then walk a playlist by cueing the next clip on a background
// layer (LOADBG ... AUTO) so CasparCG performs a gapless transition at end of
// the current clip.
//
// Media is referenced by a path relative to CasparCG's configured media folder
// (/media inside the container). The mam-api stages assets from S3 to that
// shared volume and passes the resolved relative path on each item.
const CHANNEL = 1; // single CasparCG channel per sidecar
const FG_LAYER = 10; // foreground (on-air) layer
const MEDIA_ROOT = process.env.CASPAR_MEDIA_ROOT || '/media';
// Channel-id-derived HLS preview path. The mam-api proxies /live/<channel_id>/
// to this directory (shared media volume) so the UI's existing HLS player
// (capture's /live/<id> plumbing) works for playout monitors with zero new
// transport.
const CHANNEL_ID = process.env.CHANNEL_ID || '';
const HLS_DIR = CHANNEL_ID ? `${MEDIA_ROOT}/live/${CHANNEL_ID}` : '';
// CasparCG SEEK / LENGTH are in frames, not seconds. Capture standard is 59.94;
// SD/film modes need their own values. Default 60000/1001 matches both
// '1080p5994' and '1080i5994'.
function fpsFor(videoFormat) {
const f = String(videoFormat || '').toLowerCase();
if (f.endsWith('5994')) return 60000 / 1001;
if (f.endsWith('p60') || f.endsWith('i60')) return 60;
if (f.endsWith('p50') || f.endsWith('i50')) return 50;
if (f.endsWith('2997')) return 30000 / 1001;
if (f.endsWith('p30')) return 30;
if (f.endsWith('p25')) return 25;
if (f.endsWith('p24') || f.endsWith('2398')) return 24000 / 1001;
return 60000 / 1001; // safe default for the house standard
}
// CasparCG transition syntax fragments keyed by our item.transition value.
function transitionArgs(transition, ms, fps) {
if (!transition || transition === 'cut' || !ms) return '';
const frames = Math.max(1, Math.round((ms / 1000) * fps));
if (transition === 'mix') return ` MIX ${frames}`;
if (transition === 'wipe') return ` WIPE ${frames}`;
return '';
}
// Turn an absolute /media path (or a relative one) into the token CasparCG
// expects: a path relative to MEDIA_ROOT, without extension, forward-slashed.
// CasparCG resolves "subdir/clip" against its media folder + probes extensions.
function toCasparToken(mediaPath) {
let p = String(mediaPath || '');
if (p.startsWith(MEDIA_ROOT)) p = p.slice(MEDIA_ROOT.length);
p = p.replace(/^\/+/, '');
p = p.replace(/\.[^/.]+$/, ''); // strip extension
return p;
}
export class PlayoutManager {
constructor() {
this.amcp = new AmcpClient({
host: process.env.CASPAR_HOST || '127.0.0.1',
port: parseInt(process.env.CASPAR_PORT || '5250', 10),
});
this.state = {
running: false,
outputType: null,
outputConfig: null,
videoFormat: null,
playlist: [], // resolved items in play order
currentIndex: -1,
loop: false,
currentClip: null,
startedAt: null,
lastError: null,
};
this._advanceTimer = null;
}
async _consumerCommand(outputType, cfg) {
// Returns the AMCP ADD argument string for the requested output target.
if (outputType === 'decklink') {
const dev = cfg.device_index || 1;
return `DECKLINK DEVICE ${dev} EMBEDDED_AUDIO`;
}
if (outputType === 'ndi') {
const name = cfg.ndi_name || 'DRAGONFLIGHT';
return `NDI NAME "${name}"`;
}
if (outputType === 'srt' || outputType === 'rtmp') {
// CasparCG 2.3 streams via the FFMPEG consumer, invoked with the STREAM
// keyword (FILE/STREAM are interchangeable aliases for it; the bare word
// "FFMPEG" is the PRODUCER and is NOT a valid consumer keyword). Args must
// use ffmpeg's -param:stream form (-codec:v, not -vcodec) or CasparCG
// rejects them. The channel feeds the consumer as RGBA, so a
// format=yuv420p filter is required before libx264.
const url = cfg.url || '';
if (outputType === 'srt') {
const latency = cfg.latency || 200;
const full = url.includes('latency=') ? url : `${url}${url.includes('?') ? '&' : '?'}latency=${latency}`;
return `STREAM "${full}" -format mpegts -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
}
const target = cfg.key ? `${url}/${cfg.key}` : url;
return `STREAM "${target}" -format flv -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
}
throw new Error(`Unknown output_type: ${outputType}`);
}
// Start the channel: bring up CasparCG's primary output consumer for the
// target, plus a second FFMPEG consumer writing HLS for the UI preview
// monitor (~4-6s lag, reuses capture's /live/<id> plumbing).
async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) {
await this.amcp.waitReady(30000);
// Set the channel video mode, then attach the output consumer.
try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); }
catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); }
const consumer = await this._consumerCommand(outputType, outputConfig);
await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
if (HLS_DIR) {
try {
await this._addHlsConsumer();
console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`);
} catch (err) {
// HLS preview is non-fatal — operators still get the on-air output.
console.warn(`[playout] HLS preview consumer failed: ${err.message}`);
}
}
this.state.running = true;
this.state.outputType = outputType;
this.state.outputConfig = outputConfig;
this.state.videoFormat = videoFormat;
this.state.fps = fpsFor(videoFormat);
this.state.startedAt = new Date().toISOString();
this.state.lastError = null;
console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}`);
return this.getStatus();
}
// Low-bitrate HLS for the web UI preview. Segments land in the shared media
// volume; the mam-api serves /live/<channel_id>/* from there.
async _addHlsConsumer() {
// mkdir is done by the entrypoint; CasparCG's ffmpeg consumer creates the
// playlist on first segment. 2s segments / 6-window list keeps lag low
// without thrashing disk.
// FILE keyword (alias of the FFMPEG consumer) writing a segmented HLS
// playlist. Same arg rules as the STREAM consumer: -param:stream form and a
// format=yuv420p filter ahead of libx264 (channel output is RGBA).
const out = `${HLS_DIR}/index.m3u8`;
const args = [
`FILE "${out}"`,
'-format hls',
'-hls_time 2',
'-hls_list_size 6',
'-hls_flags delete_segments+append_list',
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 800k -maxrate 1M -bufsize 2M',
'-g 60 -keyint_min 60 -sc_threshold 0',
'-codec:a aac -b:a 96k',
'-filter:v format=yuv420p',
].join(' ');
await this.amcp.send(`ADD ${CHANNEL} ${args}`);
}
async stopChannel() {
this._clearAdvance();
try { await this.amcp.send(`STOP ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
try { await this.amcp.send(`CLEAR ${CHANNEL}`); } catch (_) {}
this.state.running = false;
this.state.playlist = [];
this.state.currentIndex = -1;
this.state.currentClip = null;
console.log('[playout] channel stopped');
return { stopped: true };
}
// Load a playlist (array of { id, asset_id, media_path, in_point, out_point,
// transition, transition_ms, clip_name }) and start playing from index 0.
async loadPlaylist({ items = [], loop = false }) {
this.state.playlist = items;
this.state.loop = !!loop;
this.state.currentIndex = -1;
if (items.length === 0) return this.getStatus();
await this._playIndex(0);
return this.getStatus();
}
async _playIndex(index) {
const item = this.state.playlist[index];
if (!item) return;
const fps = this.state.fps || fpsFor(this.state.videoFormat);
const token = toCasparToken(item.media_path);
const seek = item.in_point ? ` SEEK ${Math.round(item.in_point * fps)}` : '';
const length = (item.out_point && item.out_point > (item.in_point || 0))
? ` LENGTH ${Math.round((item.out_point - (item.in_point || 0)) * fps)}`
: '';
const trans = transitionArgs(item.transition, item.transition_ms, fps);
// PLAY puts the clip on the foreground layer immediately (first clip), with
// the configured transition. Subsequent clips are cued via LOADBG ... AUTO
// for a gapless hand-off; see _scheduleAdvance.
await this.amcp.send(`PLAY ${CHANNEL}-${FG_LAYER} "${token}"${seek}${length}${trans}`);
this.state.currentIndex = index;
this.state.currentClip = item.clip_name || token;
console.log(`[playout] PLAY [${index}] ${token}`);
this._reportAsRunStart(item);
this._scheduleAdvance(item);
}
// Effective on-air duration of an item in milliseconds. Prefers an explicit
// in/out trim, else the asset's full duration. Returns null when unknown (no
// duration metadata + no out_point) so the caller can skip the timer.
_itemDurationMs(item) {
const inS = item.in_point || 0;
if (item.out_point && item.out_point > inS) return (item.out_point - inS) * 1000;
if (item.asset_duration_ms != null) return Math.max(0, item.asset_duration_ms - inS * 1000);
return null;
}
// CasparCG's LOADBG ... AUTO swaps the cued background clip to foreground when
// the current clip ends, giving a gapless visual take. But CasparCG won't cue
// clip N+2 on its own and won't move OUR pointer / as-run bookkeeping. So we
// also arm a duration-based timer: when the current clip is due to end we
// advance currentIndex and cue the following clip. This keeps an arbitrary-
// length playlist walking, not just the first two items.
_scheduleAdvance(item) {
this._clearAdvance();
const next = this._nextIndex();
if (next === null) return; // end of a non-looping playlist
const nextItem = this.state.playlist[next];
const nextToken = toCasparToken(nextItem.media_path);
const fps = this.state.fps || fpsFor(this.state.videoFormat);
const trans = transitionArgs(nextItem.transition, nextItem.transition_ms, fps);
// Cue next on background with AUTO so CasparCG performs the gapless take.
this.amcp.send(`LOADBG ${CHANNEL}-${FG_LAYER} "${nextToken}" AUTO${trans}`)
.catch((err) => console.warn(`[playout] LOADBG failed: ${err.message}`));
// Arm the pointer-advance timer. Without duration metadata we can't time the
// hand-off; leave AUTO to take clip N+1 visually but log a warning since the
// pointer (and thus clip N+2 cueing) will stall.
const durMs = this._itemDurationMs(item);
if (durMs == null) {
console.warn(`[playout] no duration for clip [${this.state.currentIndex}] — pointer advance stalled after this clip`);
return;
}
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null;
// The AUTO take already happened in CasparCG; just move our pointer and
// cue the clip after next. _playIndex would re-PLAY and double-take, so we
// advance state directly and re-arm.
this.state.currentIndex = next;
this.state.currentClip = nextItem.clip_name || nextToken;
console.log(`[playout] advance -> [${next}] ${nextToken}`);
this._reportAsRunStart(nextItem);
this._scheduleAdvance(nextItem);
}, Math.max(250, durMs));
}
_nextIndex() {
const n = this.state.currentIndex + 1;
if (n < this.state.playlist.length) return n;
if (this.state.loop && this.state.playlist.length > 0) return 0;
return null;
}
_clearAdvance() {
if (this._advanceTimer) { clearTimeout(this._advanceTimer); this._advanceTimer = null; }
}
async skip() {
const next = this._nextIndex();
if (next === null) { await this.stopChannel(); return this.getStatus(); }
await this._playIndex(next);
return this.getStatus();
}
async pause() {
try { await this.amcp.send(`PAUSE ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
return this.getStatus();
}
async resume() {
try { await this.amcp.send(`RESUME ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
return this.getStatus();
}
_reportAsRunStart(item) {
// The mam-api owns the as-run table; the sidecar just logs locally. The API
// polls /status and writes as-run rows on clip change. Keeping the DB write
// in the API avoids giving the sidecar a DB connection.
this.state.currentItemId = item.id || null;
this.state.currentItemStartedAt = new Date().toISOString();
}
getStatus() {
return {
running: this.state.running,
outputType: this.state.outputType,
videoFormat: this.state.videoFormat,
currentIndex: this.state.currentIndex,
currentClip: this.state.currentClip,
currentItemId: this.state.currentItemId || null,
currentItemStartedAt: this.state.currentItemStartedAt || null,
playlistLength: this.state.playlist.length,
loop: this.state.loop,
startedAt: this.state.startedAt,
lastError: this.state.lastError,
};
}
}
export default new PlayoutManager();

View file

@ -9,12 +9,10 @@
<div id="root">
<!-- ── Connect Pane ─────────────────────────────────────────────── -->
<section id="connect-pane" class="pane">
<section id="connect-pane" class="pane pane-connect">
<div class="brand">
<div class="brand-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>
</svg>
<svg width="28" height="28" viewBox="0 0 24 24"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
</div>
<div class="brand-title">Dragonflight</div>
<div class="brand-tag">Wild Dragon Broadcast</div>
@ -28,45 +26,60 @@
<div id="connect-status" class="status-msg muted"></div>
</section>
<!-- ── Library Pane ─────────────────────────────────────────────── -->
<!-- ── Library Pane (app-shell: statusbar · rail · main · dock) ──── -->
<section id="library-pane" class="pane hidden">
<div class="app">
<!-- Connection status strip (v2.1.8): dot + identity + ⋯ menu.
Disconnect lives inside the menu so it's not always visible. -->
<header class="status-strip">
<!-- Connection collapsed to a status line: dot + host + version + ⋯ -->
<header class="statusbar">
<span class="signal-dot"></span>
<span id="connected-host" class="connected-host"></span>
<span id="panel-version" class="panel-version" title="Plugin version"></span>
<button id="menu-btn" class="btn-ghost" title="More" aria-label="More"></button>
<div id="menu-btn" role="button" tabindex="0" class="iconbtn iconbtn--sm" data-tip="More" data-tip-pos="down-left" aria-label="More">
<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
</div>
<div id="status-menu" class="menu hidden" role="menu">
<button id="disconnect-btn" class="menu-item" role="menuitem">Disconnect</button>
</div>
</header>
<!-- Tabs -->
<div class="tab-nav">
<button id="tab-library" class="tab-btn active">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
Library
</button>
<button id="tab-growing" class="tab-btn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
Growing
<span id="growing-count" class="badge" style="display:none">0</span>
</button>
<div class="workspace">
<!-- Vertical icon rail: views on top, global actions below -->
<nav class="rail">
<div id="tab-library" role="button" tabindex="0" class="rail-btn active" data-tip="Library" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><rect x="3" y="3" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5" fill="currentColor"/></svg>
</div>
<div id="tab-growing" role="button" tabindex="0" class="rail-btn" data-tip="Growing" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<span id="growing-count" class="rail-count" style="display:none">0</span>
</div>
<!-- Search + Project filter -->
<div class="search-row">
<input id="search-input" type="search" placeholder="Search assets…" />
<select id="project-filter" title="Filter by project">
<span class="rail-spacer"></span>
<div id="export-timeline-btn" role="button" tabindex="0" class="rail-btn rail-btn--accent" data-tip="Export" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>
</div>
<div id="refresh-btn" role="button" tabindex="0" class="rail-btn" data-tip="Refresh" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</div>
</nav>
<!-- Main column -->
<div class="main">
<!-- Search + project filter -->
<div class="toolbar">
<label class="search">
<svg width="13" height="13" viewBox="0 0 24 24"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 0 0 1.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.5 6.5 0 0 0-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 0 0 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input id="search-input" type="search" placeholder="Search assets" />
</label>
<select id="project-filter" class="filter-select" title="Filter by project">
<option value="all">All Projects</option>
</select>
<button id="refresh-btn" class="btn btn-icon" title="Refresh"></button>
<div id="view-toggle-btn" role="button" tabindex="0" class="iconbtn iconbtn--sm" data-tip="Grid view" data-tip-pos="down-left" aria-label="Toggle layout">
<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 11h5V5H4v6zm0 7h5v-6H4v6zm6 0h5v-6h-5v6zm6 0h5v-6h-5v6zm-6-7h5V5h-5v6zm6-6v6h5V5h-5z"/></svg>
</div>
</div>
<!-- Active sequence info bar -->
@ -89,52 +102,75 @@
</div>
</div>
<!-- (v2.2.0) Asset details panel dropped — card meta already carries
name / codec / duration. If we need richer detail later, surface
it on the card hover state rather than reserving permanent space. -->
<!-- Details panel retired (v2.2.0) — card meta carries name/codec/duration. -->
<div id="details-panel" class="hidden"></div>
<!-- Action buttons -->
<footer class="actions">
<div class="action-row">
<button id="import-proxy-btn" class="btn btn-primary" disabled>Import Proxy</button>
<button id="import-hires-btn" class="btn btn-secondary" disabled>Hi-Res</button>
</div>
<div class="action-row">
<button id="mount-live-btn" class="btn btn-secondary" disabled title="Open live file from SMB share">Mount Live</button>
<button id="relink-btn" class="btn btn-secondary" disabled title="Relink proxy → hi-res original">Relink Hi-Res</button>
</div>
<div class="action-row">
<button id="import-all-btn" class="btn btn-secondary" disabled>Import All</button>
<button id="export-timeline-btn" class="btn btn-secondary" disabled>Export Timeline ↑</button>
</div>
<!-- Progress -->
<!-- Progress + toast sit just above the action dock -->
<div id="progress-row" class="progress-row hidden">
<div class="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-label" class="progress-label"></div>
</div>
<!-- Toast -->
<div id="toast" class="toast hidden"></div>
<!-- Contextual action dock: text buttons replaced by icon buttons
with hover labels. Per-asset actions left, batch actions right. -->
<footer class="dock">
<div id="import-proxy-btn" role="button" tabindex="0" class="iconbtn iconbtn--primary" data-tip="Import Proxy" data-tip-pos="up" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
</div>
<div id="import-hires-btn" role="button" tabindex="0" class="iconbtn" data-tip="Import Hi-Res" data-tip-pos="up" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27-7.38 5.74zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16z"/></svg>
</div>
<div id="mount-live-btn" role="button" tabindex="0" class="iconbtn" data-tip="Mount Live" data-tip-pos="up" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M14 12c0 1.11-.89 2-2 2s-2-.89-2-2 .89-2 2-2 2 .89 2 2zm-2-6c-3.31 0-6 2.69-6 6 0 2.22 1.21 4.15 3 5.19l1-1.74A3.98 3.98 0 0 1 8 12c0-2.21 1.79-4 4-4s4 1.79 4 4c0 1.48-.81 2.75-2 3.45l1 1.74c1.79-1.04 3-2.97 3-5.19 0-3.31-2.69-6-6-6zm0-4C7.58 2 4 5.58 4 10c0 2.96 1.61 5.53 4 6.92l1-1.73C7.21 14.07 6 12.18 6 10c0-3.31 2.69-6 6-6s6 2.69 6 6c0 2.18-1.21 4.07-3 5.19l1 1.73c2.39-1.39 4-3.96 4-6.92 0-4.42-3.58-8-8-8z"/></svg>
</div>
<div id="relink-btn" role="button" tabindex="0" class="iconbtn" data-tip="Relink Hi-Res" data-tip-pos="up" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
</div>
<span class="dock-sep"></span>
<div id="upload-mam-btn" role="button" tabindex="0" class="iconbtn" data-tip="Upload to MAM" data-tip-pos="up-left">
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
</div>
</footer>
<!-- Advanced section — collapsed by default; click the row to expand. -->
<div class="advanced-section">
<button id="advanced-toggle" class="advanced-toggle" type="button" aria-expanded="false">
<span class="advanced-caret"></span>
<span class="advanced-title">Advanced</span>
</button>
<div id="advanced-body" class="advanced-body hidden">
<div class="action-row">
<button id="export-conform-btn" class="btn btn-secondary" disabled>Export &amp; Conform</button>
<button id="fetch-relink-btn" class="btn btn-secondary" disabled>Fetch &amp; Relink All</button>
</div>
</div>
</div>
</div><!-- /main -->
</div><!-- /workspace -->
</div><!-- /app -->
</section>
<!-- ── Export Timeline Slide Panel ──────────────────────────────── -->
<!-- Full-panel Export screen -->
<div id="export-screen" class="screen hidden">
<div class="screen-header">
<span class="screen-title">Export</span>
<button id="export-screen-close" class="btn btn-icon"></button>
</div>
<div class="screen-body">
<div id="opt-conform" role="button" tabindex="0" class="export-option">
<div class="eo-icon">
<svg width="22" height="22" viewBox="0 0 24 24"><path fill="currentColor" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
</div>
<div class="eo-text">
<div class="eo-title">Conform Timeline → MAM</div>
<div class="eo-desc">Render the sequence to a chosen codec and push it to the MAM</div>
</div>
<span class="eo-arrow"></span>
</div>
<div id="opt-local-export" role="button" tabindex="0" class="export-option">
<div class="eo-icon">
<svg width="22" height="22" viewBox="0 0 24 24"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
</div>
<div class="eo-text">
<div class="eo-title">Local Export</div>
<div class="eo-desc">Trim the hi-res sources on the MAM, download and relink them in Premiere</div>
</div>
<span class="eo-arrow"></span>
</div>
</div>
</div>
<!-- ── (retired) Push Timeline Slide Panel — kept hidden/unused ──── -->
<div id="export-overlay" class="slide-overlay hidden"></div>
<div id="export-panel" class="slide-panel hidden">
<div class="slide-header">
@ -164,13 +200,15 @@
<div class="slide-body">
<label class="field-label">Target project</label>
<select id="conform-proj-select"><option value="">— Select project —</option></select>
<label class="field-label">Preset</label>
<div id="preset-cards" class="preset-grid">
<div class="preset-card selected" data-preset="broadcast"><div class="pc-title">Broadcast</div><div class="pc-desc">ProRes 422 HQ · 1080p · 48kHz</div></div>
<div class="preset-card" data-preset="web"><div class="pc-title">Web</div><div class="pc-desc">H.264 · 1080p · AAC 320k</div></div>
<div class="preset-card" data-preset="archive"><div class="pc-title">Archive</div><div class="pc-desc">ProRes 4444 · UHD · 48kHz</div></div>
<div class="preset-card" data-preset="custom"><div class="pc-title">Custom</div><div class="pc-desc">Manual settings</div></div>
<label class="field-label">Format</label>
<div id="preset-cards" class="preset-list">
<div class="preset-card selected" data-preset="broadcast"><span class="pc-title">Broadcast</span><span class="pc-desc">ProRes 422 HQ · 1080p</span></div>
<div class="preset-card" data-preset="web"><span class="pc-title">Web</span><span class="pc-desc">H.264 · 1080p · AAC</span></div>
<div class="preset-card" data-preset="archive"><span class="pc-title">Archive</span><span class="pc-desc">ProRes 4444 · UHD</span></div>
<div class="preset-card" data-preset="custom"><span class="pc-title">Custom</span><span class="pc-desc">Manual settings</span></div>
</div>
<div id="conform-custom" class="custom-fields hidden">
<label class="field-label">Codec</label>
<select id="conform-codec">
<option value="prores_hq">ProRes 422 HQ</option>
@ -198,6 +236,7 @@
<option value="web">Web (AAC 320k)</option>
<option value="archive">Archive (96kHz PCM)</option>
</select>
</div>
<div id="conform-clip-info" class="clip-info"></div>
</div>
<div class="slide-footer">
@ -231,6 +270,7 @@
<script src="src/library.js"></script>
<script src="src/import-flow.js"></script>
<script src="src/timeline.js"></script>
<script src="src/tooltip.js"></script>
<script src="src/main.js"></script>
</body>
</html>

View file

@ -167,5 +167,57 @@
});
};
// Local Export trim job polling + segment retrieval.
API.getTrimStatus = function (jobId) {
return API.json('/api/v1/assets/trim-status/' + jobId);
};
API.getTempSegmentUrl = function (clipInstanceId) {
return API.json('/api/v1/assets/temp-segment-url/' + clipInstanceId);
};
// ── Upload (ingest editor media into the MAM) ────────────────────
// Single-shot multipart form upload (server caps simple at <50 MB).
API.uploadSimple = async function (blob, meta) {
const fd = new FormData();
fd.append('file', blob, meta.filename);
fd.append('filename', meta.filename);
fd.append('projectId', meta.projectId);
if (meta.binId) fd.append('binId', meta.binId);
if (meta.contentType) fd.append('contentType', meta.contentType);
const r = await API.request('/api/v1/upload/simple', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload HTTP ' + r.status + ' — ' + (await r.text().catch(() => '')).slice(0, 160));
return r.json();
};
// Chunked multipart for large originals.
API.uploadInit = function (meta) {
return API.json('/api/v1/upload/init', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
});
};
API.uploadPart = async function (blob, meta) {
const fd = new FormData();
fd.append('file', blob, 'part-' + meta.partNumber);
fd.append('uploadId', meta.uploadId);
fd.append('key', meta.key);
fd.append('partNumber', String(meta.partNumber));
const r = await API.request('/api/v1/upload/part', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload part HTTP ' + r.status);
return r.json();
};
API.uploadComplete = function (meta) {
return API.json('/api/v1/upload/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
});
};
API.uploadAbort = function (meta) {
return API.json('/api/v1/upload/abort', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
}).catch(() => {});
};
window.API = API;
})();

View file

@ -154,5 +154,111 @@
return destPath;
};
// ── Upload (ingest editor media into the MAM) ────────────────────
const SIMPLE_MAX = 45 * 1024 * 1024; // server caps /simple at <50 MB
const PART_SIZE = 16 * 1024 * 1024; // chunk size for multipart
function _contentType(name) {
const ext = String(name).split('.').pop().toLowerCase();
const map = {
mp4:'video/mp4', m4v:'video/mp4', mov:'video/quicktime', mxf:'application/mxf',
mkv:'video/x-matroska', avi:'video/x-msvideo', mpg:'video/mpeg', mpeg:'video/mpeg',
mts:'video/mp2t', m2ts:'video/mp2t', wav:'audio/wav', aif:'audio/aiff', aiff:'audio/aiff',
mp3:'audio/mpeg', png:'image/png', jpg:'image/jpeg', jpeg:'image/jpeg',
};
return map[ext] || 'application/octet-stream';
}
// Read a local file and push it to the MAM. Returns the created asset row.
// NOTE: reads the whole file into memory once (fine for typical clips;
// very large multi-GB originals may strain memory — revisit with a
// positional-read stream if that becomes a problem).
Import.uploadFile = async function (nativePath, meta) {
meta = meta || {};
if (!meta.projectId) throw new Error('No target project for upload');
const filename = meta.filename || path.basename(nativePath);
const contentType = _contentType(filename);
const buf = await fs.readFile(nativePath);
const size = buf.byteLength != null ? buf.byteLength : buf.length;
if (size <= SIMPLE_MAX) {
const blob = new Blob([buf], { type: contentType });
return API.uploadSimple(blob, { filename, projectId: meta.projectId, binId: meta.binId, contentType });
}
// Chunked multipart for large files.
const init = await API.uploadInit({ filename, fileSize: size, contentType, projectId: meta.projectId, binId: meta.binId });
const parts = [];
try {
let partNumber = 1;
for (let off = 0; off < size; off += PART_SIZE, partNumber++) {
const chunk = buf.slice(off, Math.min(off + PART_SIZE, size));
const blob = new Blob([chunk], { type: contentType });
if (meta.onProgress) meta.onProgress(off, size);
const res = await API.uploadPart(blob, { uploadId: init.uploadId, key: init.key, partNumber });
parts.push({ PartNumber: partNumber, ETag: res.etag });
}
return API.uploadComplete({ uploadId: init.uploadId, key: init.key, assetId: init.assetId, parts });
} catch (e) {
await API.uploadAbort({ uploadId: init.uploadId, key: init.key, assetId: init.assetId });
throw e;
}
};
// ── Bin selection (best-effort) + file-picker fallback ───────────
// Tries to read the highlighted project-panel item(s). The UXP premierepro
// selection surface varies by version, so every access is guarded; on any
// miss this returns [] and callers fall back to a native file picker.
Import.getSelectedBinPaths = async function () {
const paths = [];
try {
const P = _ppro();
const project = await P.Project.getActiveProject();
if (!project) return paths;
let sel = null;
try { if (project.getSelection) sel = await project.getSelection(); } catch (_) {}
let items = [];
if (sel) {
if (typeof sel.getItems === 'function') items = await sel.getItems();
else if (Array.isArray(sel)) items = sel;
else if (Array.isArray(sel.items)) items = sel.items;
}
for (const it of (items || [])) {
try {
const ci = await P.ClipProjectItem.cast(it);
const mp = await ci.getMediaFilePath();
if (mp) paths.push(mp);
} catch (_) {}
}
} catch (_) {}
return paths;
};
// Native file picker — returns array of native paths (may be empty).
Import.pickFiles = async function () {
if (!uxpFs || !uxpFs.getFileForOpening) throw new Error('File picker unavailable in this host');
const sel = await uxpFs.getFileForOpening({ allowMultiple: true });
if (!sel) return [];
const arr = Array.isArray(sel) ? sel : [sel];
return arr.map(f => f && f.nativePath).filter(Boolean);
};
// Upload any timeline clips not yet in the MAM, recording the path→asset
// mapping so resolveClipsToAssets picks them up on the next pass.
Import.ensureClipsInMam = async function (clips, projectId, onProgress) {
const missing = clips.filter(c => !c.asset_id && c.filePath);
for (let i = 0; i < missing.length; i++) {
const c = missing[i];
if (onProgress) onProgress(c.fileName || path.basename(c.filePath), i + 1, missing.length);
const asset = await Import.uploadFile(c.filePath, { projectId, filename: path.basename(c.filePath) });
if (asset && asset.id) {
Library.recordImport(c.filePath, { assetId: asset.id });
if (c.fileName) Library.recordImport('name:' + c.fileName, { assetId: asset.id });
}
}
return { uploaded: missing.length };
};
window.Import = Import;
})();

View file

@ -220,10 +220,7 @@
_btn('import-hires-btn').disabled = !sel || live || !sel.original_s3_key;
_btn('mount-live-btn').disabled = !sel || !live;
_btn('relink-btn').disabled = !(ready && hasLiveImport);
_btn('import-all-btn').disabled = Library.state.currentTab !== 'library';
_btn('export-timeline-btn').disabled = false; // available once connected
_btn('export-conform-btn').disabled = false;
_btn('fetch-relink-btn').disabled = false;
// export-timeline-btn (Export menu) and upload-mam-btn are always available.
};
function _btn(id) { return document.getElementById(id) || { disabled: false }; }

View file

@ -6,6 +6,50 @@
const $ = id => document.getElementById(id);
// UXP renders native <button> chrome that ignores CSS `background` and does
// not draw <svg>-only button content, so the rail/dock icon controls are
// <div role="button"> (divs render custom backgrounds + SVG children fine).
// Divs have no native `disabled`, so reflect the `.disabled` property the
// rest of the code sets onto a [disabled] attribute the stylesheet keys off.
const ICON_CONTROLS = [
'menu-btn', 'tab-library', 'tab-growing', 'export-timeline-btn', 'refresh-btn',
'import-proxy-btn', 'import-hires-btn', 'mount-live-btn', 'relink-btn', 'upload-mam-btn'
];
function enableDivDisabled() {
ICON_CONTROLS.forEach(id => {
const el = document.getElementById(id);
if (!el || Object.getOwnPropertyDescriptor(el, 'disabled')) return;
Object.defineProperty(el, 'disabled', {
configurable: true,
get() { return this.hasAttribute('disabled'); },
set(v) { if (v) this.setAttribute('disabled', ''); else this.removeAttribute('disabled'); }
});
});
}
// Asset layout toggle: compact list (default) vs thumbnail grid. Persisted
// in localStorage when available (UXP host permitting), else session-only.
const GRID_ICON = '<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 11h5V5H4v6zm0 7h5v-6H4v6zm6 0h5v-6h-5v6zm6 0h5v-6h-5v6zm-6-7h5V5h-5v6zm6-6v6h5V5h-5z"/></svg>';
const LIST_ICON = '<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>';
let _viewMode = null;
function getViewMode() {
if (_viewMode) return _viewMode;
try { _viewMode = localStorage.getItem('df_view_mode'); } catch (e) {}
return _viewMode || 'list';
}
function applyViewMode(mode) {
_viewMode = mode === 'grid' ? 'grid' : 'list';
try { localStorage.setItem('df_view_mode', _viewMode); } catch (e) {}
const isList = _viewMode === 'list';
document.querySelectorAll('.asset-grid').forEach(g => g.classList.toggle('list-view', isList));
const btn = $('view-toggle-btn');
if (btn) {
// Show the icon for the layout a click switches TO.
btn.innerHTML = isList ? GRID_ICON : LIST_ICON;
btn.setAttribute('data-tip', isList ? 'Grid view' : 'List view');
}
}
function syncConnectBtn() {
$('connect-btn').disabled = !$('server-url').value.trim() || !$('api-token').value.trim();
}
@ -74,6 +118,11 @@
$('tab-library').addEventListener('click', () => Library.switchTab('library'));
$('tab-growing').addEventListener('click', () => Library.switchTab('growing'));
const vt = $('view-toggle-btn');
if (vt) vt.addEventListener('click', () => {
applyViewMode(getViewMode() === 'list' ? 'grid' : 'list');
});
let searchTimer;
$('search-input').addEventListener('input', e => {
clearTimeout(searchTimer);
@ -114,24 +163,8 @@
finally { _disableImportBtns(false); Library._syncActions(); }
});
$('import-all-btn').addEventListener('click', async () => {
const assets = Library.state.assets;
if (!assets.length) { UI.toast('No assets', 'error'); return; }
_disableImportBtns(true);
let ok = 0, fail = 0;
for (const a of assets) {
try {
const { localPath, safeName } = await Import.proxy(a);
Library.recordImport(localPath, { assetId: a.id, displayName: a.display_name || a.filename });
Library.recordImport('name:' + safeName, { assetId: a.id, displayName: a.display_name || a.filename });
ok++;
} catch (_) { fail++; }
}
_disableImportBtns(false);
UI.hideProgress();
UI.toast('Import all: ' + ok + ' ok' + (fail ? ', ' + fail + ' failed' : ''), fail ? 'error' : 'ok');
Library._syncActions();
});
// ── Upload highlighted bin file(s) to the MAM ──
$('upload-mam-btn').addEventListener('click', uploadToMam);
$('mount-live-btn').addEventListener('click', async () => {
const a = Library.selectedAsset(); if (!a) return;
@ -181,12 +214,8 @@
finally { Library._syncActions(); }
});
// v2.2.1: Export Timeline is now a single-click pipeline —
// push to MAM → start conform → poll → new asset lands in Library.
// The Conform slide panel is still wired for Advanced → Export & Conform.
$('export-timeline-btn').addEventListener('click', oneClickExport);
$('export-conform-btn').addEventListener('click', openConformPanel);
$('fetch-relink-btn').addEventListener('click', openRelinkPanel);
// Single Export entry → popup menu (Conform Timeline / Local Export).
wireExportMenu();
// Advanced collapsible toggle (v2.2.0).
const advToggle = $('advanced-toggle');
@ -203,6 +232,83 @@
['import-proxy-btn','import-hires-btn'].forEach(id => { $(id).disabled = dis; });
}
function _basename(p) { return String(p).split(/[\\/]/).pop(); }
// Target project for uploads/auto-upload: the project filter when specific,
// else the only project if there's exactly one, else null (caller prompts).
function getTargetProjectId() {
const sel = Library.state.selectedProject;
if (sel && sel !== 'all') return sel;
const projs = Library.state.projects || [];
return projs.length === 1 ? projs[0].id : null;
}
// ── Export screen (full-panel chooser → Conform / Local Export) ──
function wireExportMenu() {
const btn = $('export-timeline-btn');
if (!btn) return;
const close = () => UI.setHidden('#export-screen', true);
btn.addEventListener('click', () => UI.setHidden('#export-screen', false));
const closeBtn = $('export-screen-close');
if (closeBtn) closeBtn.addEventListener('click', close);
const optConform = $('opt-conform');
if (optConform) optConform.addEventListener('click', () => { close(); openConformPanel(); });
const optLocal = $('opt-local-export');
if (optLocal) optLocal.addEventListener('click', () => { close(); runLocalExport(); });
}
// ── Upload highlighted bin file(s) (or file-picker fallback) ─────
async function uploadToMam() {
const projectId = getTargetProjectId();
if (!projectId) { UI.toast('Pick a target project (project filter) before uploading', 'error'); return; }
let paths = [];
try { paths = await Import.getSelectedBinPaths(); } catch (_) {}
if (!paths.length) {
UI.toast('No bin selection — choose file(s) to upload', 'muted');
try { paths = await Import.pickFiles(); }
catch (e) { UI.toast('File picker unavailable: ' + e.message, 'error'); return; }
}
if (!paths.length) return;
let ok = 0, fail = 0;
for (let i = 0; i < paths.length; i++) {
const name = _basename(paths[i]);
UI.showProgress('Uploading ' + name + ' (' + (i + 1) + '/' + paths.length + ')…', 10 + (i / paths.length) * 80);
try { await Import.uploadFile(paths[i], { projectId }); ok++; }
catch (e) { fail++; console.warn('[df] upload failed', paths[i], e.message); }
}
UI.hideProgress();
UI.toast('Uploaded ' + ok + (fail ? ', ' + fail + ' failed' : '') + ' to MAM', fail ? 'error' : 'ok');
if (ok) Library.refresh(Library.state.searchQuery);
}
// ── Local Export (server FFMPEG-trims hi-res → download → relink) ─
async function runLocalExport() {
const projectId = getTargetProjectId();
UI.showProgress('Reading Premiere sequence…', 8);
let td;
try { td = await Timeline.readActiveSequence(); }
catch (e) { UI.hideProgress(); UI.toast('Timeline read failed: ' + e.message, 'error'); return; }
if (!td.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
let resolved = Library.resolveClipsToAssets(td.clips);
const missing = resolved.filter(c => !c.asset_id && c.filePath);
if (missing.length) {
if (!projectId) { UI.hideProgress(); UI.toast(missing.length + ' clip(s) not in MAM — pick a target project so they can be uploaded', 'error'); return; }
try {
await Import.ensureClipsInMam(resolved, projectId, (name, n, total) =>
UI.showProgress('Uploading missing source ' + name + ' (' + n + '/' + total + ')…', 8 + (n / total) * 20));
resolved = Library.resolveClipsToAssets(td.clips);
} catch (e) { UI.hideProgress(); UI.toast('Auto-upload failed: ' + e.message, 'error'); return; }
}
try {
const res = await Timeline.localExport(resolved, (label, pct) => UI.showProgress(label, pct));
UI.hideProgress();
if (res.failed) UI.toast('Local Export: ' + res.succeeded + ' ok, ' + res.failed + ' failed', 'error');
else UI.toast('Local Export complete — ' + res.succeeded + ' clip(s) relinked', 'ok');
} catch (e) { UI.hideProgress(); UI.toast('Local Export failed: ' + e.message, 'error'); }
}
let _seqCache = null;
// ── One-click Export Timeline ────────────────────────────────────
@ -331,9 +437,13 @@
if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
UI.hideProgress();
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
const total = _seqCache.clips.length;
const matched = resolved.filter(c => c.asset_id).length;
$('conform-clip-info').textContent = matched + ' of ' + _seqCache.clips.length + ' clip(s) matched';
$('conform-start-btn').disabled = matched === 0;
const missing = total - matched;
$('conform-clip-info').textContent = missing
? matched + ' of ' + total + ' clip(s) in MAM — ' + missing + ' will be uploaded first'
: matched + ' of ' + total + ' clip(s) in MAM';
$('conform-start-btn').disabled = total === 0;
const conformProj = $('conform-proj-select');
if (conformProj) {
conformProj.innerHTML = '<option value="">— Select project —</option>';
@ -360,6 +470,8 @@
};
const p = presets[card.dataset.preset];
if (p) { $('conform-codec').value=p.codec; $('conform-quality').value=p.quality; $('conform-resolution').value=p.resolution; $('conform-audio').value=p.audio; }
// Manual codec/quality/res/audio fields appear only for Custom.
UI.setHidden('#conform-custom', card.dataset.preset !== 'custom');
});
$('conform-start-btn').addEventListener('click', async () => {
if (!_seqCache) return;
@ -367,6 +479,15 @@
const projectId = conformProj ? conformProj.value : '';
if (!projectId) { UI.toast('Select a target project', 'error'); return; }
UI.closeSlide('conform-overlay', 'conform-panel');
// Auto-upload any timeline sources not yet in the MAM, then conform.
try {
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
const missing = resolved.filter(c => !c.asset_id && c.filePath);
if (missing.length) {
await Import.ensureClipsInMam(resolved, projectId, (name, n, tot) =>
UI.showProgress('Uploading missing source ' + name + ' (' + n + '/' + tot + ')…', 5 + (n / tot) * 10));
}
} catch (e) { UI.hideProgress(); UI.toast('Auto-upload failed: ' + e.message, 'error'); return; }
UI.showProgress('Starting conform job…', 15);
try {
const jobId = await Timeline.startConform(projectId, _seqCache.sequenceName, _seqCache, {
@ -465,6 +586,8 @@
}
function init() {
enableDivDisabled();
applyViewMode(getViewMode());
wireConnectPane(); wireLibraryPane();
wireExportPanel(); wireConformPanel(); wireRelinkPanel();
showVersion();

View file

@ -304,5 +304,76 @@
return count;
};
// ── Local Export ─────────────────────────────────────────────────
// Server trims each timeline clip's hi-res via FFMPEG, then we download
// the trimmed segments and relink the project items to them.
// CAVEAT: relink keys on the source media path, so a source used by
// multiple timeline clips with different in/out points will relink to a
// single segment (last one wins). Common single-use case is exact.
Timeline.localExport = async function (resolvedClips, onProgress) {
const P = ppro();
const project = await P.Project.getActiveProject();
if (!project) throw new Error('No active Premiere project');
const matched = (resolvedClips || []).filter(c => c.asset_id);
if (!matched.length) throw new Error('No clips matched MAM assets to export');
const payload = matched.map(c => ({
assetId: c.asset_id,
filename: c.fileName || (c.filePath ? path.basename(c.filePath) : 'clip'),
sourceInFrames: c.sourceInFrames, sourceOutFrames: c.sourceOutFrames,
timelineInFrames: c.timelineInFrames, timelineOutFrames: c.timelineOutFrames,
trackIndex: c.trackIndex,
}));
onProgress && onProgress('Requesting trim of ' + matched.length + ' clip(s)…', 10);
const job = await API.batchTrim(payload);
const jobId = job.jobId;
const clipByInstance = {};
(job.clips || []).forEach((cr, i) => { if (cr.clipInstanceId) clipByInstance[cr.clipInstanceId] = matched[i]; });
// Poll until every segment is ready (s3Key set) or the job fails.
const ready = {};
await new Promise((resolve, reject) => {
const t = setInterval(async () => {
try {
const st = await API.getTrimStatus(jobId);
const clips = st.clips || [];
const completed = clips.filter(c => c.status === 'completed' && c.s3Key);
onProgress && onProgress('Trimming on server… (' + completed.length + '/' + clips.length + ')',
15 + (completed.length / Math.max(1, clips.length)) * 45);
if (st.status === 'failed') { clearInterval(t); reject(new Error('Server trim job failed')); return; }
if (clips.length && completed.length === clips.length) {
clearInterval(t); completed.forEach(c => { ready[c.clipInstanceId] = c; }); resolve();
}
} catch (_) { /* transient — keep polling */ }
}, 2000);
});
// Download each segment and relink the source media path to it.
const results = { succeeded: 0, failed: 0, errors: [] };
const ids = Object.keys(ready);
for (let i = 0; i < ids.length; i++) {
const cid = ids[i];
const clip = clipByInstance[cid];
if (!clip) continue;
try {
onProgress && onProgress('Downloading segment ' + (i + 1) + '/' + ids.length + '…', 60 + (i / ids.length) * 35);
const seg = await API.getTempSegmentUrl(cid);
const ext = (seg.s3Key && seg.s3Key.split('.').pop()) || 'mov';
const base = UI.sanitizeFilename((clip.fileName || 'clip') + '-trim-' + cid.slice(0, 8) + '.' + ext);
const dest = await Import._tempPath(base);
const r = await API.requestExternal(seg.url);
if (!r.ok) throw new Error('Segment download HTTP ' + r.status);
await Import._writeBuffer(dest, await r.arrayBuffer());
if (clip.filePath) await Timeline._relinkInProject(project, clip.filePath, dest);
results.succeeded++;
} catch (e) {
results.failed++;
results.errors.push((clip && clip.fileName || 'clip') + ': ' + e.message);
}
}
return results;
};
window.Timeline = Timeline;
})();

View file

@ -0,0 +1,78 @@
// Hover tooltips — v1
// Icon-first UI: every actionable control carries a [data-tip] label that
// surfaces on hover. UXP's CSS engine can't be trusted with
// `content: attr(data-tip)` on ::after, so we position a single floating
// bubble with plain DOM + getBoundingClientRect (both well supported).
(function () {
let bubble = null;
let timer = null;
function ensure() {
if (bubble) return bubble;
bubble = document.createElement('div');
bubble.className = 'tip-bubble';
document.body.appendChild(bubble);
return bubble;
}
function show(el) {
const text = el.getAttribute('data-tip');
if (!text) return;
const tip = ensure();
tip.textContent = text;
tip.style.display = 'block';
tip.style.opacity = '0';
const r = el.getBoundingClientRect();
const t = tip.getBoundingClientRect();
// position:absolute on a body-level node is offset from the document
// origin; add scroll offset (0 in practice, body doesn't scroll) for safety.
const sx = window.pageXOffset || document.documentElement.scrollLeft || 0;
const sy = window.pageYOffset || document.documentElement.scrollTop || 0;
const gap = 7;
const pos = el.getAttribute('data-tip-pos') || 'down';
let x, y;
if (pos === 'right') {
x = r.right + gap; y = r.top + (r.height - t.height) / 2;
} else if (pos === 'up') {
x = r.left + (r.width - t.width) / 2; y = r.top - t.height - gap;
} else if (pos === 'up-left') {
x = r.right - t.width; y = r.top - t.height - gap;
} else if (pos === 'down-left') {
x = r.right - t.width; y = r.bottom + gap;
} else {
x = r.left + (r.width - t.width) / 2; y = r.bottom + gap;
}
const vw = window.innerWidth || document.documentElement.clientWidth || 99999;
const vh = window.innerHeight || document.documentElement.clientHeight || 99999;
x = Math.max(4, Math.min(x, vw - t.width - 4));
y = Math.max(4, Math.min(y, vh - t.height - 4));
tip.style.left = (x + sx) + 'px';
tip.style.top = (y + sy) + 'px';
tip.style.opacity = '1';
}
function hide() {
clearTimeout(timer);
if (bubble) { bubble.style.opacity = '0'; bubble.style.display = 'none'; }
}
function bind(el) {
el.addEventListener('mouseenter', () => {
clearTimeout(timer);
timer = setTimeout(() => show(el), 240);
});
el.addEventListener('mouseleave', hide);
el.addEventListener('click', hide);
}
function init() {
document.querySelectorAll('[data-tip]').forEach(bind);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
// app.jsx main shell
// app.jsx - main shell
const ACCENT = '#5B7CFA';
@ -40,6 +40,14 @@ function App() {
}, []);
const navigate = (id) => { setOpenAsset(null); setRoute(id); };
// Window-level nav event so deeply nested components (like the Tokens
// "see the parody" link) can route without prop drilling.
React.useEffect(() => {
const handler = (e) => { if (e && e.detail) navigate(e.detail); };
window.addEventListener('df:nav', handler);
return () => window.removeEventListener('df:nav', handler);
}, []);
const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };
const crumbs = React.useMemo(() => {
@ -59,7 +67,7 @@ function App() {
schedule: ['Ingest', 'Schedule'],
youtube: ['Ingest', 'YouTube'],
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
jobs: ['Jobs'], editor: ['Editor'],
jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
settings: ['Admin', 'Settings'],
@ -89,11 +97,18 @@ function App() {
);
}
// Admin-only destinations. Non-admins who reach one (deep link, keyboard
// router, stale tab) get bounced home instead of a broken/forbidden page.
// The API enforces the same rules this is just UX.
const ADMIN_ROUTES = new Set(['users', 'containers', 'cluster', 'settings']);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const effectiveRoute = (ADMIN_ROUTES.has(route) && !isAdmin) ? 'home' : route;
let content;
if (openAsset) {
content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
} else {
switch (route) {
switch (effectiveRoute) {
case 'home': content = <Home navigate={navigate} />; break;
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
@ -105,9 +120,10 @@ function App() {
case 'capture': content = <Capture navigate={navigate} />; break;
case 'monitors': content = <Monitors navigate={navigate} />; break;
case 'jobs': content = <Jobs navigate={navigate} />; break;
case 'editor': content = <Editor />; break;
case 'playout': content = <Playout navigate={navigate} />; break;
case 'users': content = <Users />; break;
case 'tokens': content = <Tokens />; break;
case 'billing': content = <TokensParody />; break;
case 'containers':content = <Containers />; break;
case 'cluster': content = <Cluster />; break;
case 'settings': content = <Settings />; break;
@ -115,7 +131,7 @@ function App() {
}
}
// Home (launcher) suppresses the topbar it's a full-bleed landing page.
// Home (launcher) suppresses the topbar - it's a full-bleed landing page.
const hideTopbar = !openAsset && route === 'home';
return (

View file

@ -1,10 +1,10 @@
// auth-gate.jsx owns the "logged in or not" state.
// auth-gate.jsx - owns the "logged in or not" state.
//
// The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
// (defined in screens-auth.jsx, Task 16). On 200 it renders the real <App>.
//
// This component is the SINGLE source of truth for the auth check no other
// This component is the SINGLE source of truth for the auth check - no other
// component should redirect to a login page or wipe data on 401. Other code
// surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts
// the gate so the next /auth/me request decides what to do.

View file

@ -1,4 +1,4 @@
// data.jsx API client; populates window.ZAMPP_DATA from real endpoints
// data.jsx - API client; populates window.ZAMPP_DATA from real endpoints
const API = '/api/v1';
window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
@ -22,33 +22,28 @@ window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
})();
// Premiere panel releases embedded in this deployment. Bumping the version
// here is the single source of truth both the Editor download buttons and
// here is the single source of truth - both the Editor download buttons and
// the Settings Capture SDKs page read from this list (#125).
//
// The panel is now a UXP plugin (.ccx), replacing the legacy CEP/ZXP panel.
// Each entry carries a `ccx` URL; the older `zxp`/`installer` fields are gone.
window.PREMIERE_RELEASES = [
{
version: '1.2.0',
zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp',
version: '2.2.2',
ccx: '/downloads/dragonflight-mam-2.2.2.ccx',
installer: null,
notes: 'Latest — design system refresh, aligned panel UI with web-ui tokens',
notes: 'UXP plugin: one-click Export Timeline, library + conform + auto-relink, growing-file mount, runtime version chip. Replaces the legacy CEP/ZXP panel.',
latest: true,
},
{
version: '1.0.1',
zxp: '/downloads/dragonflight-premiere-panel-1.0.1.zxp',
installer: '/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe',
notes: 'Auto-relinking, growing-file support, batch trim',
latest: false,
},
{
version: '1.0.0',
zxp: '/downloads/dragonflight-premiere-panel-1.0.0.zxp',
installer: '/downloads/dragonflight-premiere-panel-1.0.0-windows-setup.exe',
notes: 'Initial release',
latest: false,
},
];
window.PREMIERE_LATEST = window.PREMIERE_RELEASES.find(r => r.latest) || window.PREMIERE_RELEASES[0];
// Teams ISO workstation installer. Placeholder slot: the .exe is not in the
// repo yet, so `available` is false and the Downloads modal renders the row
// disabled with a "coming soon" note. Drop the file into public/downloads/
// and flip `available: true` (set `version`) to finish it.
window.TEAMS_ISO = { version: null, url: '/downloads/TeamsISO.exe', available: false };
window.ZAMPP_DATA = {
PROJECTS: [],
ASSETS: [],
@ -86,7 +81,7 @@ async function apiFetch(path, opts = {}) {
}
function fmtDuration(ms) {
if (!ms) return '';
if (!ms) return '·';
const s = Math.round(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
@ -96,7 +91,7 @@ function fmtDuration(ms) {
}
function fmtSize(bytes) {
if (!bytes) return '';
if (!bytes) return '·';
if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB';
if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB';
@ -104,7 +99,7 @@ function fmtSize(bytes) {
}
function fmtRelative(iso) {
if (!iso) return '';
if (!iso) return '·';
const diff = (Date.now() - new Date(iso)) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
@ -122,7 +117,7 @@ function normalizeAsset(a, projectMap) {
type: a.media_type || 'video',
duration: fmtDuration(a.duration_ms),
size: fmtSize(a.file_size),
res: a.resolution || '',
res: a.resolution || '·',
updated: fmtRelative(a.updated_at),
project: (projectMap && projectMap[a.project_id]) || '',
comments: 0,
@ -133,7 +128,7 @@ function normalizeAsset(a, projectMap) {
}
function normalizeRecorder(r) {
let elapsed = '';
let elapsed = '·';
if (r.status === 'recording' && r.started_at) {
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
@ -143,13 +138,13 @@ function normalizeRecorder(r) {
const cfg = r.source_config || {};
return {
...r,
source: r.source_type || '',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '',
codec: r.recording_codec || '',
res: r.recording_resolution || '',
source: r.source_type || '·',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
codec: r.recording_codec || '·',
res: r.recording_resolution || '·',
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
elapsed,
bitrate: '',
bitrate: '·',
health: 100,
audio: false,
};
@ -163,9 +158,9 @@ function normalizeJob(j) {
...j,
status: statusMap[j.status] || j.status,
kind: kindMap[j.type] || j.type || 'Job',
asset: j.asset_name || meta.filename || '',
eta: '',
node: meta.node || '',
asset: j.asset_name || meta.filename || '·',
eta: '·',
node: meta.node || '·',
priority: meta.priority || 'normal',
error: j.error || null,
progress: j.progress || 0,

View file

@ -8,10 +8,11 @@ const ICONS = {
upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>,
record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>,
jobs: <><path d="M3 6h18" /><path d="M3 12h18" /><path d="M3 18h12" /></>,
jobs: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>,
users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>,
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
dollar: <><line x1="12" y1="2" x2="12" y2="22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
container: <><rect x="3" y="6" width="18" height="12" rx="1" /><path d="M3 10h18" /><circle cx="7" cy="14" r="1" fill="currentColor" /><circle cx="11" cy="14" r="1" fill="currentColor" /></>,
cluster: <><circle cx="12" cy="5" r="2.5" /><circle cx="5" cy="19" r="2.5" /><circle cx="19" cy="19" r="2.5" /><path d="M12 7.5l-6.5 9M12 7.5l6.5 9M7.5 19h9" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></>,
@ -37,14 +38,14 @@ const ICONS = {
x: <path d="M6 6l12 12M6 18L18 6" />,
filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />,
sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>,
grid: <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>,
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />,
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>,
gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>,
cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>,
hdd: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="1" fill="currentColor" /></>,
hdd: <><ellipse cx="12" cy="6" rx="9" ry="3" /><path d="M3 6v12c0 1.66 4.03 3 9 3s9-1.34 9-3V6" /></>,
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />,
signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>,
@ -65,7 +66,7 @@ const ICONS = {
power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>,
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>,
package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>,
proxy: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l3-3 3 3M12 9v8" /></>,
proxy: <><path d="M4 6h11M19 6h1M4 12h2M10 12h10M4 18h7M15 18h5" /><circle cx="17" cy="6" r="2" /><circle cx="8" cy="12" r="2" /><circle cx="13" cy="18" r="2" /></>,
};
function Icon({ name, size = 16, className, style }) {

View file

@ -21,6 +21,7 @@
<link rel="stylesheet" href="styles-rest.css" />
<link rel="stylesheet" href="styles-modal.css" />
<link rel="stylesheet" href="styles-fixes.css" />
<link rel="stylesheet" href="styles-playout.css" />
</head>
<body>
<div id="root"></div>
@ -35,6 +36,7 @@
<script src="dist/shell.js"></script>
<script src="dist/auth-gate.js"></script>
<script src="dist/screens-auth.js"></script>
<script src="dist/screens-resources.js"></script>
<script src="dist/screens-home.js"></script>
<script src="dist/screens-library.js"></script>
<script src="dist/screens-asset.js"></script>
@ -46,6 +48,7 @@
<script src="js/bmd-card.js"></script>
<script src="dist/screens-editor.js"></script>
<script src="dist/screens-admin.js"></script>
<script src="dist/screens-playout.js"></script>
<script src="dist/modal-new-recorder.js"></script>
<script src="dist/app.js"></script>
</body>

View file

@ -1,4 +1,103 @@
// modal-new-recorder.jsx New Recorder dialog (SRT / RTMP / SDI)
// modal-new-recorder.jsx - New Recorder dialog (SRT / RTMP / SDI / Deltacast)
/**
* DevicePortPicker - groups a flat per-port API response by node_id and
* renders one button per actual port. Replaces the old code that iterated
* over entries and synthesised port counts, which caused duplicate groups.
*
* props:
* ports - flat array from /cluster/devices/blackmagic or /deltacast
* each entry: { node_id, hostname, model, index, device, present? }
* selectedIdx - currently selected device_index
* selectedNode - currently selected node_id
* onSelect(idx, nodeId)
* portLabel - e.g. "SDI" or "Port"
* showTestBadge - show TEST CARD badge when present===false
*/
function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabel = 'Port', showTestBadge = false }) {
// Group by node_id (stable - one group per physical node)
const groups = React.useMemo(() => {
const map = new Map();
for (const p of ports) {
const key = p.node_id || p.hostname || 'unknown';
if (!map.has(key)) map.set(key, { nodeId: p.node_id || p.hostname || '', hostname: p.hostname || key, model: p.model || '', ports: [] });
map.get(key).ports.push(p);
}
// Sort ports within each group by index
for (const g of map.values()) g.ports.sort((a, b) => a.index - b.index);
return Array.from(map.values());
}, [ports]);
return (
<div className="sdi-port-mini">
{groups.map(group => (
<div key={group.nodeId} style={{ marginBottom: groups.length > 1 ? 12 : 4 }}>
{/* Node header: only show when multiple groups, or always for clarity */}
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '0 0 6px' }}>
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
</div>
{group.ports.map(port => {
const active = selectedIdx === port.index && selectedNode === group.nodeId;
return (
<button key={port.index}
className={`sdi-mini-port${active ? ' active' : ''}`}
onClick={() => onSelect(port.index, group.nodeId)}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>
{portLabel} {port.index + 1}
{showTestBadge && port.present === false && (
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--accent)', background: 'var(--accent-soft)', borderRadius: 3, padding: '1px 5px', marginLeft: 7 }}>
TEST CARD
</span>
)}
</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 'auto', fontFamily: 'var(--font-mono)' }}>
{port.device ? port.device.split('/').pop() : `index ${port.index}`}
</span>
</button>
);
})}
</div>
))}
</div>
);
}
/**
* ManualDevicePicker - fallback when no devices detected. Lets the operator
* pick node + index from dropdowns.
*/
function ManualDevicePicker({ nodes, nodeId, deviceIdx, portLabel, portCount, onNodeChange, onIdxChange, emptyNote }) {
return (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
{emptyNote || `No ${portLabel} devices auto-detected. Configure manually:`}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={nodeId}
onChange={e => onNodeChange(e.target.value)} style={{ appearance: 'auto' }}>
{nodes.length === 0
? <option value="">No cluster nodes</option>
: nodes.map(n => {
const id = n.id || n.hostname || n.name || '';
return <option key={id} value={id}>{n.hostname || n.name || id}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">{portLabel} index</label>
<select className="field-input" value={deviceIdx}
onChange={e => onIdxChange(Number(e.target.value))} style={{ appearance: 'auto' }}>
{Array.from({ length: portCount }, (_, i) =>
<option key={i} value={i}>{portLabel} {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
);
}
function ProbeResult({ result }) {
if (!result.ok) {
@ -42,9 +141,25 @@ function NewRecorderModal({ open, onClose }) {
return n ? (n.id || n.hostname || '') : '';
});
const [sdiDevices, setSdiDevices] = React.useState(null);
const [dcDeviceIdx, setDcDeviceIdx] = React.useState(0);
const [dcNodeId, setDcNodeId] = React.useState(() => {
const n = NODES[0];
return n ? (n.id || n.hostname || '') : '';
});
const [dcDevices, setDcDevices] = React.useState(null);
const [recTab, setRecTab] = React.useState('video');
const [recCodec, setRecCodec] = React.useState('prores_hq');
const [recContainer, setRecContainer] = React.useState('mov');
// All-Intra HEVC (NVENC) is the default master GPU-encoded, growing-file
// capable in fragmented MOV. ProRes stays selectable for 4:2:2 mezzanine.
const [recCodec, setRecCodec] = React.useState('hevc_nvenc');
// Custom target bitrate in Mbps for bitrate-controlled codecs (NVENC / x264 /
// x265 / DNxHD). ProRes ignores bitrate (quality is profile-driven).
const [recBitrate, setRecBitrate] = React.useState('60');
// Container is derived from the codec, not manually chosen: HEVC/ProRes/DNxHR
// MOV (fragmented, growing-capable); H.264 MP4.
const recContainer = (recCodec === 'h264' || recCodec === 'h264_nvenc' || recCodec === 'libx264') ? 'mp4' : 'mov';
// Codecs whose bitrate is operator-controlled (everything except ProRes).
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
const [proxyOn, setProxyOn] = React.useState(true);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false);
@ -59,6 +174,13 @@ function NewRecorderModal({ open, onClose }) {
.catch(() => setSdiDevices([]));
}, [sourceType]);
React.useEffect(() => {
if (sourceType !== 'DELTACAST' || dcDevices !== null) return;
window.ZAMPP_API.fetch('/cluster/devices/deltacast')
.then(d => setDcDevices(Array.isArray(d) ? d : []))
.catch(() => setDcDevices([]));
}, [sourceType]);
React.useEffect(() => { setProbeResult(null); }, [sourceType, srtUrl, rtmpUrl]);
const handleProbe = () => {
@ -75,6 +197,7 @@ function NewRecorderModal({ open, onClose }) {
const handleCreate = () => {
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); return; }
if (sourceType === 'DELTACAST' && !dcNodeId) { setSubmitErr('Select a capture node for Deltacast.'); return; }
setSubmitting(true);
setSubmitErr(null);
@ -85,14 +208,25 @@ function NewRecorderModal({ open, onClose }) {
generate_proxy: proxyOn,
recording_codec: recCodec,
recording_container: recContainer,
// Framerate + resolution are auto-detected from the source signal/stream.
recording_framerate: '', // empty = match source
recording_resolution: 'native',
};
// Custom bitrate only applies to bitrate-controlled codecs (ProRes ignores it).
if (codecUsesBitrate && recBitrate) {
body.recording_video_bitrate = `${recBitrate}M`;
}
if (sourceType === 'SRT') {
body.source_config = { url: srtUrl };
} else if (sourceType === 'RTMP') {
body.source_config = { url: rtmpUrl };
} else if (sourceType === 'DELTACAST') {
body.source_config = {};
body.device_index = dcDeviceIdx;
body.node_id = dcNodeId || undefined;
} else {
// SDI: device_index and node_id are top-level fields
// SDI (DeckLink): device_index and node_id are top-level fields
body.source_config = {};
body.device_index = sdiDeviceIdx;
body.node_id = sdiNodeId || undefined;
@ -133,9 +267,10 @@ function NewRecorderModal({ open, onClose }) {
<label className="field-label">Source type</label>
<div className="source-type-grid">
{[
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport pull caller', icon: 'signal' },
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport · pull caller', icon: 'signal' },
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
{ id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' },
].map(t => (
<button key={t.id}
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
@ -193,54 +328,55 @@ function NewRecorderModal({ open, onClose }) {
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting DeckLink devices</div>
)}
{sdiDevices !== null && sdiDevices.length > 0 && (
<div className="sdi-port-mini">
{sdiDevices.map((dev, di) => (
<div key={di} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '4px 0 8px' }}>
{(dev.model || dev.device || 'DeckLink').toUpperCase()} · {dev.hostname}
</div>
{Array.from({ length: dev.port_count || 4 }, (_, i) => i).map(idx => (
<button key={idx}
className={`sdi-mini-port ${sdiDeviceIdx === idx && sdiNodeId === (dev.node_id || dev.hostname || '') ? 'active' : ''}`}
onClick={() => { setSdiDeviceIdx(idx); setSdiNodeId(dev.node_id || dev.hostname || ''); }}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>SDI {idx + 1}</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 6 }}>index {idx}</span>
</button>
))}
</div>
))}
</div>
<DevicePortPicker
ports={sdiDevices}
selectedIdx={sdiDeviceIdx}
selectedNode={sdiNodeId}
onSelect={(idx, nodeId) => { setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }}
portLabel="SDI"
/>
)}
{sdiDevices !== null && sdiDevices.length === 0 && (
<div style={{ padding: '8px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
No DeckLink devices auto-detected. Configure manually:
<ManualDevicePicker
nodes={NODES}
nodeId={sdiNodeId}
deviceIdx={sdiDeviceIdx}
portLabel="SDI"
portCount={4}
onNodeChange={setSdiNodeId}
onIdxChange={setSdiDeviceIdx}
/>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
)}
{sourceType === 'DELTACAST' && (
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={sdiNodeId}
onChange={e => setSdiNodeId(e.target.value)} style={{ appearance: 'auto' }}>
{NODES.length === 0
? <option value="">No cluster nodes</option>
: NODES.map(n => {
const id = n.id || n.hostname || n.name || '';
const label = n.hostname || n.name || id;
return <option key={id} value={id}>{label}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">Device index</label>
<select className="field-input" value={sdiDeviceIdx}
onChange={e => setSdiDeviceIdx(Number(e.target.value))} style={{ appearance: 'auto' }}>
{[0, 1, 2, 3].map(i =>
<option key={i} value={i}>SDI {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
<label className="field-label">Capture device</label>
{dcDevices === null && (
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting Deltacast devices</div>
)}
{dcDevices !== null && dcDevices.length > 0 && (
<DevicePortPicker
ports={dcDevices}
selectedIdx={dcDeviceIdx}
selectedNode={dcNodeId}
onSelect={(idx, nodeId) => { setDcDeviceIdx(idx); setDcNodeId(nodeId); }}
portLabel="Port"
showTestBadge
/>
)}
{dcDevices !== null && dcDevices.length === 0 && (
<ManualDevicePicker
nodes={NODES}
nodeId={dcNodeId}
deviceIdx={dcDeviceIdx}
portLabel="Port"
portCount={8}
onNodeChange={setDcNodeId}
onIdxChange={setDcDeviceIdx}
emptyNote="No Deltacast devices detected. Configure manually (test-card mode):"
/>
)}
</div>
)}
@ -263,22 +399,48 @@ function NewRecorderModal({ open, onClose }) {
<div className="field">
<label className="field-label">Video codec</label>
<select className="field-input" value={recCodec} onChange={e => setRecCodec(e.target.value)} style={{ appearance: 'auto' }}>
<option value="prores_4444xq">ProRes 4444 XQ</option>
<option value="prores_4444">ProRes 4444</option>
<option value="prores_hq">ProRes 422 HQ</option>
<option value="hevc_nvenc">All-Intra HEVC (NVENC) GPU, growing</option>
<option value="h264_nvenc">H.264 (NVENC) GPU</option>
<option value="prores_hq">ProRes 422 HQ 4:2:2 CPU</option>
<option value="prores">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option>
<option value="libx264">H.264 (x264)</option>
<option value="libx265">H.265 / HEVC (x265)</option>
<option value="dnxhd">DNxHD 185x</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="xdcam_hd422">XDCAM HD422</option>
<option value="libx264">H.264 (x264, CPU)</option>
<option value="libx265">H.265 (x265, CPU)</option>
</select>
</div>
<Field label="Resolution" value="Source (native)" select />
<Field label="Color space" value="Rec. 709" select />
<Field label="Bit depth" value="10-bit" select />
{codecUsesBitrate ? (
<div className="field">
<label className="field-label">Target bitrate (Mbps)</label>
<input
className="field-input"
type="number" min="1" max="400" step="1"
value={recBitrate}
onChange={e => setRecBitrate(e.target.value)}
/>
</div>
) : (
<Field label="Bitrate" value="Quality-based (profile)" select />
)}
<Field label="Resolution" value="Auto — from source" select />
<Field label="Framerate" value="Auto — from source" select />
{/* #3: warn when the configured bitrate exceeds the probed source
bitrate re-encoding above source adds storage, not quality. */}
{codecUsesBitrate && (() => {
const d = probeResult && probeResult.ok ? (probeResult.data || {}) : null;
const raw = d && (d.bitrate ?? d.bit_rate ?? d.video_bitrate ?? (d.video && d.video.bit_rate));
const srcMbps = raw ? (Number(raw) > 100000 ? Number(raw) / 1e6 : Number(raw)) : null;
const cfg = parseFloat(recBitrate);
if (srcMbps && cfg && cfg > srcMbps * 1.05) {
return (
<div style={{ gridColumn: '1 / -1', fontSize: 11.5, color: 'var(--warn, #d9a441)', border: '1px solid var(--warn, #d9a441)', borderRadius: 6, padding: '8px 10px', background: 'rgba(217,164,65,0.08)' }}>
Target {cfg} Mbps exceeds the source stream (~{srcMbps.toFixed(1)} Mbps). Encoding above the source bitrate increases file size without adding quality.
</div>
);
}
return null;
})()}
</div>
)}
{recTab === 'audio' && (
@ -291,16 +453,8 @@ function NewRecorderModal({ open, onClose }) {
)}
{recTab === 'container' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Container</label>
<select className="field-input" value={recContainer} onChange={e => setRecContainer(e.target.value)} style={{ appearance: 'auto' }}>
<option value="mov">MOV (QuickTime)</option>
<option value="mxf">MXF (SMPTE)</option>
<option value="mkv">MKV (Matroska)</option>
<option value="mp4">MP4</option>
</select>
</div>
<Field label="Segment" value="None (single file)" select />
<Field label="Container" value={recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)'} select />
<Field label="Growing-file" value={recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No'} select />
</div>
)}
</div>
@ -328,7 +482,7 @@ function NewRecorderModal({ open, onClose }) {
<span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span>
))}
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile not configurable.</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile. Not configurable.</div>
</div>
</div>
)}

View file

@ -1,4 +1,4 @@
// screens-admin.jsx Users, Tokens, Containers, Cluster (graph), Settings
// screens-admin.jsx - Users, Tokens, Containers, Cluster (graph), Settings
function _normalizeNode(n, x, y) {
const cap = n.capabilities || {};
@ -29,9 +29,9 @@ function _normalizeNode(n, x, y) {
dbId: n.id,
role: n.role || 'worker',
status: n.status || (n.online ? 'online' : 'offline'),
ip: n.ip_address || n.ip || '',
version: n.version || '',
uptime: n.uptime || '',
ip: n.ip_address || n.ip || '·',
version: n.version || '·',
uptime: n.uptime || '·',
cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0),
mem: Math.round(memUsedMb / 1024 * 10) / 10,
memTotal: Math.round(memTotalMb / 1024 * 10) / 10,
@ -230,7 +230,7 @@ function Users() {
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || ''}
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '·'}
</div>
<div style={{ position: 'relative' }}>
<button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
@ -258,14 +258,7 @@ function Users() {
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
{tab === 'policies' && (
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
<Icon name="lock" size={24} />
<div style={{ marginTop: 10, fontWeight: 500, fontSize: 14, color: 'var(--text-2)' }}>Access policies</div>
<div style={{ fontSize: 12, marginTop: 4 }}>
Per-project and per-bin permissions are coming soon. For now, role-based access<br />
(admin / editor / viewer) is enforced API-wide.
</div>
</div>
<PoliciesPanel users={users} onChange={refreshUsers} />
)}
</div>
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
@ -285,6 +278,204 @@ function Users() {
);
}
//
// PoliciesPanel - interactive per-user permission matrix for the Policies tab.
// Keeps the access-model explainer as a small header, then renders one row per
// user with: inline role <select> (PATCH /users/:id), a 2FA badge driven by
// totp_enabled, an admin-only "Reset 2FA" action (POST /users/:id/totp/disable,
// 204), and an Access expander backed by GET /users/:id/access.
//
function PoliciesPanel({ users, onChange }) {
const [expandedId, setExpandedId] = React.useState(null);
const [err, setErr] = React.useState(null);
const changeRole = (u, newRole) => {
if (u.role === newRole) return;
setErr(null);
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
.then(() => onChange && onChange())
.catch(e => setErr('Role change failed: ' + (e.message || e)));
};
// Reset 2FA uses a raw fetch because ZAMPP_API.fetch throws on the 204 (no JSON
// body). Mirrors the disable() pattern in TotpSection.
const resetTotp = (u) => {
if (!confirm(`Reset two-factor for "${u.name}" (@${u.username})?\nThey will be able to sign in without a code until they re-enrol.`)) return;
setErr(null);
fetch('/api/v1/users/' + u.id + '/totp/disable', {
method: 'POST',
credentials: 'include',
headers: { 'X-Requested-With': 'dragonflight-ui' },
})
.then(r => {
if (r.status === 204) { onChange && onChange(); return; }
return r.json().catch(() => ({})).then(b => { throw new Error(b.error || ('Failed (' + r.status + ')')); });
})
.catch(e => setErr('Reset 2FA failed: ' + (e.message || e)));
};
return (
<div>
{/* Access-model explainer (kept from the old static tab, condensed) */}
<div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<Icon name="lock" size={15} />
<div style={{ fontWeight: 600, fontSize: 13.5 }}>Access model</div>
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.6, maxWidth: 720 }}>
<strong style={{ color: 'var(--text-2)' }}>admin</strong> has full access to every project plus
user, group, cluster, and system administration. <strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see
only the projects they're granted a <em>view</em> grant is read-only, an <em>edit</em> grant
allows changes, and grants can target a user or a group. Edit per-project grants from the{' '}
<a href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('df:nav', { detail: 'projects' })); }}
style={{ color: 'var(--accent-text)' }}>Projects</a> page; manage group membership on the Groups tab above.
</div>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
<div className="panel">
<div className="user-row head">
<div>User</div>
<div>Role</div>
<div>2FA</div>
<div>Access</div>
<div></div>
</div>
{users.length === 0 && (
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)' }}>No users found</div>
)}
{users.map(u => (
<UserPolicyRow key={u.id} user={u}
expanded={expandedId === u.id}
onToggle={() => setExpandedId(expandedId === u.id ? null : u.id)}
onChangeRole={changeRole}
onResetTotp={resetTotp} />
))}
</div>
</div>
);
}
function UserPolicyRow({ user: u, expanded, onToggle, onChangeRole, onResetTotp }) {
const [access, setAccess] = React.useState(null); // null = not loaded, {} once fetched
const [loading, setLoading] = React.useState(false);
const [accessErr, setAccessErr] = React.useState(null);
// Lazily fetch GET /users/:id/access the first time the row is expanded.
React.useEffect(() => {
if (!expanded || access !== null) return;
setLoading(true); setAccessErr(null);
window.ZAMPP_API.fetch('/users/' + u.id + '/access')
.then(d => setAccess(d || {}))
.catch(e => { setAccess({}); setAccessErr(e.message || 'Failed to load access'); })
.finally(() => setLoading(false));
}, [expanded, access, u.id]);
const projects = (access && access.projects) || [];
const memberships = (access && (access.groups || access.memberships)) || [];
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div className="user-row" style={{ borderBottom: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{u.username}</div>
</div>
</div>
<div>
<select value={u.role || 'viewer'}
onChange={e => onChangeRole(u, e.target.value)}
className="field-input"
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
</div>
<div>
{u.totp_enabled
? <span className="badge success"><Icon name="key" size={10} /> 2FA on</span>
: <span className="badge neutral">2FA off</span>}
</div>
<div>
<button className="btn ghost sm" onClick={onToggle}>
{expanded ? 'Hide' : 'View'}
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
{u.totp_enabled && (
<button className="btn ghost sm danger" onClick={() => onResetTotp(u)} title="Disable this user's two-factor">
<Icon name="key" size={11} />Reset 2FA
</button>
)}
</div>
</div>
{expanded && (
<div style={{ padding: '0 16px 16px 16px', background: 'var(--bg-2)' }}>
{loading && <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)' }}>Loading access</div>}
{accessErr && <div style={{ padding: '8px 0', fontSize: 12, color: 'var(--danger)' }}>{accessErr}</div>}
{!loading && !accessErr && (u.role === 'admin') && (
<div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="check" size={12} style={{ color: 'var(--success)' }} />
Admin full access to every project.
</div>
)}
{!loading && !accessErr && u.role !== 'admin' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, paddingTop: 12 }}>
{/* Accessible projects */}
<div>
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
Projects ({projects.length})
</div>
{projects.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>No project access granted.</div>
)}
{projects.map(p => {
// Backend `via` is 'direct' for a user grant, or 'group:<name>'
// when inherited from a group. Split the label off the prefix.
const via = p.via || 'direct';
const isGroup = via.indexOf('group') === 0;
const viaLabel = isGroup ? (via.indexOf(':') >= 0 ? via.slice(via.indexOf(':') + 1) : 'group') : 'direct';
return (
<div key={(p.project_id || p.id) + ':' + via}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 12.5, flex: 1 }}>{p.project_name || p.name || p.project_id || p.id}</span>
<span className={`badge ${(p.level === 'edit') ? 'accent' : 'neutral'}`}>{p.level || 'view'}</span>
<span className="badge neutral" title={isGroup ? 'Inherited from group ' + viaLabel : 'Granted directly'}>
<Icon name={isGroup ? 'users' : 'user'} size={9} /> {viaLabel}
</span>
</div>
);
})}
</div>
{/* Group memberships */}
<div>
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
Groups ({memberships.length})
</div>
{memberships.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>Not a member of any group.</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{memberships.map(g => (
<span key={g.id || g.group_id || g.name} className="badge neutral" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Icon name="users" size={9} />{g.name || g.group_name || g.group_id}
</span>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
function EditUserModal({ user, onClose, onSaved }) {
const [name, setName] = React.useState(user.display_name || user.name || '');
const [saving, setSaving] = React.useState(false);
@ -329,7 +520,7 @@ function PasswordResetModal({ user, onClose, onSaved }) {
const [err, setErr] = React.useState(null);
const [done, setDone] = React.useState(false);
// #111 guard async resolution / delayed onSaved against unmount.
// #111 - guard async resolution / delayed onSaved against unmount.
const mountedRef = React.useRef(true);
const savedTimerRef = React.useRef(null);
React.useEffect(() => () => {
@ -481,7 +672,7 @@ function GroupsPanel({ groups, users, onChange }) {
<div className="panel">
{groups.length === 0 && !creating && (
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
No groups yet click <em>New group</em> above to create one.
No groups yet: click <em>New group</em> above to create one.
</div>
)}
{groups.map(g => {
@ -521,8 +712,8 @@ function GroupsPanel({ groups, users, onChange }) {
<select className="field-input" defaultValue=""
onChange={e => { addMember(g, e.target.value); e.target.value = ''; }}
style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}>
<option value="" disabled> Pick a user </option>
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username} {u.name}</option>)}
<option value="" disabled>Pick a user</option>
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username}: {u.name}</option>)}
</select>
</div>
)}
@ -536,7 +727,25 @@ function GroupsPanel({ groups, users, onChange }) {
);
}
// Real Tokens admin page: wraps ApiTokensSection (defined further down) in a
// .page shell so it can be a top-level admin nav destination. The old parody
// pricing page lives below as TokensParody and is now routed at /billing in
// the Admin section.
function Tokens() {
return (
<div className="page">
<div className="page-header">
<h1>Tokens</h1>
<span className="subtitle">API tokens for the Premiere panel, node-agents, and external integrations</span>
</div>
<div className="page-body">
<ApiTokensSection />
</div>
</div>
);
}
function TokensParody() {
const [burned, setBurned] = React.useState(14340);
const [rate, setRate] = React.useState(2.4);
const [showCalc, setShowCalc] = React.useState(false);
@ -582,7 +791,7 @@ function Tokens() {
}, []);
const tiers = [
{ name: "Starter", desc: "For \"evaluation only\" definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
{ name: "Starter", desc: "For \"evaluation only\": definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
{ name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
{ name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" },
];
@ -590,7 +799,7 @@ function Tokens() {
return (
<div className="page">
<div className="page-header">
<h1>Tokens</h1>
<h1>Billing</h1>
<span className="subtitle">Token-metered pricing parody · You actually pay <strong style={{ color: "var(--success)" }}>$0.00</strong></span>
<div className="spacer" />
<span className="badge warning"><Icon name="alert" size={10} /> SATIRE</span>
@ -643,7 +852,7 @@ function Tokens() {
</div>
<div className="token-comparison">
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN DRAGONFLIGHT vs. THE OTHER GUYS</div>
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN: DRAGONFLIGHT vs. THE OTHER GUYS</div>
<div className="token-compare-chart">
<ChartLine
series={[
@ -699,7 +908,7 @@ function Tokens() {
<div>
<strong>Disclaimer:</strong> No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform
is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical
and protected as commentary. If you came here looking for actual API tokens, that page no longer exists service
and protected as commentary. If you came here looking for actual API tokens, that page no longer exists: service
credentials are managed through the cluster's own JWT issuer.
</div>
</div>
@ -798,7 +1007,7 @@ function Containers() {
const [containers, setContainers] = React.useState(null);
const [restartFlashState, setRestartFlashState] = React.useState(null);
const [logsModalState, setLogsModalState] = React.useState(null);
// #111 guard restart-flash timers against unmount.
// #111 - guard restart-flash timers against unmount.
const mountedRef = React.useRef(true);
const flashTimerRef = React.useRef(null);
React.useEffect(() => () => {
@ -960,7 +1169,7 @@ function Containers() {
}
//
// BmdCardPanel capture-card section inside the Cluster node detail panel.
// BmdCardPanel - capture-card section inside the Cluster node detail panel.
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
//
function BmdCardPanel({ sel, portSignals }) {
@ -995,7 +1204,7 @@ function BmdCardPanel({ sel, portSignals }) {
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="video" size={11} />
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'}
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '(none reported)'}
</div>
{sel.bmdPorts.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
@ -1021,7 +1230,7 @@ function BmdCardPanel({ sel, portSignals }) {
const { label, color } = _signalChip(sig);
const isReceiving = sig === 'receiving';
return (
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} ${label}` : label}
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
style={{
display: "flex", alignItems: "center", gap: 5,
fontSize: 10.5, fontFamily: "var(--font-mono)",
@ -1070,7 +1279,7 @@ function _signalChip(sig) {
case 'error': return { label: 'ERROR', color: 'var(--danger)' };
case 'idle': return { label: 'IDLE', color: 'var(--text-3)' };
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' };
default: return { label: sig || '', color: 'var(--text-4)' };
default: return { label: sig || '·', color: 'var(--text-4)' };
}
}
@ -1174,7 +1383,7 @@ function Cluster() {
});
const removeNode = (node) => {
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine it only removes it from cluster membership.')) return;
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine: it only removes it from cluster membership.')) return;
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' })
.then(() => refresh())
.catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] }));
@ -1323,7 +1532,7 @@ function Cluster() {
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} />
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '— none reported'}
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '(none reported)'}
</div>
{sel.gpus.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No GPUs detected on this node</div>
@ -1456,6 +1665,135 @@ function AccountSection() {
);
}
// Two-factor (TOTP) enrollment + management. Reflects window.ZAMPP_DATA.ME.totp_enabled.
function TotpSection() {
const me = window.ZAMPP_DATA?.ME || {};
const [enabled, setEnabled] = React.useState(!!me.totp_enabled);
const [phase, setPhase] = React.useState('idle'); // idle | enrolling | recovery
const [enroll, setEnroll] = React.useState(null); // { secret, otpauth_uri, qr }
const [code, setCode] = React.useState('');
const [recovery, setRecovery] = React.useState(null); // string[]
const [disablePw, setDisablePw] = React.useState('');
const [showDisable, setShowDisable] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const api = (path, body) => fetch('/api/v1/auth' + path, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify(body || {}),
});
const startSetup = async () => {
setMsg(null); setBusy(true);
try {
const r = await api('/totp/setup');
if (r.status === 200) { setEnroll(await r.json()); setPhase('enrolling'); }
else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Setup failed' });
} finally { setBusy(false); }
};
const confirmEnable = async () => {
setMsg(null); setBusy(true);
try {
const r = await api('/totp/enable', { code: code.trim() });
const body = await r.json().catch(() => ({}));
if (r.status === 200) {
setRecovery(body.recovery_codes || []); setPhase('recovery');
setEnabled(true); setCode('');
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: true };
} else setMsg({ kind: 'err', text: body.error || 'Could not enable' });
} finally { setBusy(false); }
};
const disable = async () => {
setMsg(null); setBusy(true);
try {
const r = await api('/totp/disable', { password: disablePw });
if (r.status === 204) {
setEnabled(false); setShowDisable(false); setDisablePw(''); setPhase('idle');
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: false };
setMsg({ kind: 'ok', text: 'Two-factor disabled' });
} else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Could not disable' });
} finally { setBusy(false); }
};
return (
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Two-factor authentication</h3>
{/* Status line */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: phase === 'idle' ? 0 : 14 }}>
<span className={`badge ${enabled ? 'success' : 'neutral'}`}>{enabled ? 'Enabled' : 'Disabled'}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{enabled ? 'An authenticator code is required at sign-in.' : 'Add a time-based code from an authenticator app.'}
</span>
<span style={{ flex: 1 }} />
{!enabled && phase === 'idle' && <button className="btn primary sm" disabled={busy} onClick={startSetup}>Set up</button>}
{enabled && phase !== 'recovery' && !showDisable && <button className="btn ghost sm danger" onClick={() => setShowDisable(true)}>Disable</button>}
</div>
{/* Enrolling: show QR / secret + code field */}
{phase === 'enrolling' && enroll && (
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 16, alignItems: 'start' }}>
<div>
{enroll.qr
? <img src={enroll.qr} alt="TOTP QR code" style={{ width: 160, height: 160, borderRadius: 6, background: '#fff', padding: 6 }} />
: <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Scan the secret below in your app.</div>}
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
Scan the QR with Google Authenticator, Authy, or 1Password or enter this secret manually:
</div>
<div className="mono" style={{ fontSize: 12, background: 'var(--bg-2)', padding: '6px 10px', borderRadius: 5, wordBreak: 'break-all', marginBottom: 12 }}>{enroll.secret}</div>
<div className="field">
<label className="field-label">Enter the 6-digit code to confirm</label>
<input className="field-input mono" value={code} autoFocus
onChange={e => setCode(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && code.trim()) confirmEnable(); }}
placeholder="123456" style={{ width: 140 }} />
</div>
<div style={{ marginTop: 10 }}>
<button className="btn primary sm" disabled={busy || !code.trim()} onClick={confirmEnable}>Enable</button>
<button className="btn ghost sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setEnroll(null); setCode(''); }}>Cancel</button>
</div>
</div>
</div>
)}
{/* Recovery codes — shown exactly once */}
{phase === 'recovery' && recovery && (
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
Save these recovery codes somewhere safe. Each works once if you lose your authenticator. They won't be shown again.
</div>
<div className="mono" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 13, marginBottom: 10 }}>
{recovery.map(c => <div key={c}>{c}</div>)}
</div>
<button className="btn sm" onClick={() => navigator.clipboard && navigator.clipboard.writeText(recovery.join('\n'))}>Copy codes</button>
<button className="btn primary sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setRecovery(null); }}>Done</button>
</div>
)}
{/* Disable confirmation */}
{showDisable && (
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '160px 1fr auto auto', gap: 8, alignItems: 'end' }}>
<div className="field" style={{ marginBottom: 0, gridColumn: '1 / 3' }}>
<label className="field-label">Confirm your password to disable</label>
<input className="field-input" type="password" value={disablePw} autoComplete="current-password"
onChange={e => setDisablePw(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && disablePw) disable(); }} />
</div>
<button className="btn danger sm" disabled={busy || !disablePw} onClick={disable}>Disable 2FA</button>
<button className="btn ghost sm" onClick={() => { setShowDisable(false); setDisablePw(''); }}>Cancel</button>
</div>
)}
{msg && <div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>}
</section>
);
}
function ApiTokensSection() {
const [tokens, setTokens] = React.useState([]);
const [name, setName] = React.useState('');
@ -1504,7 +1842,7 @@ function ApiTokensSection() {
{justCreated && (
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
Save this token now it will not be shown again
Save this token now: it will not be shown again
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
@ -1565,6 +1903,7 @@ function Settings() {
{section === 'account' && (
<>
<AccountSection />
<TotpSection />
<ApiTokensSection />
</>
)}
@ -1580,7 +1919,7 @@ function Settings() {
}
//
// Storage unified view: live mount/bucket health on top, then the two
// Storage - unified view: live mount/bucket health on top, then the two
// existing editors (S3 bucket + growing-files SMB landing zone) stacked.
//
@ -1595,7 +1934,7 @@ function StorageSection() {
}
function formatBytes(n) {
if (n == null || isNaN(n)) return '';
if (n == null || isNaN(n)) return '·';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let v = n, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
@ -1628,7 +1967,7 @@ function MountHealthStrip() {
React.useEffect(() => {
load();
// Light auto-refresh so free-space + reachability stay current while the
// operator is on the page. 15s is plenty these are diagnostic, not real-time.
// operator is on the page. 15s is plenty - these are diagnostic, not real-time.
const t = setInterval(load, 15_000);
return () => clearInterval(t);
}, [load]);
@ -1678,9 +2017,9 @@ function MountHealthStrip() {
)}
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Container</span><span className="mono">{g.container_path || ''}</span>
<span>Host</span><span className="mono">{g.host_path || ''}</span>
<span>SMB</span><span className="mono">{g.smb_url || ''}</span>
<span>Container</span><span className="mono">{g.container_path || '·'}</span>
<span>Host</span><span className="mono">{g.host_path || '·'}</span>
<span>SMB</span><span className="mono">{g.smb_url || '·'}</span>
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
</div>
@ -1700,8 +2039,8 @@ function MountHealthStrip() {
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Endpoint</span><span className="mono">{s.endpoint || '(AWS default)'}</span>
<span>Bucket</span><span className="mono">{s.bucket || ''}</span>
<span>Region</span><span className="mono">{s.region || ''}</span>
<span>Bucket</span><span className="mono">{s.bucket || '·'}</span>
<span>Region</span><span className="mono">{s.region || '·'}</span>
{s.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{s.error}</span></>}
</div>
</div>
@ -1767,7 +2106,7 @@ function S3SettingsCard() {
<SField label="Bucket"><input className="field-input mono" required value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
</div>
<SField label="Access key ID"><input className="field-input mono" required value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /></SField>
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved type to replace)' : 'Secret key'} autoComplete="new-password" /></SField>
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved: type to replace)' : 'Secret key'} autoComplete="new-password" /></SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
@ -1791,7 +2130,7 @@ function GpuSettingsCard() {
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved new settings apply to the next proxy job.' }); })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved: new settings apply to the next proxy job.' }); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
@ -1810,7 +2149,7 @@ function GpuSettingsCard() {
<SField label="Hardware acceleration">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available falls back to CPU on missing hardware</span>
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available: falls back to CPU on missing hardware</span>
</label>
</SField>
@ -1843,9 +2182,9 @@ function GpuSettingsCard() {
</SField>
<SField label="Rate control">
<select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
<option value="cbr">CBR constant bitrate</option>
<option value="vbr">VBR variable bitrate</option>
<option value="cqp">CQP / CRF constant quality</option>
<option value="cbr">CBR: constant bitrate</option>
<option value="vbr">VBR: variable bitrate</option>
<option value="cqp">CQP / CRF: constant quality</option>
</select>
</SField>
</div>
@ -1941,13 +2280,13 @@ function SdiSettingsCard() {
}
//
// Capture SDK deployment Blackmagic / AJA / Deltacast
// Capture SDK deployment - Blackmagic / AJA / Deltacast
//
const SDK_VENDORS = [
{
id: 'blackmagic',
name: 'Blackmagic DeckLink',
sub: 'DeckLink SDK 16.x required for SDI capture via DeckLink cards',
sub: 'DeckLink SDK 16.x: required for SDI capture via DeckLink cards',
expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',
docs: 'https://www.blackmagicdesign.com/developer/product/capture',
buildHint: 'docker compose build --no-cache capture',
@ -1956,24 +2295,24 @@ const SDK_VENDORS = [
{
id: 'aja',
name: 'AJA NTV2',
sub: 'NTV2 SDK for Kona / Io / U-Tap / T-Tap cards',
sub: 'NTV2 SDK: for Kona / Io / U-Tap / T-Tap cards',
expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',
docs: 'https://sdksupport.aja.com/',
buildHint: 'FFmpeg patch + Dockerfile update pending files will be staged for the next capture image build',
buildHint: 'FFmpeg patch + Dockerfile update pending: files will be staged for the next capture image build',
status: 'staging-only',
},
{
id: 'deltacast',
name: 'Deltacast VideoMaster',
sub: 'VideoMasterHD SDK for FLEX / DELTA-h4k2 / etc.',
sub: 'VideoMasterHD SDK: for FLEX / DELTA-h4k2 / etc.',
expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',
docs: 'https://www.deltacast.tv/products/sdk',
buildHint: 'FFmpeg patch + Dockerfile update pending files will be staged for the next capture image build',
buildHint: 'FFmpeg patch + Dockerfile update pending: files will be staged for the next capture image build',
status: 'staging-only',
},
];
// Premiere panel releases single source of truth lives on `window.PREMIERE_RELEASES`
// Premiere panel releases - single source of truth lives on `window.PREMIERE_RELEASES`
// (see data.jsx). Local alias for readability.
const PREMIERE_RELEASES = window.PREMIERE_RELEASES;
@ -1988,7 +2327,7 @@ function SdkSettingsCard() {
React.useEffect(() => { load(); }, [load]);
return (
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed upload them here so the capture container can build with hardware support"
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed: upload them here so the capture container can build with hardware support"
tag={<span className="badge neutral">{SDK_VENDORS.length} vendors</span>}>
{/* ── Premiere Panel download section ── */}
@ -1997,9 +2336,9 @@ function SdkSettingsCard() {
Premiere Pro Panel
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 10 }}>
The Dragonflight CEP panel enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro.
Install the <strong style={{ color: 'var(--text-2)' }}>.zxp</strong> via <a href="https://zxpsign.com/zxp-installer" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>ZXP Installer</a> (Mac/Win),
or run the <strong style={{ color: 'var(--text-2)' }}>Windows Setup</strong> which bundles the installer automatically.
The Dragonflight UXP plugin enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro.
Install the <strong style={{ color: 'var(--text-2)' }}>.ccx</strong> via the <a href="https://developer.adobe.com/photoshop/uxp/2022/guides/devtool/installation/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>Adobe UXP Developer Tool</a>,
or double-click it with Creative Cloud installed.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{PREMIERE_RELEASES.map(r => (
@ -2011,12 +2350,16 @@ function SdkSettingsCard() {
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{r.notes}</div>
</div>
<a href={r.zxp} download style={{ textDecoration: 'none' }}>
<button className="btn ghost sm">ZXP</button>
{r.ccx && (
<a href={r.ccx} download style={{ textDecoration: 'none' }}>
<button className="btn ghost sm">UXP (.ccx)</button>
</a>
)}
{r.installer && (
<a href={r.installer} download style={{ textDecoration: 'none' }}>
<button className="btn ghost sm">Win Installer</button>
</a>
)}
</div>
))}
</div>
@ -2059,7 +2402,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
const fd = new FormData();
fd.append('archive', file);
// Use XHR so we can report progress to the user fetch's stream API is fiddly.
// Use XHR so we can report progress to the user - fetch's stream API is fiddly.
await new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
@ -2075,7 +2418,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
} else {
let txt = xhr.responseText;
try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
onDone(vendor.name + ': upload failed ' + txt, false);
onDone(vendor.name + ': upload failed: ' + txt, false);
}
resolve();
};
@ -2159,7 +2502,7 @@ function AmppSettingsCard() {
<input className="field-input mono" type="url" required value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
</SField>
<SField label="API token">
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved type to replace)' : 'AMPP API token'} autoComplete="new-password" />
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved: type to replace)' : 'AMPP API token'} autoComplete="new-password" />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>

View file

@ -1,6 +1,6 @@
// screens-asset.jsx asset detail (Frame.io-style player, filmstrip, comments)
// screens-asset.jsx - asset detail (Frame.io-style player, filmstrip, comments)
// Simple gradient palette replaces the missing thumbGrad function
// Simple gradient palette - replaces the missing thumbGrad function
const _FRAME_GRADIENTS = [
'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
@ -41,7 +41,7 @@ function AssetDetail({ asset, onClose }) {
// Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error'
const [playerState, setPlayerState] = React.useState('idle');
const [playerError, setPlayerError] = React.useState(null);
// Array of {start, end} in milliseconds populated from HTMLMediaElement.buffered
// Array of {start, end} in milliseconds - populated from HTMLMediaElement.buffered
const [buffered, setBuffered] = React.useState([]);
// Wall-clock when waiting/stalled began (so we can show how long it's been hung)
const [stallStart, setStallStart] = React.useState(null);
@ -65,7 +65,12 @@ function AssetDetail({ asset, onClose }) {
setStreamLoading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
.then(function(r) {
if (r && r.url) {
if (r && r.hls_url) {
// Prefer HLS for in-browser playback; `url` stays the MP4 proxy
// (used by the Premiere plugin importer + as a fallback).
setStreamUrl(r.hls_url);
setStreamType('hls');
} else if (r && r.url) {
setStreamUrl(r.url);
setStreamType(r.type || 'mp4');
} else if (r) {
@ -89,7 +94,7 @@ function AssetDetail({ asset, onClose }) {
}, [streamUrl, streamType]);
// Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg).
// Falls back to nothing if not ready yet user can right-click Re-generate.
// Falls back to nothing if not ready yet - user can right-click Re-generate.
React.useEffect(() => {
if (!assetId) return;
let cancelled = false;
@ -115,7 +120,7 @@ function AssetDetail({ asset, onClose }) {
return function() { cancelled = true; };
}, [assetId, filmstripKey]);
// Fake playback timer only used when no real video stream
// Fake playback timer - only used when no real video stream
React.useEffect(() => {
if (!playing || totalMs <= 0 || streamUrl) return;
const i = setInterval(function() {
@ -159,7 +164,7 @@ function AssetDetail({ asset, onClose }) {
return () => clearInterval(i);
}, [stallStart]);
// #143 if the player is stalled within 250 ms of EOF for more than 1.2 s,
// #143 - if the player is stalled within 250 ms of EOF for more than 1.2 s,
// treat it as a clean end. Avoids the silent-freeze users hit when seeking
// to the last instant of a clip.
React.useEffect(() => {
@ -180,7 +185,7 @@ function AssetDetail({ asset, onClose }) {
}, [stallStart, totalMs, playerState]);
const seek = function(ms) {
// #143 seeking exactly to `totalMs` parked the playhead one micro-sample
// #143 - seeking exactly to `totalMs` parked the playhead one micro-sample
// past the last decoded frame; the player then asked S3 for a range past
// EOF and stalled silently. Pull the clamp back 50 ms so the final frames
// are reachable but the player never asks for bytes past the file size.
@ -212,7 +217,7 @@ function AssetDetail({ asset, onClose }) {
.finally(function() { setDownloading(false); });
};
// Right-click style menu on the kebab icon delete, copy ID.
// Right-click style menu on the kebab icon - delete, copy ID.
const [menuOpen, setMenuOpen] = React.useState(false);
const moreBtnRef = React.useRef(null);
React.useEffect(function() {
@ -258,7 +263,7 @@ function AssetDetail({ asset, onClose }) {
const regenFilmstrip = function() {
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
.then(function() { window.alert('Filmstrip job queued it will appear automatically when ready.'); })
.then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); })
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
};
@ -477,7 +482,7 @@ function AssetDetail({ asset, onClose }) {
<span className="badge live">LIVE · REC</span>
</div>
)}
{/* Player health badge shows when waiting/stalled so the freeze is visible */}
{/* Player health badge: shows when waiting/stalled so the freeze is visible */}
{streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && (
<div style={{ position: "absolute", top: 12, right: 12, display: "flex", gap: 6, alignItems: "center" }}>
<span className={'badge ' + (playerState === 'error' ? 'danger' : playerState === 'stalled' ? 'warning' : 'neutral')}>
@ -630,7 +635,7 @@ function PlaybackBar({ current, total, onSeek, comments, buffered }) {
const bufferedRanges = Array.isArray(buffered) ? buffered : [];
return (
<div className="playback-bar" ref={ref} onClick={handle}>
{/* Buffered byte ranges translucent grey segments showing what the browser has loaded */}
{/* Buffered byte ranges: translucent grey segments showing what the browser has loaded */}
{total > 0 && bufferedRanges.map((br, i) => {
const left = Math.max(0, (br.start / total) * 100);
const right = Math.min(100, (br.end / total) * 100);
@ -902,7 +907,7 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
<FileRow
label="Filmstrip"
present={hasFilmstrip}
path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet right-click filmstrip or click Re-generate'}
path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet: right-click filmstrip or click Re-generate'}
icon="editor"
actionLabel="Re-generate"
onAction={onRegenFilmstrip}
@ -929,21 +934,21 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
function MetadataTab({ asset }) {
var rows = [
{ k: "Filename", v: asset.name },
{ k: "Duration", v: asset.duration || '' },
{ k: "Resolution", v: asset.res || '' },
{ k: "Codec", v: asset.codec || '' },
{ k: "File size", v: asset.size || '' },
{ k: "Status", v: asset.status || '' },
{ k: "Updated", v: asset.updated || '' },
{ k: "Project", v: asset.project || '' },
{ k: "Duration", v: asset.duration || '·' },
{ k: "Resolution", v: asset.res || '·' },
{ k: "Codec", v: asset.codec || '·' },
{ k: "File size", v: asset.size || '·' },
{ k: "Status", v: asset.status || '·' },
{ k: "Updated", v: asset.updated || '·' },
{ k: "Project", v: asset.project || '·' },
];
var audioMeta = asset.audio_metadata;
if (audioMeta && Array.isArray(audioMeta) && audioMeta.length > 0) {
rows.push({ k: "Audio tracks", v: audioMeta.length });
audioMeta.forEach(function(tr, i) {
var label = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '');
var parts = [tr.codec || '', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '', tr.bit_depth ? tr.bit_depth + '-bit' : '', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : ''];
var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·');
var parts = [tr.codec || '·', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·', tr.bit_depth ? tr.bit_depth + '-bit' : '·', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·'];
if (tr.language) parts.push(tr.language);
rows.push({ k: " " + label, v: parts.join(' · ') });
});
@ -1106,13 +1111,13 @@ function AudioTab({ asset }) {
var st = trackState[i] || { muted: false, solo: false, volume: 100 };
var isAudible = st.muted ? false : (anySolo ? st.solo : true);
var color = _AUDIO_TRACK_COLORS[i % _AUDIO_TRACK_COLORS.length];
var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '');
var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·');
var trackName = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
var langTag = tr.language ? <span className="badge neutral" style={{ marginLeft: 6 }}>{tr.language}</span> : null;
var codecLabel = tr.codec || '';
var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '';
var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '';
var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '';
var codecLabel = tr.codec || '·';
var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·';
var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '·';
var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·';
return (
<div key={i} className={'audio-track' + (isAudible ? '' : ' muted')}>
@ -1187,7 +1192,7 @@ function AudioLevelMeter({ level, label, tall }) {
}
function parseDuration(d) {
if (!d || d === '' || typeof d !== 'string') return 0;
if (!d || d === '·' || typeof d !== 'string') return 0;
const parts = d.split(':');
if (parts.length < 2) return 0;
const nums = parts.map(Number);

View file

@ -1,4 +1,4 @@
// LoginScreen + SetupScreen layout B from the auth brainstorm spec:
// LoginScreen + SetupScreen - layout B from the auth brainstorm spec:
// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card.
// Matches DESIGN.md tokens; no decoration, dense, ops register.
@ -116,27 +116,131 @@
);
}
// Google sign-in availability + a friendly message for the callback's
// ?auth_error redirect (domain-not-allowed / generic google failure).
function useGoogleAndAuthError(setError) {
const [googleEnabled, setGoogleEnabled] = React.useState(false);
React.useEffect(() => {
fetch(API_BASE + '/auth/google/enabled', { credentials: 'include' })
.then(r => r.json()).then(d => setGoogleEnabled(!!d.enabled)).catch(() => {});
const params = new URLSearchParams(location.search);
const e = params.get('auth_error');
if (e === 'domain') setError('That Google account is not in an allowed domain.');
else if (e === 'google') setError('Google sign-in failed. Please try again.');
if (e) {
// Clean the query string so a reload doesn't re-show the error.
const url = location.pathname + location.hash;
history.replaceState(null, '', url);
}
}, [setError]);
return googleEnabled;
}
function GoogleButton() {
return (
<a href={API_BASE + '/auth/google'} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
width: '100%', boxSizing: 'border-box', textDecoration: 'none',
background: 'var(--bg-3)', color: 'var(--text-1)',
border: '1px solid var(--border)', borderRadius: 4,
padding: '9px', fontSize: 13, fontWeight: 600, marginTop: 10,
}}>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700 }}>G</span>
Sign in with Google
</a>
);
}
function Divider() {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '14px 0 4px' }}>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<span style={{ fontSize: 10, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>or</span>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
</div>
);
}
function LoginScreen({ onDone }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
// Second factor: when the server returns { mfa_required, ticket }, we switch
// to the code step instead of completing login. `ticket` may be a real value
// (password path) or the sentinel 'session' (Google path, where the ticket
// lives in the session cookie and is not exposed to JS).
const [ticket, setTicket] = React.useState(null);
const [code, setCode] = React.useState('');
const googleEnabled = useGoogleAndAuthError(setError);
// Google OAuth with TOTP redirects back to /?mfa=1; the ticket is in the
// session, so enter the code step without a body ticket.
React.useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('mfa') === '1') {
setTicket('session');
history.replaceState(null, '', location.pathname + location.hash);
}
}, []);
const submit = async () => {
setError(''); setBusy(true);
try {
const r = await postJson('/auth/login', { username, password });
if (r.status === 200) { onDone(); return; }
if (r.status === 200) {
const body = await r.json().catch(() => ({}));
if (body.mfa_required) { setTicket(body.ticket); setBusy(false); return; }
onDone(); return;
}
const body = await r.json().catch(() => ({}));
setError(body.error || ('Login failed: ' + r.status));
} catch (e) { setError(e.message || 'Login failed'); }
finally { setBusy(false); }
};
const submitCode = async () => {
setError(''); setBusy(true);
try {
// For the Google path the ticket is the session sentinel send code only.
const payload = ticket === 'session' ? { code: code.trim() } : { ticket, code: code.trim() };
const r = await postJson('/auth/login/totp', payload);
if (r.status === 200) { onDone(); return; }
const body = await r.json().catch(() => ({}));
// An expired/used ticket means the user must start over.
if (r.status === 401 && /ticket/.test(body.error || '')) {
setTicket(null); setCode(''); setPassword('');
setError('Session timed out — please sign in again.');
} else {
setError(body.error || ('Verification failed: ' + r.status));
}
} catch (e) { setError(e.message || 'Verification failed'); }
finally { setBusy(false); }
};
if (ticket) {
return (
<Screen>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
Two-factor authentication
</div>
<ErrorRow text={error} />
<Field label="Authenticator code" value={code} onChange={setCode} autoComplete="one-time-code" autoFocus />
<div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 12 }}>
Enter the 6-digit code from your authenticator app, or a recovery code.
</div>
<Button type="submit" disabled={busy || !code.trim()} onClick={submitCode}>Verify</Button>
</Screen>
);
}
return (
<Screen>
<ErrorRow text={error} />
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
{googleEnabled && <><Divider /><GoogleButton /></>}
</Screen>
);
}
@ -163,7 +267,7 @@
return (
<Screen>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
First-run setup create the first admin
First-run setup: create the first admin
</div>
<ErrorRow text={error} />
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />

View file

@ -1,4 +1,4 @@
// screens-editor.jsx NLE timeline editor
// screens-editor.jsx - NLE timeline editor
// Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API
function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); }
@ -378,45 +378,23 @@ function Editor() {
return (
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* ── COMING SOON bumper — overlays the entire editor ── */}
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(10, 12, 18, 0.92)',
backdropFilter: 'blur(6px)',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 20, pointerEvents: 'all',
}}>
<div style={{
width: 64, height: 64,
background: 'linear-gradient(135deg, var(--accent), hsl(250 80% 65%))',
borderRadius: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 0 40px rgba(91, 124, 250, 0.4)',
}}>
<Icon name="editor" size={30} />
{/* Beta banner: flat strip across top, no glassmorphism or gradient. */}
<div className="editor-beta-banner">
<Icon name="editor" size={14} />
<div className="editor-beta-banner-body">
<strong>NLE editor is in beta.</strong>
<span> Use the Premiere Pro panel for frame-accurate editing and growing-file workflows.</span>
</div>
<div style={{ textAlign: 'center', maxWidth: 420 }}>
<div style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', color: 'var(--text-primary)', marginBottom: 8 }}>
NLE Editor Coming Soon
</div>
<div style={{ fontSize: 14, color: 'var(--text-3)', lineHeight: 1.6 }}>
The browser-based timeline editor is under active development.
In the meantime, use the <strong style={{ color: 'var(--text-2)' }}>Premiere Pro panel</strong> for
frame-accurate editing and growing-file workflows download it from
<strong style={{ color: 'var(--text-2)' }}> Settings Capture SDKs</strong>.
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
<a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download style={{ textDecoration: 'none' }}>
<button className="btn primary">Download ZXP</button>
<div className="editor-beta-banner-actions">
<a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download className="btn primary sm">
Download Premiere panel
</a>
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download style={{ textDecoration: 'none' }}>
<button className="btn ghost">Windows Installer</button>
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download className="btn ghost sm">
Windows installer
</a>
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}>
Dragonflight Premiere Panel v{(window.PREMIERE_LATEST || {}).version || '—'}
<span className="editor-beta-banner-version mono">
v{(window.PREMIERE_LATEST || {}).version || '·'}
</span>
</div>
</div>
@ -719,7 +697,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) {
React.useEffect(() => {
function handler(e) {
// #116 `document.activeElement` is null in some edge cases (iframe focus,
// #116 - `document.activeElement` is null in some edge cases (iframe focus,
// popovers, devtools-driven focus), and the previous code threw NPE here.
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;

View file

@ -2,31 +2,40 @@
//
// Two routes share this file:
//
// Home the launcher. Big-button entry into each section of the MAM.
// Home - the launcher. Big-button entry into each section of the MAM.
// Untouched in this rewrite.
//
// Dashboard the operations view. Rebuilt as a control-room status
// Dashboard - the operations view. Rebuilt as a control-room status
// board, not a SaaS analytics page. Sections render top-down by
// operator priority:
//
// 1. ON AIR live recorder tiles, full-width
// 2. UP NEXT single-row strip of next scheduled recordings
// 3. ATTENTION conditional; only when something failed
// 4. WORK + CLUSTER two-column dense panels
// 5. STATUS BAR single mono-text line, bottom
// 1. ON AIR - live recorder tiles, full-width
// 2. UP NEXT - single-row strip of next scheduled recordings
// 3. ATTENTION - conditional; only when something failed
// 4. WORK + CLUSTER - two-column dense panels
// 5. STATUS BAR - single mono-text line, bottom
//
// Anything that would just say "all clear" is hidden, not rendered.
function Home({ navigate }) {
const [showDownloads, setShowDownloads] = React.useState(false);
// Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running")
// reflect what's actually in the DB right now, not a stale boot-time cache.
const [cards, setCards] = React.useState({});
// Playout has no /metrics/home card yet (and the playout schema may not be
// migrated on every install); fetch /playout/channels separately and degrade
// silently the tile just shows "No channels" if the endpoint isn't there.
const [playoutChannels, setPlayoutChannels] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
const load = () => {
window.ZAMPP_API.fetch('/metrics/home?hours=1')
.then(d => { if (!cancelled) setCards(d?.cards || {}); })
.catch(() => {});
window.ZAMPP_API.fetch('/playout/channels')
.then(d => { if (!cancelled) setPlayoutChannels(Array.isArray(d) ? d : []); })
.catch(() => { if (!cancelled) setPlayoutChannels([]); });
};
load();
const t = setInterval(load, 30_000);
@ -62,12 +71,27 @@ function Home({ navigate }) {
desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.',
},
{
id: 'editor',
label: 'Editor',
icon: 'editor',
id: 'playout',
label: 'Playout',
icon: 'signal',
tone: 'accent',
sub: (() => {
if (playoutChannels === null) return '·';
const total = playoutChannels.length;
const onAir = playoutChannels.filter(c => c.status === 'running').length;
if (total === 0) return 'No channels';
if (onAir > 0) return onAir + ' on air · ' + total + ' channel' + (total === 1 ? '' : 's');
return total + ' channel' + (total === 1 ? '' : 's');
})(),
desc: 'Master Control. SDI · NDI · SRT · RTMP playout, playlists, as-run.',
},
{
id: '__downloads',
label: 'Downloads',
icon: 'download',
tone: 'purple',
sub: 'Beta',
desc: 'Timeline editor with cross-clip preview and render queue.',
sub: 'Plugin · Teams ISO',
desc: 'Download the Premiere Pro UXP plugin and the Teams ISO installer.',
},
{
id: 'jobs',
@ -91,6 +115,19 @@ function Home({ navigate }) {
const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal;
// Activity strip (#153): live recorders + last-24h assets + alerts.
const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
const recentAssets = (() => {
const dayAgo = Date.now() - 86400000;
return ASSETS
.filter(a => a.created_at && new Date(a.created_at).getTime() > dayAgo)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 6);
})();
const failedCount = JOBS.filter(j => j.status === 'failed').length;
const errCount = RECORDERS.filter(r => r.status === 'error').length;
const hasActivity = liveRecorders.length || recentAssets.length || failedCount || errCount;
return (
<div className="launcher">
<div className="launcher-inner">
@ -103,7 +140,10 @@ function Home({ navigate }) {
/>
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
<p className="launcher-tagline">
Self-hosted broadcast media-asset management
Media Asset Management &amp; Production Platform
</p>
<p className="launcher-tagline launcher-tagline-motto">
Let's create
</p>
</div>
@ -112,7 +152,7 @@ function Home({ navigate }) {
<button
key={t.id}
className={'launcher-tile tone-' + t.tone}
onClick={() => navigate(t.id)}
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
>
<span className="launcher-tile-icon">
<Icon name={t.icon} size={26} />
@ -131,7 +171,7 @@ function Home({ navigate }) {
onClick={() => navigate('dashboard')}
>
<span className="launcher-tile-icon">
<Icon name="home" size={22} />
<Icon name="layout" size={22} />
</span>
<span className="launcher-tile-label">Dashboard</span>
<span className="launcher-tile-sub">Operations view</span>
@ -144,6 +184,71 @@ function Home({ navigate }) {
</button>
</div>
{hasActivity && (
<div className="launcher-activity">
{(failedCount > 0 || errCount > 0) && (
<div className="launcher-activity-strip alert">
<Icon name="alert" size={14} />
<span>
{errCount > 0 && <strong>{errCount} recorder{errCount === 1 ? '' : 's'} in error.</strong>}
{errCount > 0 && failedCount > 0 && ' '}
{failedCount > 0 && <strong>{failedCount} failed job{failedCount === 1 ? '' : 's'}.</strong>}
</span>
<button className="btn ghost sm" onClick={() => navigate('dashboard')}>Open Dashboard</button>
</div>
)}
{liveRecorders.length > 0 && (
<div className="launcher-activity-section">
<div className="launcher-activity-head">
<span className="rec-dot" />
Recording now
<span className="muted">{liveRecorders.length} live</span>
<div style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => navigate('monitors')}>Monitors</button>
</div>
<div className="launcher-activity-grid">
{liveRecorders.map(r => (
<button key={r.id} className="launcher-activity-item" onClick={() => navigate('recorders')}>
<span className="badge live">REC</span>
<span className="launcher-activity-item-name">{r.name}</span>
<span className="launcher-activity-item-meta mono">{r.source_type || 'sdi'}</span>
</button>
))}
</div>
</div>
)}
{recentAssets.length > 0 && (
<div className="launcher-activity-section">
<div className="launcher-activity-head">
<Icon name="library" size={12} />
Last 24 hours
<span className="muted">{recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}</span>
<div style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => navigate('library')}>Library</button>
</div>
<div className="launcher-activity-grid">
{recentAssets.map(a => (
<button key={a.id} className="launcher-activity-item" onClick={() => navigate('library')}>
<Icon name={a.media_type === 'audio' ? 'audio' : 'video'} size={13} />
<span className="launcher-activity-item-name">{a.display_name || a.filename || 'untitled'}</span>
<span className="launcher-activity-item-meta mono">
{(() => {
const mins = Math.round((Date.now() - new Date(a.created_at)) / 60000);
if (mins < 60) return mins + 'm';
const h = Math.round(mins / 60);
return h + 'h';
})()}
</span>
</button>
))}
</div>
</div>
)}
</div>
)}
<div className="launcher-status">
<span className="launcher-status-pip">
<span
@ -161,18 +266,117 @@ function Home({ navigate }) {
)}
</div>
</div>
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
</div>
);
}
// Modal listing all downloads: the Premiere Pro UXP plugin (.ccx, one per
// released version, sourced from window.PREMIERE_RELEASES written by the
// Settings SDKs section in screens-admin.jsx) plus the Teams ISO installer
// (window.TEAMS_ISO; the .exe slot is wired but the file may still be pending).
function DownloadsModal({ onClose }) {
const teamsIso = window.TEAMS_ISO || {};
const releases = (window.PREMIERE_RELEASES || []).slice().sort((a, b) => {
// Newest first; fall back to lexicographic compare on version string.
const av = String(a.version || ''), bv = String(b.version || '');
return bv.localeCompare(av, undefined, { numeric: true });
});
const latest = window.PREMIERE_LATEST || releases[0] || null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 560 }}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Downloads</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>
The Premiere Pro (UXP) plugin and the Teams ISO installer. Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed.
</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="premiere-release">
<div className="premiere-release-head">
<span className="premiere-release-version mono">Teams ISO</span>
{teamsIso.version && (
<span className="premiere-release-date mono">v{teamsIso.version}</span>
)}
</div>
<div className="premiere-release-notes">
Windows installer for the Teams ISO workstation build.
</div>
<div className="premiere-release-actions">
{teamsIso.available && teamsIso.url ? (
<a href={teamsIso.url} download className="btn primary sm">
<Icon name="download" />Teams ISO (.exe)
</a>
) : (
<>
<span className="btn primary sm" aria-disabled="true" style={{ opacity: 0.5, pointerEvents: 'none' }}>
<Icon name="download" />Teams ISO (.exe)
</span>
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>coming soon file pending</span>
</>
)}
</div>
</div>
{releases.length === 0 && (
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12 }}>
No releases registered yet. Upload one from Settings Capture SDKs.
</div>
)}
{releases.map((rel, i) => (
<div key={rel.version || i} className="premiere-release">
<div className="premiere-release-head">
<span className="premiere-release-version mono">v{rel.version}</span>
{latest && latest.version === rel.version && (
<span className="badge accent">LATEST</span>
)}
{rel.released_at && (
<span className="premiere-release-date mono">
{new Date(rel.released_at).toLocaleDateString()}
</span>
)}
</div>
{rel.notes && <div className="premiere-release-notes">{rel.notes}</div>}
<div className="premiere-release-actions">
{rel.ccx && (
<a href={rel.ccx} download className="btn primary sm">
<Icon name="download" />UXP plugin (.ccx)
</a>
)}
{rel.installer && (
<a href={rel.installer} download className="btn ghost sm">
<Icon name="download" />Windows installer
</a>
)}
</div>
</div>
))}
</div>
<div className="modal-foot">
<div style={{ flex: 1, fontSize: 11.5, color: 'var(--text-3)' }}>
Need help installing? Use the Adobe Extension Manager or UPIA.
</div>
<button className="btn ghost" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}
//
// Dashboard broadcast-ops control board
// Dashboard - broadcast-ops control board
//
function Dashboard({ navigate }) {
const { RECORDERS, JOBS, NODES } = window.ZAMPP_DATA;
// Live state recompute every second so elapsed timers keep ticking.
// Live state - recompute every second so elapsed timers keep ticking.
const [tick, setTick] = React.useState(0);
React.useEffect(() => {
const i = setInterval(() => setTick(t => t + 1), 1000);
@ -193,7 +397,7 @@ function Dashboard({ navigate }) {
return () => { cancelled = true; clearInterval(t); };
}, []);
// Refresh jobs frequently this screen is the failed-job alert surface.
// Refresh jobs frequently - this screen is the failed-job alert surface.
const [jobs, setJobs] = React.useState(JOBS);
React.useEffect(() => {
let cancelled = false;
@ -209,8 +413,8 @@ function Dashboard({ navigate }) {
...j,
status: statusMap[j.status] || j.status,
kind: kindMap[j.type] || j.type || 'Job',
asset: j.asset_name || meta.filename || '',
node: meta.node || '',
asset: j.asset_name || meta.filename || '·',
node: meta.node || '·',
error: j.error || null,
progress: j.progress || 0,
};
@ -244,6 +448,22 @@ function Dashboard({ navigate }) {
return (
<div className="page dash">
<div className="page-header">
<h1>Dashboard</h1>
<span className="subtitle">Live operations: on-air recorders, jobs, cluster health</span>
<div className="spacer" />
{hasAttention && (
<span className="badge danger" title="Items need attention">
<Icon name="alert" size={10} />
{failedJobs.length + offlineNodes.length + erroredRecorders.length} alert{failedJobs.length + offlineNodes.length + erroredRecorders.length === 1 ? '' : 's'}
</span>
)}
<span className="status-pip">
<span className="dot" style={{ background: offlineNodes.length === 0 ? 'var(--success)' : 'var(--warning)' }} />
<span>{onlineNodes}/{NODES.length || 0} nodes online</span>
</span>
</div>
{/* ────────── ON AIR ────────── */}
<section className="dash-section">
<DashSectionHead
@ -325,7 +545,7 @@ function Dashboard({ navigate }) {
level="danger"
icon="alert"
title={j.kind + ' failed'}
detail={(j.asset || '') + (j.error ? ' · ' + j.error.slice(0, 100) : '')}
detail={(j.asset || '·') + (j.error ? ' · ' + j.error.slice(0, 100) : '')}
onClick={() => navigate('jobs')}
/>
))}
@ -403,6 +623,18 @@ function Dashboard({ navigate }) {
</div>
</section>
{/* ────────── RESOURCES ────────── */}
<section className="dash-section">
<DashSectionHead title="Resources" />
{window.ClusterResources && <window.ClusterResources />}
</section>
{/* ────────── RESOURCES ────────── */}
<section className="dash-section">
<DashSectionHead title="Resources" />
{window.ClusterResources && React.createElement(window.ClusterResources)}
</section>
{/* ────────── STATUS BAR (bottom) ────────── */}
<footer className="dash-statusbar">
<span className="dash-stat-pip" data-tone={liveRecorders.length > 0 ? 'live' : 'idle'}>
@ -480,7 +712,7 @@ function OnAirTile({ recorder, onClick }) {
<div className="dash-onair-tile" onClick={onClick}>
<div className="dash-onair-video">
{recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} />
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
: <FauxFrame />}
<span className="dash-onair-rec-pip">
<span className="dash-onair-rec-dot" />
@ -491,14 +723,14 @@ function OnAirTile({ recorder, onClick }) {
<div className="dash-onair-meta">
<div className="dash-onair-name">{recorder.name}</div>
<div className="dash-onair-sub">
<span className="dash-onair-source">{recorder.source || ''}</span>
{recorder.res && recorder.res !== '' && (
<span className="dash-onair-source">{recorder.source || '·'}</span>
{recorder.res && recorder.res !== '·' && (
<>
<span className="dash-onair-dot">·</span>
<span className="dash-onair-res">{recorder.res}</span>
</>
)}
{recorder.codec && recorder.codec !== '' && (
{recorder.codec && recorder.codec !== '·' && (
<>
<span className="dash-onair-dot">·</span>
<span className="dash-onair-codec">{recorder.codec}</span>
@ -620,7 +852,7 @@ function DashClusterRow({ node }) {
</span>
<span className="dash-cluster-val">{Math.round(cpuPct)}%</span>
</>
) : <span className="dash-cluster-val muted"></span>}
) : <span className="dash-cluster-val muted">·</span>}
</span>
<span className="dash-cluster-metric">
{memPct != null ? (
@ -636,7 +868,7 @@ function DashClusterRow({ node }) {
</span>
<span className="dash-cluster-val">{memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'}</span>
</>
) : <span className="dash-cluster-val muted"></span>}
) : <span className="dash-cluster-val muted">·</span>}
</span>
</div>
);

View file

@ -1,4 +1,4 @@
// screens-ingest.jsx Upload, Recorders, Capture, Monitors
// screens-ingest.jsx - Upload, Recorders, Capture, Monitors
/* ===== Upload helpers ===== */
const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB simple upload
@ -38,7 +38,7 @@ async function _uploadFile(file, projectId, onProgress) {
(loaded, total) => onProgress(Math.round((loaded / total) * 100)));
}
// Multipart
// - Multipart -
const init = await window.ZAMPP_API.fetch('/upload/init', {
method: 'POST',
body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
@ -106,7 +106,7 @@ function Upload({ navigate }) {
<div className="page">
<div className="page-header">
<h1>Upload</h1>
<span className="subtitle">Drop video, audio, or stills we proxy and index automatically.</span>
<span className="subtitle">Drop video, audio, or stills: we proxy and index automatically.</span>
</div>
<div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
@ -227,14 +227,32 @@ function YouTubeImport({ navigate }) {
window.dispatchEvent(new CustomEvent('df:assets-changed'));
} else if (asset.status === 'error') {
patch.status = 'error';
patch.error = patch.error || 'Import failed check the Jobs screen for details.';
patch.error = patch.error || 'Import failed: check the Jobs screen for details.';
} else if (asset.status === 'processing') {
patch.status = 'processing';
} else if (asset.status === 'ingesting') {
patch.status = 'downloading';
}
// While the import is still running, pull the live percentage from its
// BullMQ job so the bar reflects the actual yt-dlp download instead of
// sitting at 0 until the asset flips to ready. The worker emits 2..100
// across the download + S3 upload (job.updateProgress). If the job has
// already completed and been evicted, the fetch throws and we just fall
// back to the status-driven values above.
if (row.jobId && asset.status !== 'ready' && asset.status !== 'error') {
try {
const job = await window.ZAMPP_API.fetch('/jobs/' + row.jobId);
if (typeof job.progress === 'number') patch.progress = job.progress;
} catch { /* job evicted after completion — fall back to status */ }
}
if (Object.keys(patch).length) updateRow(row.id, patch);
if (asset.status === 'ready' || asset.status === 'error') return;
} catch { /* ignore */ }
setTimeout(tick, 3000);
// Poll fairly briskly: the download phase is where the user wants to see
// the bar move, and a short clip can finish in a handful of seconds.
setTimeout(tick, 2000);
};
tick();
return () => { stopped = true; };
@ -274,7 +292,7 @@ function YouTubeImport({ navigate }) {
<div className="page">
<div className="page-header">
<h1>YouTube</h1>
<span className="subtitle">Paste a link we download and import the best available MP4.</span>
<span className="subtitle">Paste a link: we download and import the best available MP4.</span>
</div>
<div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
@ -384,13 +402,15 @@ function YouTubeImport({ navigate }) {
and nginx.conf); we attach hls.js to a <video> when a recorder is
actively recording and has a live asset.
============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
function HlsPreview({ assetId, recorderId, muted = true, controls = false, className }) {
const videoRef = React.useRef(null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!assetId || !videoRef.current) return;
const url = '/live/' + assetId + '/index.m3u8';
const url = recorderId
? '/api/v1/recorders/' + recorderId + '/live/' + assetId + '/index.m3u8'
: '/live/' + assetId + '/index.m3u8';
const v = videoRef.current;
let destroyed = false;
let retryTimer = 0;
@ -471,7 +491,7 @@ function HlsPreview({ assetId, muted = true, controls = false, className }) {
/* ===== Recorders ===== */
function _normRecorder(r) {
let elapsed = '';
let elapsed = '·';
if (r.status === 'recording' && r.started_at) {
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
@ -481,13 +501,13 @@ function _normRecorder(r) {
const cfg = r.source_config || {};
return {
...r,
source: r.source_type || '',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '',
codec: r.recording_codec || '',
res: r.recording_resolution || '',
source: r.source_type || '·',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
codec: r.recording_codec || '·',
res: r.recording_resolution || '·',
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
elapsed,
bitrate: '',
bitrate: '·',
health: 100,
audio: false,
};
@ -504,7 +524,7 @@ function Recorders({ navigate, onNew }) {
setRecorders(norm);
})
.catch(err => {
// apiFetch already redirects on 401 don't log noise, interval
// apiFetch already redirects on 401 - don't log noise, interval
// will be cleared automatically when the component unmounts on redirect (#55)
if (err && err.message && err.message.includes('Unauthenticated')) return;
window.DF_LOG.warn('[recorders] poll error:', err?.message);
@ -559,13 +579,21 @@ function Recorders({ navigate, onNew }) {
}
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [recorder, setRecorder] = React.useState(initialRecorder);
const [pending, setPending] = React.useState(false);
const [err, setErr] = React.useState(null);
const [liveStatus, setLiveStatus] = React.useState(null);
const [clipName, setClipName] = React.useState('');
// Project override for this take. Defaults to the recorder's configured project.
const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
const isRec = recorder.status === 'recording';
// Keep takeProjectId in sync if the recorder row changes (e.g. after a refresh).
React.useEffect(() => {
setTakeProjectId(initialRecorder.project_id || PROJECTS[0]?.id || '');
}, [initialRecorder.id]);
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
// Poll the status endpoint every 3s while recording for live feedback.
@ -592,8 +620,8 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
}, [liveStatus, recorder.elapsed]);
const displaySignal = liveStatus
? (liveStatus.signal || '')
: (isRec ? 'connecting…' : '');
? (liveStatus.signal || '·')
: (isRec ? 'connecting…' : '·');
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
: displaySignal === 'stopped' ? 'var(--danger)'
@ -605,14 +633,18 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
setPending(true);
setErr(null);
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
// Ship the operator-typed clip name on start; stop has no body.
const body = (action === 'start' && clipName.trim())
? JSON.stringify({ clipName: clipName.trim() })
// Ship the operator-typed clip name and project override on start; stop has no body.
const body = action === 'start'
? JSON.stringify({
...(clipName.trim() ? { clipName: clipName.trim() } : {}),
...(takeProjectId ? { projectId: takeProjectId } : {}),
})
: undefined;
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
.then(() => {
setPending(false);
// Clear the input on a successful stop so the next take starts fresh.
// Clear the clip name on a successful stop so the next take starts fresh.
// Leave takeProjectId as-is (operator likely wants the same project for the next take).
if (action === 'stop') setClipName('');
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
@ -640,7 +672,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
<div className={'recorder-row ' + recorder.status}>
<div className="recorder-preview">
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} />
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
: isRec
? <LiveStrip seed={recorder.id.length * 3} count={6} />
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
@ -684,6 +716,19 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
</div>
<div className="recorder-actions">
{!isRec && (
<>
{PROJECTS.length > 0 && (
<select
className="field-input"
value={takeProjectId}
onChange={e => setTakeProjectId(e.target.value)}
disabled={pending}
style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
title="Project clips go to"
>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
<input
className="field-input"
value={clipName}
@ -692,9 +737,10 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
disabled={pending}
maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
style={{ width: 160, padding: '5px 8px', fontSize: 12 }}
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/>
</>
)}
{isRec
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
@ -725,7 +771,7 @@ function _captureSignalChip(sig) {
case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false };
case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false };
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false };
default: return { label: sig || '', color: 'var(--text-4)', pulse: false };
default: return { label: sig || '·', color: 'var(--text-4)', pulse: false };
}
}
@ -737,7 +783,7 @@ function CapturePortChip({ port, sigEntry }) {
return (
<div
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} ${label}` : label}
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 5,
@ -1031,7 +1077,7 @@ function MonitorTile({ feed, seed }) {
return (
<div className="monitor-tile">
{isLive && feed.live_asset_id
? <HlsPreview assetId={feed.live_asset_id} />
? <HlsPreview assetId={feed.live_asset_id} recorderId={feed.id} />
: <FauxFrame />}
{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
@ -1048,7 +1094,7 @@ function MonitorTile({ feed, seed }) {
)}
<div className="monitor-tile-label">
<span className="name">{feed.name}</span>
{feed.elapsed && feed.elapsed !== '' && <span className="time mono">{feed.elapsed}</span>}
{feed.elapsed && feed.elapsed !== '·' && <span className="time mono">{feed.elapsed}</span>}
</div>
</div>
);
@ -1065,7 +1111,7 @@ const _STATUS_BADGE = {
};
function _fmtWhen(iso) {
if (!iso) return '';
if (!iso) return '·';
const d = new Date(iso);
// Local-time, short, human; e.g. "May 22 · 7:30 PM"
return d.toLocaleString(undefined, {
@ -1309,7 +1355,7 @@ function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, on
const d = drag;
setDrag(null);
if (!d.moved) {
// Treat as a click open the edit modal.
// Treat as a click - open the edit modal.
onClick(event);
return;
}
@ -1463,7 +1509,7 @@ function _RecorderGutter({ recorders, projects }) {
<span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
<div className="epg-gutter-meta">
<div className="epg-gutter-name">{r.name}</div>
<div className="epg-gutter-sub mono">{(r.source_type || '').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
<div className="epg-gutter-sub mono">{(r.source_type || '·').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
</div>
</div>
);
@ -1491,7 +1537,7 @@ function Schedule({ navigate }) {
return () => clearInterval(id);
}, []);
// Schedule data pull everything once and filter client-side for the
// Schedule data - pull everything once and filter client-side for the
// active view. /schedules caps at 200 rows so this stays cheap.
const apiFilter = view === 'list' ? listFilter : 'all';
const load = React.useCallback(() => {
@ -1523,7 +1569,7 @@ function Schedule({ navigate }) {
const projects = window.ZAMPP_DATA?.PROJECTS || [];
// Pixels per hour wider on Today (high-res operations view), tighter
// Pixels per hour - wider on Today (high-res operations view), tighter
// when the user is scanning Week-at-a-glance.
const pph = view === 'week' ? 44 : 88;
@ -1578,7 +1624,7 @@ function Schedule({ navigate }) {
};
const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
// Dismiss the context menu on any outside click capture phase so a
// Dismiss the context menu on any outside click - capture phase so a
// click on a menu item still fires before the menu unmounts.
React.useEffect(() => {
if (!ctxMenu) return;
@ -1833,7 +1879,7 @@ function EditScheduleModal({ schedule, onClose, onSaved }) {
<label className="field-label">Recorder</label>
<input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
style={{ color: 'var(--text-3)' }} />
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned delete + recreate to change.</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned: delete + recreate to change.</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
@ -1902,11 +1948,11 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
const endD = new Date(form.end_at);
if (endD <= startD) return setErr('End must be after start');
// Warn (but allow) start times in the past the scheduler tick will fire
// Warn (but allow) start times in the past - the scheduler tick will fire
// them immediately, which is occasionally what the operator wants
// (e.g. "record the next 30 minutes starting now").
if (startD < new Date(Date.now() - 60_000)) {
if (!confirm('Start time is in the past recorder will fire immediately when saved.\nContinue?')) return;
if (!confirm('Start time is in the past: recorder will fire immediately when saved.\nContinue?')) return;
}
// Datetime-local inputs are in the browser's local zone; ship as ISO so
@ -1946,7 +1992,7 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
<select className="field-input" value={form.recorder_id}
onChange={e => set('recorder_id', e.target.value)}
style={{ appearance: 'auto' }}>
{recorders.length === 0 && <option value=""> No recorders defined </option>}
{recorders.length === 0 && <option value="">No recorders defined</option>}
{recorders.map(r => (
<option key={r.id} value={r.id}>
{r.name} · {r.source_type?.toUpperCase() || '?'}

View file

@ -1,7 +1,7 @@
// screens-jobs.jsx
// Pick the most-meaningful timestamp + label for a job's current state.
// Returns { label, iso } caller renders "<label> <relative-time>" with
// Returns { label, iso } - caller renders "<label> <relative-time>" with
// the full ISO as a tooltip.
function _jobTimeFor(job) {
if (job.status === 'done' && job.completed_at) return { label: 'done', iso: job.completed_at };
@ -21,7 +21,7 @@ function _fmtAbsolute(iso) {
} catch { return iso; }
}
// Compact clock for the inline jobs cell "2:23 PM" if today,
// Compact clock for the inline jobs cell - "2:23 PM" if today,
// "May 22 · 2:23 PM" if a different day. Full datetime stays in the tooltip.
function _fmtCompact(iso) {
if (!iso) return '';
@ -51,9 +51,9 @@ function Jobs({ navigate }) {
...j,
status: statusMap[j.status] || j.status,
kind: kindMap[j.type] || j.type || 'Job',
asset: j.asset_name || meta.filename || '',
eta: '',
node: meta.node || '',
asset: j.asset_name || meta.filename || '·',
eta: '·',
node: meta.node || '·',
priority: meta.priority || 'normal',
error: j.error || null,
progress: j.progress || 0,
@ -83,7 +83,7 @@ function Jobs({ navigate }) {
}, [refresh]);
// One handler covers cancel (running) AND delete (queued / done / failed).
// BullMQ's job.remove() what the API calls works on any state, so a
// BullMQ's job.remove() - what the API calls - works on any state, so a
// stalled-active job (worker died mid-process, holding a concurrency slot)
// gets yanked and the next queued job runs. mode just changes the prompt
// copy so the operator knows what they're doing.
@ -98,7 +98,7 @@ function Jobs({ navigate }) {
}, []);
// Retry every failed job at once. Useful after a transient infra issue
// (S3 outage, hung worker) one click per job is painful with 20+ failures.
// (S3 outage, hung worker) - one click per job is painful with 20+ failures.
const handleRetryAll = React.useCallback(() => {
const failedJobs = jobs.filter(j => j.status === 'failed');
if (failedJobs.length === 0) return;
@ -108,6 +108,23 @@ function Jobs({ navigate }) {
).then(refresh);
}, [jobs, refresh]);
// Drop every failed job from the queue. The opposite of Retry all used
// when a batch of jobs is unrecoverable (e.g. assets that were deleted
// mid-encode) and the operator just wants the queue cleared.
const handleCancelAll = React.useCallback(() => {
const failedJobs = jobs.filter(j => j.status === 'failed');
if (failedJobs.length === 0) return;
if (!window.confirm(`Remove all ${failedJobs.length} failed jobs from the queue?\nThis cannot be undone.`)) return;
Promise.allSettled(
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id, { method: 'DELETE' }))
).then(() => {
// Optimistic local drop so the UI updates the instant the modal closes,
// not 5s later on the next poll tick.
setJobs(prev => prev.filter(j => j.status !== 'failed'));
refresh();
});
}, [jobs, refresh]);
const counts = {
all: jobs.length,
running: jobs.filter(j => j.status === 'running').length,
@ -124,9 +141,14 @@ function Jobs({ navigate }) {
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>
<div className="spacer" />
{counts.failed > 0 && (
<>
<button className="btn ghost sm" onClick={handleRetryAll} title={`Retry all ${counts.failed} failed jobs`}>
<Icon name="refresh" />Retry all failed
</button>
<button className="btn ghost sm jobs-cancel-all" onClick={handleCancelAll} title={`Remove all ${counts.failed} failed jobs from the queue`}>
<Icon name="x" />Cancel all failed
</button>
</>
)}
<button className="btn ghost sm" onClick={refresh}>
<Icon name="refresh" />Refresh
@ -148,18 +170,18 @@ function Jobs({ navigate }) {
<div className="stat-card">
<div className="label">Failed</div>
<div className="value">{counts.failed}</div>
<div className="delta" style={{ color: counts.failed > 0 ? 'var(--warning)' : '' }}>
<div className={'delta' + (counts.failed > 0 ? ' delta-warn' : '')}>
{counts.failed > 0 ? 'Needs attention' : 'All clear'}
</div>
</div>
<div className="stat-card">
<div className="label">Total jobs</div>
<div className="value">{counts.all}</div>
<div className="delta muted" style={{ fontSize: 10.5 }}>Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
<div className="delta muted delta-tiny">Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
</div>
</div>
<div className="tab-group" style={{ marginTop: 20, width: 'fit-content' }}>
<div className="tab-group jobs-tabs">
{[
{ id: 'all', label: 'All · ' + counts.all },
{ id: 'running', label: 'Running · ' + counts.running },
@ -171,12 +193,12 @@ function Jobs({ navigate }) {
))}
</div>
<div className="panel" style={{ marginTop: 12 }}>
<div className="panel jobs-panel">
<div className="job-row head">
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>Time</div><div>Priority</div><div></div>
</div>
{filtered.length === 0
? <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)' }}>No jobs in this category.</div>
? <div className="jobs-empty">No jobs in this category.</div>
: filtered.map(j => <JobRow key={j.id} job={j} onRetry={handleRetry} onDelete={handleDelete} />)}
</div>
</div>
@ -189,36 +211,35 @@ function JobRow({ job, onRetry, onDelete }) {
return (
<div className="job-row">
<div><StatusDot status={job.status} /></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Icon name={iconMap[job.kind] || 'jobs'} size={13} style={{ color: 'var(--text-3)' }} />
<span style={{ fontWeight: 500 }}>{job.kind}</span>
<div className="job-row-kind">
<Icon name={iconMap[job.kind] || 'jobs'} size={13} className="job-row-kind-icon" />
<span className="job-row-kind-name">{job.kind}</span>
</div>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-2)' }}>{job.asset}</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{job.node}</div>
<div className="job-row-asset">{job.asset}</div>
<div className="mono job-row-node">{job.node}</div>
<div>
{job.status === 'running' && (
<div className="job-progress-wrap">
<div className="job-progress-bar"><div className="job-progress-fill" style={{ width: job.progress + '%' }} /></div>
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', minWidth: 32, textAlign: 'right' }}>{Math.round(job.progress)}%</span>
<span className="mono job-row-progress-pct">{Math.round(job.progress)}%</span>
</div>
)}
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>}
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting</span>}
{job.status === 'done' && <span className="badge success job-row-status-done"><Icon name="check" size={12} /> Complete</span>}
{job.status === 'queued' && <span className="job-row-status-queued">Waiting</span>}
{job.status === 'failed' && (
<span title={job.error || 'Failed'}
style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
<span title={job.error || 'Failed'} className="job-row-status-failed">
<Icon name="alert" size={12} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
<span className="job-row-status-failed-msg">
{(job.error || 'Failed').slice(0, 120)}
</span>
</span>
)}
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
<div className="mono job-row-time"
title={(() => { const t = _jobTimeFor(job); return t ? t.label + ' at ' + _fmtAbsolute(t.iso) : ''; })()}>
{(() => {
const t = _jobTimeFor(job);
if (!t) return '';
if (!t) return '·';
// Terminal states (done/failed) anchor on the absolute clock so the
// operator can correlate with logs; queued/running show relative
// since it's a moving target.
@ -229,16 +250,16 @@ function JobRow({ job, onRetry, onDelete }) {
})()}
</div>
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<div className="job-row-actions">
{job.status === 'failed' && (
<button className="btn ghost sm" onClick={() => onRetry(job)}><Icon name="refresh" />Retry</button>
)}
{job.status === 'running' && (
/* Cancel a stalled-active job frees the BullMQ concurrency slot
/* Cancel a stalled-active job: frees the BullMQ concurrency slot
so anything queued behind it can run. The worker may finish in
the background but its result is discarded. */
<button className="btn ghost sm" onClick={() => onDelete(job, 'cancel')}
style={{ color: 'var(--danger)' }} title="Cancel this running job and free its queue slot">
<button className="btn ghost sm job-row-cancel" onClick={() => onDelete(job, 'cancel')}
title="Cancel this running job and free its queue slot">
<Icon name="x" />Cancel
</button>
)}

View file

@ -46,7 +46,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
const [search, setSearch] = React.useState(window._dfPendingSearch || '');
React.useEffect(() => { delete window._dfPendingSearch; }, []);
// Local state lets us re-render after delete / move-to-bin without forcing
// a full app reload keeps ZAMPP_DATA in sync as the cache of record.
// a full app reload - keeps ZAMPP_DATA in sync as the cache of record.
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
const [renamingAsset, setRenamingAsset] = React.useState(null);
@ -65,7 +65,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
runDownload(asset);
return;
}
} catch (_) { /* localStorage unavailable show modal to be safe */ }
} catch (_) { /* localStorage unavailable: show modal to be safe */ }
setPendingDownload(asset);
};
const [creatingBin, setCreatingBin] = React.useState(false);
@ -273,7 +273,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
)}
{!creatingBin && BINS.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
{openProject ? 'No bins yet click + to create one.' : 'Open a project to manage bins.'}
{openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
</div>
) : BINS.map(function(b) {
const isActive = selectedBinId === b.id;
@ -307,7 +307,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
<div className="library-main">
<div className="library-toolbar">
<div className="toolbar-title">{displayTitle}</div>
<h1 className="toolbar-title">{displayTitle}</h1>
<span className="count">· {assets.length} assets</span>
<div style={{ flex: 1 }} />
<div className="search" style={{ width: 220 }}>
@ -324,8 +324,8 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
})}
</div>
<div className="tab-group">
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
</div>
<button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
</div>
@ -362,7 +362,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
</div>
<div className="col-sub">{a.duration}</div>
<div className="col-sub">{a.res}</div>
<div className="col-sub">{a.codec || ''}</div>
<div className="col-sub">{a.codec || '·'}</div>
<div className="col-sub">{a.size}</div>
<div className="col-sub">{a.updated}</div>
<button className="icon-btn" aria-label="Asset actions" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>
@ -447,7 +447,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
// Hi-res download trigger shared by the card and the context menu. Resolves
// a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext })
// and clicks a hidden anchor so the browser does the download. The download
// itself is direct S3 never proxied through mam-api so big files don't
// itself is direct S3 - never proxied through mam-api - so big files don't
// touch the API container.
function runDownload(asset) {
if (!asset || !asset.id) return;
@ -536,7 +536,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
)}
</>
) : (
<div className="ctx-empty">No bins create one inside a project</div>
<div className="ctx-empty">No bins: create one inside a project</div>
)}
<div className="ctx-divider" />
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
@ -606,14 +606,14 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
}}
/>
)}
{/* Status badges and duration inside the relative wrapper so
{/* Status badges and duration: inside the relative wrapper so
position:absolute is anchored to the thumbnail, not the card (#52) */}
<div className="thumb-status">
{asset.status === 'live' && <span className="badge live">LIVE</span>}
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
{asset.status === 'error' && <span className="badge danger">Error</span>}
</div>
{/* Hi-res download trigger only shown when the asset has an
{/* Hi-res download trigger: only shown when the asset has an
original_s3_key (everything queued through ingest / conform).
Hidden until card hover, lives in top-right of the thumb. */}
{asset.original_s3_key && onDownload && (
@ -625,7 +625,7 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
<Icon name="download" size={12} />
</button>
)}
{(asset.type === 'video' || !asset.type) && asset.duration !== '' && <div className="thumb-duration">{asset.duration}</div>}
{(asset.type === 'video' || !asset.type) && asset.duration !== '·' && <div className="thumb-duration">{asset.duration}</div>}
</div>
<div className="meta">
<div className="name">{asset.name}</div>

View file

@ -0,0 +1,460 @@
// screens-playout.jsx Master Control (MCR) playout page.
//
// Operator workflow (Phase A playlist player):
// 1. Create / pick a channel (output target: SRT / RTMP / NDI / DeckLink).
// 2. Start the channel spawns the CasparCG sidecar, brings up the output.
// 3. Drag assets from the media bin into the playlist; reorder by dragging.
// Each item stages from S3 to the CasparCG /media volume in the background.
// 4. Hit PLAY the engine walks the playlist gaplessly. PAUSE / SKIP / STOP
// transport. As-run log records what aired.
//
// Talks to /api/v1/playout via window.ZAMPP_API.fetch. Native HTML5 drag-drop,
// no extra library. Components are plain globals (esbuild bundle:false).
const PO_OUTPUTS = [
{ value: 'srt', label: 'SRT' },
{ value: 'rtmp', label: 'RTMP' },
{ value: 'ndi', label: 'NDI' },
{ value: 'decklink', label: 'SDI (DeckLink)' },
];
const PO_FORMATS = ['1080p5994', '1080i5994', '1080p2997', '720p5994', '1080i50', '1080p25'];
async function poFetch(path, opts) {
return window.ZAMPP_API.fetch('/playout' + path, opts);
}
// Output-config sub-form (varies by output type)
function OutputConfigFields({ type, config, onChange }) {
const set = (k, v) => onChange({ ...config, [k]: v });
if (type === 'decklink') {
return (
<div className="field">
<label className="field-label">DeckLink device index</label>
<input className="field-input" type="number" min="1" value={config.device_index || 1}
onChange={e => set('device_index', parseInt(e.target.value, 10) || 1)} />
</div>
);
}
if (type === 'ndi') {
return (
<div className="field">
<label className="field-label">NDI source name</label>
<input className="field-input" value={config.ndi_name || ''} placeholder="DRAGONFLIGHT CH1"
onChange={e => set('ndi_name', e.target.value)} />
</div>
);
}
// srt / rtmp
return (
<React.Fragment>
<div className="field">
<label className="field-label">{type.toUpperCase()} URL</label>
<input className="field-input mono" value={config.url || ''}
placeholder={type === 'srt' ? 'srt://host:9000' : 'rtmp://host/live'}
onChange={e => set('url', e.target.value)} />
</div>
{type === 'rtmp' && (
<div className="field">
<label className="field-label">Stream key</label>
<input className="field-input mono" value={config.key || ''}
onChange={e => set('key', e.target.value)} />
</div>
)}
{type === 'srt' && (
<div className="field">
<label className="field-label">Latency (ms)</label>
<input className="field-input" type="number" value={config.latency || 200}
onChange={e => set('latency', parseInt(e.target.value, 10) || 200)} />
</div>
)}
</React.Fragment>
);
}
// Channel create modal
function ChannelCreate({ onClose, onCreated }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [name, setName] = React.useState('');
const [outputType, setOutputType] = React.useState('srt');
const [config, setConfig] = React.useState({});
const [videoFormat, setVideoFormat] = React.useState('1080i5994');
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = async () => {
setBusy(true); setErr(null);
try {
const ch = await poFetch('/channels', {
method: 'POST',
body: JSON.stringify({
name, output_type: outputType, output_config: config,
video_format: videoFormat, project_id: projectId || null,
}),
});
onCreated(ch);
} catch (e) { setErr(e.message || 'Failed to create channel'); }
finally { setBusy(false); }
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 460 }}>
<div className="modal-header"><h3>New Playout Channel</h3></div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" value={name} autoFocus
onChange={e => setName(e.target.value)} placeholder="Channel 1" />
</div>
<div className="field">
<label className="field-label">Output</label>
<select className="field-input" value={outputType}
onChange={e => { setOutputType(e.target.value); setConfig({}); }}>
{PO_OUTPUTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<OutputConfigFields type={outputType} config={config} onChange={setConfig} />
<div className="field">
<label className="field-label">Video format</label>
<select className="field-input" value={videoFormat} onChange={e => setVideoFormat(e.target.value)}>
{PO_FORMATS.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Project (RBAC scope)</label>
<select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}>
<option value=""> admin only </option>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{err && <div className="alert error">{err}</div>}
</div>
<div className="modal-footer">
<button className="btn ghost" onClick={onClose}>Cancel</button>
<button className="btn primary" disabled={busy || !name} onClick={submit}>
{busy ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
);
}
// Media bin: assets draggable into the playlist
function MediaBin({ projectId }) {
const ASSETS = (window.ZAMPP_DATA?.ASSETS || []).filter(a =>
!projectId || a.project_id === projectId);
const [q, setQ] = React.useState('');
const filtered = ASSETS.filter(a => !q || (a.name || '').toLowerCase().includes(q.toLowerCase()));
const onDragStart = (e, asset) => {
e.dataTransfer.setData('application/x-df-asset', JSON.stringify({ id: asset.id, name: asset.name }));
e.dataTransfer.effectAllowed = 'copy';
};
return (
<div className="panel po-bin">
<div className="po-bin-head">
<span className="po-section-label">Media Bin</span>
<input className="field-input sm" placeholder="Filter…" value={q}
onChange={e => setQ(e.target.value)} style={{ maxWidth: 160 }} />
</div>
<div className="po-bin-list">
{filtered.length === 0 && <div className="muted" style={{ padding: 12 }}>No assets.</div>}
{filtered.map(a => (
<div key={a.id} className="po-bin-item" draggable
onDragStart={e => onDragStart(e, a)} title="Drag into the playlist">
<span className="po-bin-name">{a.name}</span>
<span className="mono muted" style={{ fontSize: 11 }}>{a.duration || ''}</span>
</div>
))}
</div>
</div>
);
}
const MEDIA_STATUS_BADGE = {
ready: 'success', staging: 'warn', pending: 'neutral', error: 'error',
};
// Playlist: ordered, drag-drop reorder, drop-target for bin assets
function Playlist({ channel, playlistId, items, onReload }) {
const [dragIndex, setDragIndex] = React.useState(null);
const onItemDragStart = (e, index) => {
setDragIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const onItemDragOver = (e) => { e.preventDefault(); };
const onItemDrop = async (e, index) => {
e.preventDefault();
// Asset dropped from the bin append.
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (assetRaw) {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
return;
}
// Reorder within the playlist.
if (dragIndex === null || dragIndex === index) return;
const order = items.map(i => i.id);
const [moved] = order.splice(dragIndex, 1);
order.splice(index, 0, moved);
setDragIndex(null);
await poFetch('/playlists/' + playlistId + '/reorder', {
method: 'PUT', body: JSON.stringify({ order }),
});
onReload();
};
// Dropping onto empty area appends.
const onContainerDrop = async (e) => {
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (!assetRaw) return;
e.preventDefault();
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
};
const removeItem = async (id) => {
await poFetch('/items/' + id, { method: 'DELETE' });
onReload();
};
const restage = async (id) => {
await poFetch('/items/' + id + '/stage', { method: 'POST' });
onReload();
};
return (
<div className="panel po-playlist" onDragOver={e => e.preventDefault()} onDrop={onContainerDrop}>
<div className="po-section-label" style={{ padding: '8px 12px' }}>Playlist</div>
{items.length === 0 && (
<div className="muted po-playlist-empty">Drag clips here to build the playlist.</div>
)}
{items.map((it, index) => (
<div key={it.id} className="po-pl-item" draggable
onDragStart={e => onItemDragStart(e, index)}
onDragOver={onItemDragOver}
onDrop={e => onItemDrop(e, index)}>
<span className="po-pl-index">{index + 1}</span>
<span className="po-pl-name">{it.clip_name || it.asset_id}</span>
<span className={'badge ' + (MEDIA_STATUS_BADGE[it.media_status] || 'neutral')}>
{it.media_status}
</span>
{it.media_status === 'error' && (
<button className="btn ghost xs" onClick={() => restage(it.id)}>Retry</button>
)}
<button className="btn ghost xs" onClick={() => removeItem(it.id)}></button>
</div>
))}
</div>
);
}
// Transport bar
function Transport({ channel, playlistId, onStatus }) {
const [busy, setBusy] = React.useState(false);
const act = async (fn) => { setBusy(true); try { await fn(); } catch (e) { alert(e.message); } finally { setBusy(false); } };
const play = () => act(async () => {
const r = await poFetch('/channels/' + channel.id + '/play', {
method: 'POST', body: JSON.stringify({ playlist_id: playlistId }),
});
onStatus && onStatus(r);
});
const pause = () => act(() => poFetch('/channels/' + channel.id + '/pause', { method: 'POST' }));
const resume = () => act(() => poFetch('/channels/' + channel.id + '/resume', { method: 'POST' }));
const skip = () => act(() => poFetch('/channels/' + channel.id + '/skip', { method: 'POST' }));
const stopPb = () => act(() => poFetch('/channels/' + channel.id + '/stop-playback', { method: 'POST' }));
const live = channel.status === 'running';
return (
<div className="po-transport">
<button className="btn primary" disabled={!live || busy || !playlistId} onClick={play}> Play</button>
<button className="btn ghost" disabled={!live || busy} onClick={pause}> Pause</button>
<button className="btn ghost" disabled={!live || busy} onClick={resume}> Resume</button>
<button className="btn ghost" disabled={!live || busy} onClick={skip}> Skip</button>
<button className="btn danger ghost" disabled={!live || busy} onClick={stopPb}> Stop</button>
</div>
);
}
// Program monitor
function ProgramMonitor({ channel, engine }) {
const onAir = channel.status === 'running';
return (
<div className="po-monitor">
<div className="po-monitor-head">
<span className={'po-onair ' + (onAir ? 'live' : '')}>{onAir ? '● ON AIR' : '○ OFF'}</span>
<span className="mono muted">{channel.output_type?.toUpperCase()} · {channel.video_format}</span>
</div>
<div className="po-monitor-screen">
{engine && engine.currentClip
? <div className="po-monitor-clip">{engine.currentClip}</div>
: <div className="muted">{onAir ? 'Idle — no clip playing' : 'Channel stopped'}</div>}
</div>
{engine && (
<div className="po-monitor-foot mono muted">
clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : ''} / {engine.playlistLength || 0}
{engine.loop ? ' · loop' : ''}
</div>
)}
</div>
);
}
// Channel detail (monitors + bin + playlist + transport)
function ChannelDetail({ channel, onChannelChange }) {
const [playlists, setPlaylists] = React.useState([]);
const [playlistId, setPlaylistId] = React.useState(null);
const [items, setItems] = React.useState([]);
const [engine, setEngine] = React.useState(null);
const [ch, setCh] = React.useState(channel);
React.useEffect(() => { setCh(channel); }, [channel.id]);
const loadPlaylists = React.useCallback(async () => {
const pls = await poFetch('/playlists?channel_id=' + channel.id);
setPlaylists(pls);
if (pls.length && !playlistId) setPlaylistId(pls[0].id);
if (!pls.length) {
// Auto-create a default playlist so the operator can start dragging.
const created = await poFetch('/playlists', {
method: 'POST', body: JSON.stringify({ channel_id: channel.id, name: 'Main' }),
});
setPlaylists([created]); setPlaylistId(created.id);
}
}, [channel.id]);
const loadItems = React.useCallback(async () => {
if (!playlistId) return;
const its = await poFetch('/playlists/' + playlistId + '/items');
setItems(its);
}, [playlistId]);
React.useEffect(() => { loadPlaylists(); }, [channel.id]);
React.useEffect(() => { loadItems(); }, [playlistId]);
// Poll engine status + item staging while live.
React.useEffect(() => {
let t;
const poll = async () => {
try {
const s = await poFetch('/channels/' + channel.id + '/status');
setEngine(s.engine || null);
} catch (_) {}
try { await loadItems(); } catch (_) {}
t = setTimeout(poll, 4000);
};
poll();
return () => clearTimeout(t);
}, [channel.id, playlistId]);
const startChannel = async () => {
const updated = await poFetch('/channels/' + channel.id + '/start', { method: 'POST' });
setCh(updated); onChannelChange(updated);
};
const stopChannel = async () => {
const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' });
setCh(updated); onChannelChange(updated);
};
return (
<div className="po-detail">
<div className="po-detail-head">
<div>
<h3 style={{ margin: 0 }}>{ch.name}</h3>
<span className="mono muted">{ch.output_type?.toUpperCase()} · {ch.video_format} · {ch.status}</span>
</div>
<div className="po-detail-actions">
{ch.status === 'running'
? <button className="btn danger" onClick={stopChannel}>Stop channel</button>
: <button className="btn primary" onClick={startChannel}>Start channel</button>}
</div>
</div>
{ch.error_message && <div className="alert error">{ch.error_message}</div>}
<div className="po-grid">
<ProgramMonitor channel={ch} engine={engine} />
<MediaBin projectId={ch.project_id} />
</div>
<Transport channel={ch} playlistId={playlistId} onStatus={() => loadItems()} />
{playlistId && (
<Playlist channel={ch} playlistId={playlistId} items={items} onReload={loadItems} />
)}
</div>
);
}
// Top-level page
function Playout() {
const [channels, setChannels] = React.useState(null);
const [selectedId, setSelectedId] = React.useState(null);
const [showCreate, setShowCreate] = React.useState(false);
const [err, setErr] = React.useState(null);
const load = React.useCallback(async () => {
try {
const list = await poFetch('/channels');
setChannels(list);
if (list.length && !selectedId) setSelectedId(list[0].id);
} catch (e) { setErr(e.message); setChannels([]); }
}, [selectedId]);
React.useEffect(() => { load(); }, []);
const selected = (channels || []).find(c => c.id === selectedId) || null;
const onChannelChange = (updated) => {
setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c));
};
return (
<div className="page">
<div className="page-header">
<span className="title">Playout Master Control</span>
<span className="subtitle">Schedule and play assets to SDI, NDI, SRT or RTMP.</span>
</div>
<div className="page-body po-page">
{err && <div className="alert error">{err}</div>}
<div className="po-channels-bar">
{(channels || []).map(c => (
<button key={c.id}
className={'po-chan-tab ' + (c.id === selectedId ? 'active' : '')}
onClick={() => setSelectedId(c.id)}>
<span className={'po-chan-dot ' + (c.status === 'running' ? 'live' : '')} />
{c.name}
</button>
))}
<button className="btn ghost sm" onClick={() => setShowCreate(true)}>+ Channel</button>
</div>
{channels === null && <div className="muted">Loading channels</div>}
{channels !== null && channels.length === 0 && (
<div className="po-empty">
<p className="muted">No playout channels yet.</p>
<button className="btn primary" onClick={() => setShowCreate(true)}>Create your first channel</button>
</div>
)}
{selected && <ChannelDetail key={selected.id} channel={selected} onChannelChange={onChannelChange} />}
</div>
{showCreate && (
<ChannelCreate
onClose={() => setShowCreate(false)}
onCreated={(ch) => { setShowCreate(false); setChannels(cs => [...(cs || []), ch]); setSelectedId(ch.id); }}
/>
)}
</div>
);
}
window.Playout = Playout;

View file

@ -49,6 +49,9 @@ function Projects({ onOpenProject, navigate }) {
const [showNew, setShowNew] = React.useState(false);
const [menuFor, setMenuFor] = React.useState(null);
const [renamingProject, setRenamingProject] = React.useState(null);
const [accessProject, setAccessProject] = React.useState(null);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); };
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/projects')
@ -100,8 +103,8 @@ function Projects({ onOpenProject, navigate }) {
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search projects…" />
</div>
<div className="tab-group">
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')}><Icon name="grid" size={12} /></button>
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}><Icon name="list" size={12} /></button>
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
</div>
<button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>
</div>
@ -122,8 +125,10 @@ function Projects({ onOpenProject, navigate }) {
key={p.id}
project={p}
assets={ASSETS}
canManageAccess={isAdmin}
onOpen={() => onOpenProject(p)}
onRename={() => renameProject(p)}
onManageAccess={() => manageAccess(p)}
onDelete={() => deleteProject(p)}
/>
))}
@ -140,14 +145,15 @@ function Projects({ onOpenProject, navigate }) {
<div>{p.name}</div>
</div>
<div className="col-sub">{p.assets || 0}</div>
<div className="col-sub"></div>
<div className="col-sub">{p.updated || ''}</div>
<div className="col-sub">·</div>
<div className="col-sub">{p.updated || '·'}</div>
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
<button className="icon-btn" aria-label="Project actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
{menuFor === p.id && (
<div className="row-menu" onClick={e => e.stopPropagation()}>
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename</button>
{isAdmin && <button onClick={() => manageAccess(p)}><Icon name="users" size={11} />Manage access</button>}
<button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
</div>
)}
@ -165,6 +171,140 @@ function Projects({ onOpenProject, navigate }) {
onSaved={() => { setRenamingProject(null); refresh(); }}
/>
)}
{accessProject && (
<ProjectAccessModal
project={accessProject}
onClose={() => setAccessProject(null)}
/>
)}
</div>
);
}
// Admin-only: grant/revoke per-project access to users and groups.
// Backed by GET/POST/DELETE /api/v1/projects/:id/access.
function ProjectAccessModal({ project, onClose }) {
const [grants, setGrants] = React.useState([]);
const [users, setUsers] = React.useState([]);
const [groups, setGroups] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [err, setErr] = React.useState(null);
// Add-grant form state.
const [subjType, setSubjType] = React.useState('user');
const [subjId, setSubjId] = React.useState('');
const [level, setLevel] = React.useState('view');
const loadGrants = React.useCallback(() => {
return window.ZAMPP_API.fetch('/projects/' + project.id + '/access')
.then(list => setGrants(list || []))
.catch(e => setErr(e.message));
}, [project.id]);
React.useEffect(() => {
setLoading(true);
Promise.all([
loadGrants(),
window.ZAMPP_API.fetch('/users').then(setUsers).catch(() => setUsers([])),
window.ZAMPP_API.fetch('/groups').then(setGroups).catch(() => setGroups([])),
]).finally(() => setLoading(false));
}, [loadGrants]);
const addGrant = () => {
if (!subjId) return;
setErr(null);
window.ZAMPP_API.fetch('/projects/' + project.id + '/access', {
method: 'POST',
body: JSON.stringify({ subject_type: subjType, subject_id: subjId, level }),
})
.then(() => { setSubjId(''); return loadGrants(); })
.catch(e => setErr(e.message || 'Failed to add grant'));
};
const revoke = (g) => {
window.ZAMPP_API.fetch('/projects/' + project.id + '/access/' + g.subject_type + '/' + g.subject_id, { method: 'DELETE' })
.then(loadGrants)
.catch(e => setErr(e.message || 'Failed to revoke'));
};
// Candidates for the picker exclude subjects that already have a grant.
const grantedIds = new Set(grants.filter(g => g.subject_type === subjType).map(g => g.subject_id));
const candidates = (subjType === 'user' ? users : groups).filter(c => !grantedIds.has(c.id));
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 520 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Manage access · {project.name}</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 12 }}>
Admins always have full access. Grant specific users or groups view (read-only) or
edit (read-write) access to this project.
</div>
{/* Add-grant row */}
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr 90px auto', gap: 8, alignItems: 'end', marginBottom: 14 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">Type</label>
<select className="field-input" value={subjType} style={{ appearance: 'auto' }}
onChange={e => { setSubjType(e.target.value); setSubjId(''); }}>
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">{subjType === 'user' ? 'User' : 'Group'}</label>
<select className="field-input" value={subjId} style={{ appearance: 'auto' }}
onChange={e => setSubjId(e.target.value)}>
<option value="">Pick a {subjType}</option>
{candidates.map(c => (
<option key={c.id} value={c.id}>
{subjType === 'user' ? ('@' + c.username + (c.display_name ? ' · ' + c.display_name : '')) : c.name}
</option>
))}
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">Level</label>
<select className="field-input" value={level} style={{ appearance: 'auto' }}
onChange={e => setLevel(e.target.value)}>
<option value="view">View</option>
<option value="edit">Edit</option>
</select>
</div>
<button className="btn primary sm" onClick={addGrant} disabled={!subjId}>Add</button>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
{/* Existing grants */}
<div className="panel">
{loading && <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div>}
{!loading && grants.length === 0 && (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
No grants yet only admins can see this project.
</div>
)}
{!loading && grants.map(g => (
<div key={g.subject_type + ':' + g.subject_id}
style={{ display: 'grid', gridTemplateColumns: '20px 1fr 70px 80px', gap: 10, alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
<Icon name={g.subject_type === 'group' ? 'users' : 'user'} size={13} />
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{g.subject_name || '(deleted)'}</div>
{g.username && <div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{g.username}</div>}
</div>
<span className={`badge ${g.level === 'edit' ? 'accent' : 'neutral'}`}>{g.level}</span>
<button className="btn ghost sm danger" onClick={() => revoke(g)}>Revoke</button>
</div>
))}
</div>
</div>
<div className="modal-foot">
<button className="btn primary sm" onClick={onClose}>Done</button>
</div>
</div>
</div>
);
}
@ -206,11 +346,11 @@ function RenameProjectModal({ project, onClose, onSaved }) {
);
}
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDelete, canManageAccess }) {
const ofProject = assets.filter(a => a.project_id === project.id);
const thumbAssets = ofProject.slice(0, 4);
// Real status distribution ready vs processing/live vs error.
// Real status distribution - ready vs processing/live vs error.
const total = ofProject.length || 1;
const ready = ofProject.filter(a => a.status === 'ready').length;
const inFlight = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
@ -259,7 +399,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
<div className="project-meta">
<span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span>
<span>·</span>
<span>updated {project.updated || ''}</span>
<span>updated {project.updated || '·'}</span>
</div>
{ofProject.length > 0 ? (
<div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>
@ -275,6 +415,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename</button>
{canManageAccess && <button onClick={() => { setCtx(null); onManageAccess && onManageAccess(); }}><Icon name="users" size={11} />Manage access</button>}
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
</div>
)}
@ -284,3 +425,4 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
window.Projects = Projects;
window.RenameProjectModal = RenameProjectModal;
window.ProjectAccessModal = ProjectAccessModal;

View file

@ -0,0 +1,97 @@
// screens-resources.jsx
// Live CPU/RAM/GPU gauges for Dashboard. Polls /api/v1/cluster/metrics every 5s.
// Falls back to mock data when endpoint unavailable.
const RESOURCE_MOCK={nodes:[
{hostname:"zampp1",cpu_util_pct:42,ram_used_mb:14336,ram_total_mb:32768,
gpus:[{name:"RTX 3060",util_pct:67,memory_used_mb:5120,memory_total_mb:12288}]},
{hostname:"zampp2",cpu_util_pct:18,ram_used_mb:8192,ram_total_mb:32768,
gpus:[{name:"RTX 3060",util_pct:12,memory_used_mb:1024,memory_total_mb:12288}]},
]};
function useClusterMetrics(){
const [data,setData]=React.useState(null);
const [usingMock,setUsingMock]=React.useState(false);
React.useEffect(()=>{
let cancelled=false;
const load=()=>{
window.ZAMPP_API.fetch('/cluster/metrics')
.then(d=>{
if(cancelled)return;
if(d&&Array.isArray(d.nodes)&&d.nodes.length>0){
setData(d);setUsingMock(false);
}else{setData(RESOURCE_MOCK);setUsingMock(true);}
})
.catch(()=>{if(!cancelled){setData(RESOURCE_MOCK);setUsingMock(true);}});
};
load();
const t=setInterval(load,5000);
return ()=>{cancelled=true;clearInterval(t);};
},[]);
return {data,usingMock};
}
function ResBar({pct,color}){
const p=Math.min(100,Math.max(0,Math.round(pct||0)));
const c=color||(p>85?'var(--warning)':p>60?'var(--accent)':'var(--success)');
return (
<div className="res-bar-wrap">
<div className="res-bar"><div className="res-bar-fill" style={{width:p+'%',background:c}}/></div>
<span className="res-bar-pct">{p}%</span>
</div>
);
}
function NodeResourceCard({node}){
const ramPct=node.ram_total_mb>0?(node.ram_used_mb/node.ram_total_mb)*100:0;
const ramUsed=(node.ram_used_mb/1024).toFixed(1);
const ramTotal=(node.ram_total_mb/1024).toFixed(0);
return (
<div className="panel res-node-card">
<div className="res-node-name"><span className="res-node-dot"/>{node.hostname}</div>
<div className="res-metric">
<div className="res-metric-label">CPU</div>
<ResBar pct={node.cpu_util_pct}/>
</div>
<div className="res-metric">
<div className="res-metric-label">RAM <span className="res-metric-sub">{ramUsed}/{ramTotal} GB</span></div>
<ResBar pct={ramPct} color={ramPct>85?'var(--warning)':'var(--text-2)'}/>
</div>
{(node.gpus||[]).map((gpu,i)=>{
const vramPct=gpu.memory_total_mb>0?(gpu.memory_used_mb/gpu.memory_total_mb)*100:0;
const vramUsed=(gpu.memory_used_mb/1024).toFixed(1);
const vramTotal=(gpu.memory_total_mb/1024).toFixed(0);
const lbl=(node.gpus||[]).length>1?'GPU '+(i+1):'GPU';
return (
<React.Fragment key={i}>
<div className="res-metric">
<div className="res-metric-label">{lbl} util</div>
<ResBar pct={gpu.util_pct}/>
</div>
<div className="res-metric">
<div className="res-metric-label">{lbl} VRAM <span className="res-metric-sub">{vramUsed}/{vramTotal} GB</span></div>
<ResBar pct={vramPct} color={vramPct>85?'var(--warning)':'var(--purple)'}/>
</div>
</React.Fragment>
);
})}
</div>
);
}
function ClusterResources(){
const {data,usingMock}=useClusterMetrics();
if(!data)return <div className="dash-panel-empty">Loading resource metrics...</div>;
return (
<div>
{usingMock&&(
<div className="res-mock-note">&#9888; Metrics API unavailable - showing mock data</div>
)}
<div className="res-nodes-grid">
{data.nodes.map(n=><NodeResourceCard key={n.hostname} node={n}/>)}
</div>
</div>
);
}
window.ClusterResources=ClusterResources;

View file

@ -1,40 +1,62 @@
// shell.jsx - app shell: sidebar nav, topbar, route container
const NAV_TREE = [
// Sidebar IA: grouped sections. Renderer prints each section's label, then
// its items. Items inside `children` of a `group:true` node still render as
// the existing expandable submenu (only used for the Capture-SDK admin tools
// today, but kept general).
const NAV_SECTIONS = [
{
label: "Workspace",
items: [
{ id: "home", label: "Home", icon: "home" },
{ id: "dashboard", label: "Dashboard", icon: "layout" },
{ id: "library", label: "Library", icon: "library" },
{ id: "projects", label: "Projects", icon: "folder" },
{ id: "library", label: "Library", icon: "library" },
],
},
{
id: "ingest", label: "Ingest", icon: "upload", group: true,
children: [
label: "Ingest",
items: [
{ id: "upload", label: "Upload", icon: "upload" },
{ id: "youtube", label: "YouTube", icon: "download" },
{ id: "recorders", label: "Recorders", icon: "record" },
{ id: "schedule", label: "Schedule", icon: "jobs" },
{ id: "capture", label: "Capture", icon: "capture" },
{ id: "schedule", label: "Schedule", icon: "clock" },
{ id: "monitors", label: "Monitors", icon: "monitor" },
],
},
{
label: "Operations",
items: [
{ id: "capture", label: "Capture", icon: "capture" },
{ id: "playout", label: "Playout", icon: "signal" },
{ id: "jobs", label: "Jobs", icon: "jobs" },
{ id: "editor", label: "Editor", icon: "editor" },
];
const ADMIN_TREE = [
],
},
{
label: "Admin",
items: [
{ id: "users", label: "Users", icon: "users" },
{ id: "tokens", label: "Tokens", icon: "token" },
{ id: "billing", label: "Billing", icon: "dollar" },
{ id: "containers", label: "Containers", icon: "container" },
{ id: "cluster", label: "Cluster", icon: "cluster" },
{ id: "settings", label: "Settings", icon: "settings" },
],
},
];
// No hidden routes currently; Billing (the satirical pricing page) lives in
// the Admin section above. Real API token management is at /tokens.
const NAV_HIDDEN = [];
// Back-compat: NAV_TREE and ADMIN_TREE were used by other modules.
// NAV_FLAT is consumed by topbar search and the keyboard router.
const NAV_TREE = NAV_SECTIONS.slice(0, 3).flatMap(s => s.items);
const ADMIN_TREE = NAV_SECTIONS[3].items;
const NAV_FLAT = (() => {
const out = [];
const visit = (arr) => arr.forEach(n => {
if (n.group && n.children) { visit(n.children); return; }
out.push({ id: n.id, label: n.label, icon: n.icon });
});
visit(NAV_TREE); visit(ADMIN_TREE);
NAV_SECTIONS.forEach(s => s.items.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon })));
NAV_HIDDEN.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon }));
return out;
})();
@ -80,12 +102,11 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup })
}
function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
const [openGroups, setOpenGroups] = React.useState(new Set([]));
const [jobsBadge, setJobsBadge] = React.useState(null);
const [captureBadge, setCaptureBadge] = React.useState(null);
// Live jobs count (#130) poll /jobs/count for active jobs and render the
// result as the sidebar badge. Falls back to hidden on error.
// Live jobs count (#130): poll /jobs?status=active and render the result
// as the sidebar badge on the Jobs item. Falls back to hidden on error.
React.useEffect(() => {
let cancelled = false;
const tick = () => {
@ -103,43 +124,21 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
return () => { cancelled = true; clearInterval(id); };
}, []);
// Live DeckLink signal presence poll every 5s, badge shows receiving port count.
React.useEffect(() => {
let cancelled = false;
const tick = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal')
.then(entries => {
if (cancelled) return;
const all = Array.isArray(entries) ? entries : [];
const live = all.filter(e => e.signal === 'receiving').length;
const total = all.length;
if (total === 0) { setCaptureBadge(null); return; }
setCaptureBadge(live > 0
? { kind: 'live', text: `${live}/${total}` }
: { kind: 'neutral', text: `0/${total}` });
})
.catch(() => setCaptureBadge(null));
};
tick();
const id = setInterval(tick, 5000);
return () => { cancelled = true; clearInterval(id); };
}, []);
// (Capture live-signal badge previously lived here; it now belongs in the
// topbar status pip alongside the cluster pip. See issue #149.)
// Apply live badges to nav items.
const navTree = React.useMemo(
() => NAV_TREE.map(n => {
if (n.id === 'jobs' && jobsBadge) return { ...n, badge: jobsBadge };
if (n.id === 'ingest' && n.children) {
return {
...n,
children: n.children.map(c =>
c.id === 'capture' && captureBadge ? { ...c, badge: captureBadge } : c
),
};
}
return n;
}),
[jobsBadge, captureBadge]
// Apply the live Jobs badge to the Operations section, and gate the Admin
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
// This is UX only the API enforces the same rules server-side.
const isAdmin = me?.role === 'admin';
const sections = React.useMemo(
() => NAV_SECTIONS
.filter(sec => sec.label !== 'Admin' || isAdmin)
.map(sec => ({
...sec,
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
})),
[jobsBadge, isAdmin]
);
const toggleGroup = (id) => {
setOpenGroups(prev => {
@ -181,7 +180,10 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
</button>
</div>
<div className="sidebar-scroll">
{navTree.map(item => (
{sections.map((section, si) => (
<React.Fragment key={section.label}>
{si > 0 && <div className="nav-section-label">{section.label}</div>}
{section.items.map(item => (
<NavItem
key={item.id}
item={item}
@ -191,24 +193,15 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
toggleGroup={toggleGroup}
/>
))}
<div className="nav-section-label">Admin</div>
{ADMIN_TREE.map(item => (
<NavItem
key={item.id}
item={item}
active={active}
onSelect={onNavigate}
openGroups={openGroups}
toggleGroup={toggleGroup}
/>
</React.Fragment>
))}
</div>
<div className="sidebar-footer">
<div className="avatar">{me?.initials || ''}</div>
<div className="avatar">{me?.initials || '·'}</div>
<div className="user-meta">
<div className="user-name">{me?.name || 'Not signed in'}</div>
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false showing the OS user running the server' : ''}>
{me?.role || ''}{me?.synthetic ? ' · auth off' : ''}
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false: showing the OS user running the server' : ''}>
{me?.role || '·'}{me?.synthetic ? ' · auth off' : ''}
</div>
</div>
{me?.synthetic ? null : (
@ -265,7 +258,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
(D.ASSETS || []).forEach(a => {
const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
if (hay.includes(term)) {
const sub = [a.project, a.res, a.duration].filter(x => x && x !== '').join(' · ');
const sub = [a.project, a.res, a.duration].filter(x => x && x !== '·').join(' · ');
out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
}
});
@ -399,7 +392,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
}
function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject, onToggleSidebar }) {
// Light cluster ping the badge in the topbar should reflect reality,
// Light cluster ping - the badge in the topbar should reflect reality,
// not just look reassuring. /metrics/home returns cluster online/total.
const [clusterHealthy, setClusterHealthy] = React.useState(true);
React.useEffect(() => {
@ -478,5 +471,6 @@ window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT;
window.NAV_SECTIONS = NAV_SECTIONS;
window.Field = Field;
window.GlobalSearch = GlobalSearch;

View file

@ -1,7 +1,7 @@
/* ========== Asset detail ========== */
.asset-detail {
display: flex; flex-direction: column;
flex: 1; /* parent is `.main` (flex col) fill remaining vertical space */
flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space */
min-height: 0;
background: var(--bg-0);
overflow: hidden;
@ -60,13 +60,11 @@
background: rgba(0,0,0,0.55);
border-radius: 50%;
padding: 16px;
backdrop-filter: blur(8px);
}
.player-tc {
position: absolute;
right: 12px; bottom: 12px;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
padding: 4px 10px;
border-radius: 4px;
font-family: var(--font-mono);

View file

@ -66,7 +66,7 @@
.activity-text .target { word-break: break-word; }
.asset-card .meta .sub { overflow: hidden; min-width: 0; flex-wrap: wrap; }
/* #52 duration mono badge in the meta row had no shrink behaviour, so on
/* #52 - duration mono badge in the meta row had no shrink behaviour, so on
narrow cards it overlapped the project text. Force the duration column to
never overflow and let the project label ellipsize. */
.asset-card .meta .sub > .duration { flex-shrink: 0; margin-left: auto; }
@ -76,7 +76,7 @@
.dash-sparkline { z-index: 0; }
/* ============================================================
Search bar polish give it a real container so it doesn't
Search bar polish - give it a real container so it doesn't
read as floating text on the topbar background.
============================================================ */
.topbar .search,
@ -123,7 +123,7 @@
color: var(--text-2);
}
/* Library-local "Filter assets" search same container treatment,
/* Library-local "Filter assets" search - same container treatment,
keep its compact width. */
.library-toolbar .search {
background: var(--bg-2);
@ -165,7 +165,7 @@
}
/* ============================================================
Right-click context menu pop it forward off the page so it
Right-click context menu - pop it forward off the page so it
reads as a menu, not a floating list.
============================================================ */
.ctx-menu {
@ -225,7 +225,7 @@
}
.ctx-menu button.danger:hover:not(:disabled) svg { color: var(--danger); }
/* Row-popover menu (Users page etc.) match the same polish so the
/* Row-popover menu (Users page etc.) - match the same polish so the
app feels consistent. */
.row-menu {
background: var(--bg-2);
@ -240,7 +240,7 @@
.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }
/* ============================================================
Sidebar brand logo replace the gradient "D" tile with the
Sidebar brand logo - replace the gradient "D" tile with the
actual dragon-coiled-D logo. mix-blend-mode: screen drops the
light-gray PNG background so only the black silhouette + blue
flame remain over the dark sidebar.
@ -260,7 +260,7 @@
}
/* ============================================================
Launcher home full-bleed landing page with the logo as hero
Launcher home - full-bleed landing page with the logo as hero
and big section tiles.
============================================================ */
.launcher {
@ -297,7 +297,7 @@
width: 180px;
height: 180px;
object-fit: contain;
/* Convert to white same approach as .brand-logo. */
/* Convert to white - same approach as .brand-logo. */
filter:
brightness(0) invert(1)
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
@ -324,6 +324,14 @@
letter-spacing: 0.02em;
}
.launcher-tagline-motto {
margin-top: 6px;
color: var(--accent);
font-style: italic;
font-size: 15px;
letter-spacing: 0.04em;
}
.launcher-grid {
width: 100%;
display: grid;
@ -435,7 +443,7 @@
color: var(--tile-icon-fg, var(--accent-text));
}
/* Tone variants colour the icon tile + halo, leave the body text
/* Tone variants - colour the icon tile + halo, leave the body text
neutral so the tile reads as a button, not a banner. */
.launcher-tile.tone-accent {
--tile-tint: rgba(91, 124, 250, 0.18);
@ -515,7 +523,7 @@
}
/* ============================================================
Recorder row signal indicator with a pulsing dot when
Recorder row - signal indicator with a pulsing dot when
actually receiving frames. Closes part of #2.
============================================================ */
.signal-val {
@ -539,7 +547,7 @@
}
/* ============================================================
BMD card diagram rendered inside the Cluster node panel.
BMD card diagram - rendered inside the Cluster node panel.
The SVG is generated by bmd-card.js; styles live here so
they inherit the app CSS custom properties at render time.
============================================================ */
@ -678,3 +686,84 @@
z-index: 100;
pointer-events: none;
}
/* ── Resource utilization cards (screens-resources.jsx) ── */
.res-nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.res-node-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.res-node-name {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 2px;
}
.res-node-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 3px var(--success-soft);
flex-shrink: 0;
}
.res-metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.res-metric-label {
font-size: 11px;
font-weight: 500;
color: var(--text-3);
font-family: var(--font-mono);
display: flex;
align-items: baseline;
gap: 6px;
}
.res-metric-sub {
color: var(--text-4);
font-weight: 400;
}
.res-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.res-bar {
flex: 1;
height: 6px;
background: var(--bg-4);
border-radius: 99px;
overflow: hidden;
}
.res-bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.6s ease;
}
.res-bar-pct {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-3);
min-width: 32px;
text-align: right;
}
.res-mock-note {
font-size: 11px;
color: var(--warning);
background: var(--warning-soft);
border-radius: var(--r-sm);
padding: 6px 10px;
margin-bottom: 10px;
font-family: var(--font-mono);
}

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