Playout failover queries cluster_nodes.last_seen_at to find healthy nodes
for channel re-placement. Column missing from original cluster schema.
Migration 031 adds column + backfills existing nodes to NOW().
Fixes scheduler error: column "last_seen_at" does not exist
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Backend (routes/users.js):
- GET / now returns totp_enabled so the UI can show 2FA status
- GET /:id/access — admin-only effective per-project access (MAX over direct +
group grants), labels via=direct|group:<name>; admins report all/edit
- POST /:id/totp/disable — admin clears a locked-out user's 2FA without their
password (self-service disable still requires it); dev user blocked
- role validated against {admin,editor,viewer} on create + PATCH (was unchecked)
Frontend:
- Users>Policies tab: static prose replaced with interactive per-user matrix —
inline role select, 2FA badge, Reset-2FA action, lazy per-user access expander
- Home "Premiere panel" tile -> "Downloads"; modal renamed, adds Teams ISO row
(disabled "coming soon" until the .exe is supplied); UXP .ccx link unchanged
- data.jsx: window.TEAMS_ISO placeholder ({available:false})
Not runtime-tested in browser yet. Teams ISO .exe still pending from user.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
Fetches /playout/channels separately and degrades silently when the
endpoint or schema is absent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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).
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.
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
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.
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
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>
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>
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>
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.
- migration 028: users.google_sub (unique) + email; password_hash nullable
for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
/auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
friendly callback error handling
- .env.example documents all new vars
Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The HLS-VOD work made GET /assets/:id/stream return the HLS playlist URL as
`url` whenever hls_s3_key was set. The Premiere plugin's "Import Proxy"
downloads `url` to a file and imports it — so it was saving an .m3u8 playlist
as .mp4, and Premiere rejected it ("unsupported compression type"). This hit
every YouTube asset (all get HLS generated), regardless of codec.
/stream now returns the directly-downloadable MP4 proxy as `url` (type mp4)
and the HLS playlist as a separate `hls_url`. The web player prefers `hls_url`
(so in-browser HLS playback is unchanged), while the already-installed plugin
gets a real MP4 again — no plugin reinstall needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The redesigned UXP panel (left icon rail, compact list-view toggle, hover
tooltips, single Export menu) was committed only to redesign/panel-icon-rail
and never merged, so main + the website kept serving the old blocky-button
build under the same version number (2.2.2). That branch had diverged off an
old main and is missing recent worker/HLS/NVENC/import work, so it can't be
merged wholesale — cherry-pick just the plugin instead.
- services/premiere-plugin-uxp: replace source with the redesigned panel
(adds src/tooltip.js; reworks index.html + styles.css + src/*). Verified
byte-identical to the build installed on BMG-PC-Edit.
- web-ui/public/downloads/dragonflight-mam-2.2.2.ccx: swap the served
artifact to the redesigned 34708-byte build (download link unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Reorder the Workspace nav group so Projects sits above Library.
- Add a lucide-style `dollar` icon and use it for the Billing nav item
(was borrowing the `token` key icon).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The import queue row's progress bar read only `r.progress`, which pollRow
never updated numerically — it tracked asset.status (ingesting/processing/
ready) but not a percentage, so the bar sat at 0 until the asset flipped to
ready, then snapped to 100 ("blank until it finishes").
pollRow now also fetches GET /jobs/<jobId> and feeds the BullMQ job's numeric
progress (worker emits 2..100 across the yt-dlp download + S3 upload) into the
bar, so it fills during the download. Falls back to status when the job is
evicted post-completion. Also reaffirm 'downloading' label while ingesting and
poll a bit faster (2s) since short clips finish quickly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
- 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)
- 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
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.
- 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.
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)
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
## 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.
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.
- 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.
Adds an inline hi-res download trigger to the asset library.
UI:
- Small 22×22 download icon button in the top-right corner of each
asset thumbnail. Hidden by default, fades in on card hover or focus
so the resting-state grid stays clean.
- Only renders for assets that have an `original_s3_key` — proxies
and unfinished captures never offer it.
- Mirrored as a "Download original…" entry in the right-click
AssetContextMenu (between Rename and the bin actions).
Flow:
- First click (or any click while the warning is enabled) opens
DownloadWarningModal: terse copy explaining the file is the full
original ingest, can be multi-GB, and that speed depends on the
user's network connection. Footer: Cancel · Download. Body: a
"Don't show this again on this device" checkbox.
- Ticking the checkbox persists `df.lib.download.warnDismissed=1`
in localStorage. Subsequent clicks skip the modal and start the
download straight away.
Download itself reuses /api/v1/assets/:id/hires (presigned S3 URL)
— no proxy round-trip through mam-api, no in-browser progress UI
beyond what the browser already shows.
Spec: #145
Settings → Account "re-enable the warning" toggle is not in this
patch and will land separately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>