- remove requireAuth from all route files
- delete auth.js, tokens.js, users.js routes
- delete auth middleware
- remove session middleware and all auth deps from index.js
- delete login.html and auth-guard.js from web-ui
Scope (locked in via planning Q&A):
- Identity: local accounts only (PG users table) + existing bearer
tokens for headless callers.
- Transport: httpOnly cookie session for browser, Bearer for API.
- RBAC: admin / editor / viewer roles, plus an orthogonal
is_client flag for external (agency, talent, customer) accounts.
- Bootstrap: ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD env
seed the first admin on a clean install. Set ADMIN_BOOTSTRAP_RESET
to force-reset the named user (break-glass).
- Rate limit: in-memory, 10 fails per 15min per (IP, username).
- Password policy: \u22658 chars, mixed case, digit, symbol; small
blocklist of common passwords; cannot equal username.
- Self-service: change own display name + password. Everything
else (role, is_client, other-user mgmt) is admin only.
- Audit log: append-only table, indexed by actor + event_type +
created_at, populated by every auth/admin event.
Files added:
- services/mam-api/src/db/migrations/022-auth-rework.sql
users.is_client + last_login_at + failed_attempts; audit_log
table with FK to users (ON DELETE SET NULL).
- services/mam-api/src/middleware/audit.js
Fire-and-forget audit() helper. Caller never awaits, failure
logs but never throws — auditing cannot break the request
that triggered it.
- services/mam-api/src/middleware/passwordPolicy.js
Shared checkPassword(pw, { username }) used by setup, user
create/update, and self-service password change.
- services/mam-api/src/tasks/bootstrapAdmin.js
Runs after migrations. No-ops unless ADMIN_BOOTSTRAP_USER +
ADMIN_BOOTSTRAP_PASSWORD are set AND (users table empty OR
ADMIN_BOOTSTRAP_RESET=true).
- services/mam-api/src/routes/audit.js
Admin-only GET /audit (paginated, filter by event_type /
actor / target / date) and GET /audit/event-types.
- services/web-ui/public/modal-account-settings.jsx
Profile + Password tabs. Triggered by sidebar user button.
Files rewritten:
- services/mam-api/src/routes/auth.js
- POST /login: regenerate(), no manual save(); audit success/
fail/lockout; updates last_login_at + failed_attempts.
- POST /logout: destroys session, audits logout.
- GET /me: returns is_client + last_login_at. Synthetic admin
when AUTH_ENABLED=false.
- GET /setup-status: drives login.html UI state.
- POST /setup: blocked once any user exists; password policy.
- POST /password: self-service. Requires current pw, runs
policy, audits, invalidates other sessions implicitly via
users.js if changed by admin.
- PATCH /me: self-service display_name update.
- services/mam-api/src/routes/users.js
- is_client field in create/update/list/get.
- Guardrails: cannot delete or demote last admin, cannot
delete self, admins cannot be flagged is_client.
- Password change invalidates all sessions for that user
(DELETE FROM sessions WHERE sess->>'userId' = id).
- Audit on every mutation.
- Password policy enforced.
- services/mam-api/src/middleware/auth.js
- requireAuth now exposes req.user.is_client.
- New requireRole(["admin","editor"], { rejectClients: true })
helper. Applied to cluster, sdk, capture routes (infra).
- Synthetic user when AUTH_ENABLED=false has is_client=false.
- services/mam-api/src/index.js
- Loads bootstrap admin after migrations.
- Wires /api/v1/audit.
- Cleans up an earlier comment block.
- services/web-ui/public/login.html
- Password hint added next to setup-mode password field.
- services/web-ui/public/shell.jsx
- Sidebar user footer is a button that opens AccountSettings.
- CLIENT badge next to role when is_client=true.
- Nav filters: clients lose ingest tree + jobs + editor;
viewers lose ingest + editor; only admins see the Admin
section. Power button hidden when synthetic user.
- services/web-ui/public/screens-admin.jsx
- Users table: new Client column with inline toggle.
- InviteUserModal: Client checkbox + password hint, gated
off when role=admin.
- Last login column replaces Created in primary view.
- CSV export includes client + last_login.
- services/web-ui/public/data.jsx
- ZAMPP_DATA.ME carries is_client + display_name.
- services/web-ui/public/index.html
- Loads dist/modal-account-settings.js.
- services/web-ui/public/styles-rest.css
- .user-row grid widened to 6 columns.
- docker-compose.yml
- Plumbs SESSION_COOKIE_SECURE + ADMIN_BOOTSTRAP_* env vars.
Deploy:
cd /opt/wild-dragon
git pull origin main
# In .env:
# AUTH_ENABLED=true
# SESSION_SECRET=<openssl rand -hex 48>
# ADMIN_BOOTSTRAP_USER=admin
# ADMIN_BOOTSTRAP_PASSWORD=<strong>
docker compose build mam-api web-ui
docker compose up -d --force-recreate --no-deps mam-api web-ui
Auth work is parked until after ship. While AUTH_ENABLED=false:
- login.html now auto-redirects to / on load (no one should ever see
the login screen while auth is off; it was confusing).
- sidebar power button is hidden entirely when /auth/me returns a
synthetic user, so there's no broken-feeling no-op control.
- Removed connect-pg-simple createTableIfMissing flag in case
v9.0.1's handling of that option was responsible for the recent
boot 502 (the schema is created by migration 021 anyway).
The /auth/login + session.regenerate() + cookie fix from c34a721
stays in place — when we re-enable auth it'll work end-to-end. The
sessions table from migration 021 stays. Operator action to restore
auth later: set AUTH_ENABLED=true + SESSION_SECRET=<random> in the
mam-api environment and restart.
Login was returning 200 + correct user JSON + writing a row to the
sessions table, but emitting zero Set-Cookie headers. Root cause:
session.regenerate() → set fields → session.save() → res.json()
Calling session.save() manually writes the store but bypasses
express-session's res.end() hook, which is the only path that adds
the Set-Cookie header to the response. The cookie was never sent to
the browser even though the session existed server-side — hence the
redirect loop.
Fix: remove the manual save(). Set the session fields and call
res.json() directly inside regenerate()'s callback; express-session
handles store write + Set-Cookie automatically on res.end().
The redirect loop after successful login was almost certainly the
`sessions` table never being created. `schema.sql` defines it but
only runs on first-init via the postgres entrypoint; instances
bootstrapped via mam-api's own migration loop never got the table.
express-session's `req.session.save()` then failed silently and the
cookie pointed at a sid that wasn't in the store — every subsequent
request looked like a brand-new visitor.
- New migration 021-ensure-sessions-table.sql (idempotent).
- connect-pg-simple now configured with `createTableIfMissing: true`
as belt-and-braces.
- `POST /auth/login` now explicitly waits for session.save() and
surfaces both regenerate() and save() errors instead of treating
them as 'success'. Logs sid + req.secure + req.protocol so we can
confirm trust-proxy is doing the right thing behind NPM.
Three concrete issues kept the login flow broken on dragonflight.live:
1. mam-api trusted no proxy headers, so behind nginx/Cloudflare the
session cookie's `secure` flag and the rate-limiter's IP keying
both saw the wrong values. Now sets `app.set('trust proxy', 1)`.
2. Session config was tied to NODE_ENV and lacked sameSite/name. Now:
- SESSION_COOKIE_SECURE env (default: true when AUTH_ENABLED) so a
site behind HTTPS gets Secure cookies regardless of NODE_ENV.
- `sameSite: 'lax'` for predictable post-login redirects.
- Renamed to `df.sid` so it's obvious in DevTools.
- `rolling: true` extends the 7-day TTL on active use.
- SESSION_SECRET is now required when AUTH_ENABLED=true; the
server refuses to start with a dev default in prod.
3. login.html silently showed the sign-in panel even when no users
exist or auth is off:
- New GET /auth/setup-status reports {needs_setup, user_count,
auth_enabled}.
- login.html calls it on load and auto-flips into setup mode when
needs_setup is true, or shows an explicit "auth is off" flash
when auth_enabled is false (the previous symptom: logout button
did nothing because /auth/me returned a synthetic admin no matter
what).
- Added a `.flash.info` style for the new neutral notice.
4. Sidebar logout used to call /auth/logout then `window.location
.reload()`. With auth off that reload landed back on the synthetic-
admin app and looked like nothing happened. It now redirects to
/login.html in all states so the operator sees feedback (and the
server-side messaging about auth being off) instead of a no-op.
Deploy notes for zampp1:
- Set AUTH_ENABLED=true and a random SESSION_SECRET in the
mam-api environment (e.g. /opt/wild-dragon/.env).
- Restart mam-api.
- First load of /login.html will auto-route to the setup form so
you can create the first admin.
RustFS returns empty bodies for ranged GETs whose start offset is past
~5.9 MB on single-file proxy MP4s. HEAD reports correct size, full GET
(`bytes=0-`) works, but `bytes=8179166-` comes back 206 + correct
Content-Range header with zero bytes. Confirmed via direct S3 probe
against broadcastmgmt.cloud/dragonmam (see scratch tests).
Workaround in mam-api `GET /api/v1/assets/:id/video` until the proxy
worker emits HLS (planned v1.2.1):
- HEAD the object first to learn total size (also gives ETag /
Last-Modified for conditional requests).
- No-Range / unparseable-Range / pre-EOF requests \u2192 plain pipe.
- Parsed `bytes=N-M` requests below RUSTFS_RANGE_SAFE_START
(default 5_500_000) \u2192 direct ranged GET, RustFS handles fine.
- Anything reaching into the broken zone \u2192 stream from offset 0,
drop bytes below start, stop at end. Memory stays flat; extra
bandwidth = (end+1 - requested-size) per seek.
- Genuinely out-of-range \u2192 416 with Cache-Control: no-store so the
browser doesn't poison its cache.
Also stashes (not yet wired up) the HLS pieces we'll need for the
follow-up: `segmentToHls` ffmpeg helper + `uploadDirectoryToS3`
worker s3 helper. Harmless additions; not referenced by any code path
yet.
Confirmed against the affected asset (a72aaa03-...): bytes=0-100k +
50% +100k native pass-through; 70% +100k and near-EOF previously hung
the browser, now stream correctly via the stitched path.
Refs #143.
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
The BMD card SVG renderer (window.BMDCards) was created in an earlier
session but never wired into index.html, so the new video-presence
indicator from a44d8bd was silently bailing at the !window.BMDCards
guard. Loading it alongside the other helpers in /js/.
Adds per-port video signal state to the admin Cluster panel:
- New GET /cluster/devices/blackmagic/signal endpoint joins recorders by
node_id+device_index and queries each active capture container's
/capture/status (local: http://recorder-<id>:3001, remote: api_url/
sidecar/<container_id>/status). Returns receiving/connecting/lost/
error/idle/no-recorder per port plus framesReceived and currentFps.
- bmd-card.js render() now accepts portSignals (Map or object) and
overlays a colored dot on each BNC connector with pulse animation
for receiving/connecting states.
- screens-admin.jsx Cluster panel polls the new endpoint every 5s,
feeds the signal map into both the port chips (now show
RECEIVING/CONNECTING/LOST + fps) and the BMD SVG card diagram
rendered below them via a new BmdCardPanel component.
- styles-fixes.css adds bmd-card-* styles for the SVG diagram and
bmd-port-signal --pulse animation.
mam-api /video endpoint:
- S3 InvalidRange (httpStatusCode 416) was being caught and returned as 500
via next(err), which the video element treats as a fatal load error and
freezes the player. Now we catch the specific 416 case, do a no-range
HEAD-equivalent to learn the real file size, and return proper 416 with
Content-Range: bytes */<total> so the browser can recover.
screens-asset.jsx — player health + buffer visualization:
- New states: playerState ('idle'|'loading'|'playing'|'paused'|'seeking'|
'waiting'|'stalled'|'error'), playerError, buffered (array of {start,end}
ms from HTMLMediaElement.buffered), stallStart, stallElapsedMs
- Wired video element events: onProgress, onWaiting, onStalled, onPlaying,
onCanPlay, onCanPlayThrough, onSeeking, onSeeked, onError
- onError captures MediaError code+message into a console.error and the
on-screen badge so freeze causes are now visible
- Status badge overlay (top-right of player): shows SEEKING / BUFFERING /
STALLED / ERROR + elapsed seconds since the stall began
- PlaybackBar renders buffered ranges as translucent grey segments so you
can see what the browser has loaded vs. what's still pending — makes
seek-related freezes immediately obvious
Replaced sync execFileSync('docker') approach (no docker CLI in container)
with async Docker socket HTTP API calls:
- POST /containers/create with nvidia runtime + DeviceRequests
- POST /containers/:id/start
- Poll inspect until not running
- GET /containers/:id/logs, strip 8-byte frame headers, parse csv
probeGpusViaSmi() runs once at startup before the first heartbeat.
Result cached in _gpuCache; detectHardware() reads cache on every heartbeat.
Falls back to /dev/nvidia* scan if probe fails or runtime unavailable.
nsenter approach failed (requires SYS_ADMIN in container).
nvidia-smi bind-mount failed (Alpine vs Ubuntu glibc incompatibility).
Working solution: spawn 'docker run --rm --gpus all ubuntu:22.04 nvidia-smi'
via the Docker socket. The NVIDIA Container Runtime injects nvidia-smi and
driver libs into any container with --gpus all, regardless of the base image.
ubuntu:22.04 is already cached on GPU nodes.
Result: GPU reported with name, memory_mb, driver_version — shows as BOUND
in the cluster UI.
nvidia-smi bind-mount failed due to Alpine vs Ubuntu glibc incompatibility.
Fix: nsenter --mount=/proc/1/ns/mnt -- nvidia-smi runs in the host's mount
namespace where glibc and all NVIDIA driver libs are present.
Requires pid: host in docker-compose.worker.yml (already has network: host).
nsenter is provided by util-linux in Alpine — already in the image.
Falls back to direct nvidia-smi call (for glibc-based containers), then
to /dev/nvidia* file scan if all attempts fail.
index.js:
- detectGpusViaSmi(): runs nvidia-smi --query-gpu=index,name,memory.total,
driver_version and parses the output into structured GPU objects with
name, memory_mb, driver, device — the same fields the cluster UI uses
to determine BOUND status
- Falls back to /dev/nvidia* file scan if nvidia-smi isn't available
docker-compose.worker.yml:
- Bind-mount /usr/bin/nvidia-smi and libnvidia-ml.so.1 from host into
node-agent container (read-only). These are the minimum binaries needed
for nvidia-smi to execute inside the container.
- Mounts are optional — Docker ignores them silently if paths don't exist
(e.g. on nodes without NVIDIA hardware)
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
executor.js:
- transcodeVideo() now accepts videoMinRate, videoMaxRate, videoBufSize
- When set, passes -minrate/-maxrate/-bufsize to FFmpeg for ABR/VBR mode
- libx264 operates with per-scene quality variation within the envelope
proxy.js:
- Target average: 750k (gpu_bitrate_mbps=0.75)
- Min: 375k (50% of target), Max: 998k (~133%), Buffer: 2× max
- Gives effective range of ~500k-1M depending on scene complexity
- Log now shows VBR min-max-avg
- GPU fallback also passes VBR params
- Default videoBitrate changed from 10M to 750k in executor.js
S3 at broadcastmgmt.cloud (RustFS/openresty) returns 403 on range
requests that include an Origin header on presigned URLs. The HMAC
signature only covers 'host' in X-Amz-SignedHeaders, so the browser's
cross-origin Origin header breaks signature validation.
Reverted: /stream and /video no longer redirect to signed S3 URLs.
Fixed: /video now pipes through Node with:
Cache-Control: private, max-age=3600
ETag and Last-Modified forwarded from S3
This means the browser caches video segments for 1h. On seek the
browser checks its cache first — only uncached byte ranges hit the
server. Combined with the 1.5Mbps proxy (was 4Mbps), seeks should
be responsive for clips under ~10 minutes.
- GET /assets/:id/stream now returns a signed S3 URL directly (4h TTL)
instead of pointing to the /video pipe endpoint. Browser streams
directly from S3 — no Node.js bottleneck, S3 handles range requests
natively for smooth seeking.
- GET /assets/:id/video now redirects (302) to a signed S3 URL.
Belt-and-suspenders: any code still calling /video gets redirected.
- proxy.js: default bitrate changed from 10Mbps to 1.5Mbps, audio
default from 192kbps to 128kbps. DB settings already updated to
1.5Mbps. Cuts proxy file size ~6x for the same quality content.
Existing proxies need re-generation at new bitrate.
The badge initially showed '0' before any poll completed. Toggling
display via JS expects an initial display:none so the badge does not
flash in the tab nav on first connect.
Root causes found:
1. Scheduler crashing every 15s: assets table has no error_message column.
Fix: remove error_message from UPDATE in scheduler.js (#66 regression).
2. Clip freezing: client-side filmstrip seek loop runs on main thread,
seeks same proxy the player is streaming → both stall → freeze.
Fix: replace browser seek loop entirely with server-side FFmpeg worker.
3. No dedicated filmstrip worker: filmstrip was never pre-built server-side.
Changes:
- services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql
Add filmstrip_s3_key TEXT column to assets table
- services/worker/src/workers/filmstrip.js (new)
BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract
28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON
array to S3 at filmstrips/<assetId>.json, stores key in DB
- services/worker/src/workers/thumbnail.js
Queue filmstrip job automatically after thumbnail completes
- services/worker/src/index.js
Register filmstrip worker (concurrency=2), export filmstripQueue
singleton, close it on SIGTERM
- services/mam-api/src/routes/assets.js
- filmstripQueue added
- POST /reprocess?type=filmstrip now supported
- GET /:id/filmstrip returns signed S3 URL for JSON frames
- services/mam-api/src/routes/jobs.js
filmstrip queue visible in Jobs UI
- services/web-ui/public/screens-asset.jsx
Replace browser seek loop with fetch of /assets/:id/filmstrip
→ fetch S3 JSON → render frames. Zero browser-side video seeking.
Right-click and Files tab re-generate via API endpoint.
- FilesTab: fetch /assets/:id/thumbnail (returns signed S3 URL JSON),
display the resolved URL in <img> instead of pointing directly at the
endpoint which returns JSON not image bytes
- Transcoding: settings updated on ZAMPP1 to gpu_transcode_enabled=false,
codec=libx264 — NVENC not available in worker container (no GPU passthrough)
The proxy worker already has a CPU fallback but this prevents the
unnecessary failed GPU attempt on every job
Two bugs:
1. Frame 0 sets currentTime=0 but probe starts at t=0 after onloadedmetadata,
so 'seeked' never fires (no position change). Promise hangs until the 15s
global timeout kills the whole build. Fix: when currentTime is already at
target (within 0.05s), call done() immediately without waiting for seeked.
2. Seeks into unbuffered regions of large MP4s can stall indefinitely.
Fix: 3s per-frame timeout captures the current decoded frame and moves on,
so a slow/stalled seek doesn't block the remaining 27 frames.
Filmstrip:
- Right-click on the filmstrip opens a context menu with
'Re-generate filmstrip' and 'Re-generate proxy'
- filmstripKey state forces the build effect to re-run on demand
without waiting for a streamUrl/totalMs change
- Context menu dismisses on click, contextmenu, and scroll
Files tab (replaces empty Versions tab):
- Proxy: status badge, S3 key path, inline video preview, re-generate button
- Hi-res master: status badge and S3 key path
- Thumbnail: status badge, S3 key path, inline thumbnail image, re-generate button
- Filmstrip: status badge, frame count, scrollable strip of first 14 frames,
re-generate button (disabled while building)
The /video endpoint requires session auth (requireAuth middleware).
crossOrigin='anonymous' strips cookies from the request → 401 → video
never loads → 15s timeout → filmstrip stays empty for all clips.
Same-origin video does not need crossOrigin for canvas drawImage — the
taint restriction only applies to cross-origin resources.
- Editor: overlay Coming Soon screen over NLE timeline (code preserved,
bumper sits at z-index 100 with backdrop blur). Links to download
ZXP and Windows installer directly from the bumper.
- Settings → Capture SDKs: new Premiere Panel section lists v1.0.0
and v1.0.1 with ZXP + Windows Installer download buttons.
Both releases embedded as static files in web-ui under /downloads/.
- nginx: /downloads/ location serves files as Content-Disposition
attachment with 24h cache.
Files added:
services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.0.zxp
services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.0-windows-setup.exe
services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.1.zxp
services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe
Bug fixes:
- #91: dockerApi() 10s socket timeout (Docker daemon hang)
- #77: await syncToAmpp() with .catch() — no longer fire-and-forget
- #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status
- #73: BullMQ orphan job cleanup on hard asset delete
- #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows
- #66: scheduler tick marks stale live assets (>2h) as error
- #63: migration 017 — partial unique index prevents concurrent live asset overwrite
- #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET
- #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy)
- #40: already fixed (All projects clears openProject)
- #64: already fixed (sourceType/needsProxy handled)
- #90: GET /jobs now includes DB jobs table (trim jobs visible in UI)
- #74: nginx Content-Type header preserved; multer 500MB file size limit
- #68: GET /upload returns in-progress ingesting assets
- #58: /stream and /video endpoints fall back to original file for all video types
- #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval
- #52: thumb-status and thumb-duration moved inside position:relative wrapper
- #50: ProjectCard gets onContextMenu handler with rename/delete menu
- #49: project context menu dismisses on contextmenu + scroll events
Features:
- #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset
Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons
UI:
- Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher,
and login — white logo pops on dark UI; inline style removed from login.html
track?.@_currentExplodedTrackIndex is invalid JS syntax — @ is not a
valid identifier character. Replaced with track?.['@_currentExplodedTrackIndex']
so the worker process no longer crashes on startup.
Four critical fixes:
- Remove overflow:hidden on tlRef so Timeline.init's scroll survives re-renders
- Don't call _renderClips() inside mousedown (was destroying event target mid-drag)
- Use refs for undo history to eliminate stale closure in onClipsChanged callback
- Change .tl-clip-area overflow:hidden to overflow:visible so pointer events reach clip edges
Dispatch df:bins-changed custom event from onBinDrop and
AssetContextMenu.moveToBin so the bin rail counts update
immediately after moving an asset into a bin.
RenameProjectModal is exported to window from screens-projects.jsx,
so Library screen must reference it via window object and use
React.createElement instead of JSX syntax.
- Fix 500 error when creating bins: missing updated_at column on bins table
(migration 013 adds the column, schema.sql updated)
- Add drag-and-drop support for moving asset cards/list rows onto bin rail items
with visual droppable highlight
- Add right-click context menu on project rail items (Rename/Delete)
- Expose RenameProjectModal via window so Library screen can reuse it
- Bins context menu already existed — was hidden by the 500 error
Add 'trim' to job_type enum, create temp_segments table with
expiry/job/asset indexes, and add conform_source_sequence_id
to assets for lineage tracking.
Closes#33
End-to-end debugging against a live Premiere Pro 2025 + auth-enabled mam-api
surfaced four real bugs that made v1.0.0 install cleanly but never load,
plus the missing auth flow. All four are fixed and the panel is verified
connected (status dot green, Reconnect button shown, project list populated).
- manifest.xml: a comment in the <Resources> block contained "--" (inside
"--enable-nodejs"/"--mixed-context"), which is illegal per the XML spec.
CEP 12's strict parser logged
ERROR XPATH Double hyphen within comment
and skipped the panel entirely. Comment rewritten without double hyphens.
- manifest.xml: lacked the Version="X.Y" attribute on <ExtensionManifest>
and used a non-standard AbstractionLayers/empty <ExtensionList/>
structure. CEP rejected it with
Unsupported Manifest version ''
Manifest rewritten to the standard CSXS 7.0 schema (ExtensionList +
DispatchInfoList + RequiredRuntimeList), matching the working AMPP
panel template.
- main.js: re-declared `const csInterface = new CSInterface()` at top
level even though CSInterface.js already declared the same binding.
CEP 12 shares script-realm lexical scope across <script> tags, so the
second const threw
Identifier 'csInterface' has already been declared
The throw fired before setupEventListeners(), so the Connect button's
click handler was never attached. This is the root cause of the
original "clicking Connect does nothing" symptom; everything else was
secondary. Removed the duplicate declaration; main.js now uses the
binding from CSInterface.js.
- No auth support against AUTH_ENABLED=true servers. mam-api supports
Bearer tokens (POST /api/v1/tokens), so added:
• API token input field (password-masked) next to Server URL
• localStorage persistence on every keystroke
• window.fetch monkey-patch that injects
Authorization: Bearer <token>
on every request whose URL starts with the configured server.
Signed S3 download URLs are NOT touched.
Drive-by fixes that came out of the same debugging pass:
- Server URL input listener was 'change' (fires on blur); switched to
'input' so typing-then-clicking-Connect immediately commits.
- restoreSettings() now strips trailing slashes from the stored URL so
older saved values like 'http://host/' stop producing //api/v1 404s.
- CSS selector `input[type="text"].server-url` didn't match the new
password input → the token field was unstyled and effectively invisible.
Generalized to `input.server-url`; restructured the connection bar into
`.connection-controls--stacked` (flex column) of two `.server-input-row`
rows so two input fields fit cleanly.
- Build scripts now parse ExtensionBundleVersion from both element form
(<ExtensionBundleVersion>X</...>) and attribute form
(ExtensionBundleVersion="X"), since the manifest rewrite switched
schemas.
Version bumped 1.0.0 → 1.0.1. New artifacts committed at
services/premiere-plugin/build/releases/v1.0.1/ (.exe 2 MB, .zxp 35 KB).
v1.0.0 left in place so editors who downloaded it can verify they're on
the broken version.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DELETE /jobs/:id was throwing "404 not found" when the operator tried to
cancel a running job. BullMQ refuses job.remove() while a job is in the
active state; the route caught that error and fell through to the
404 branch, which was misleading because the job actually exists — the
queue was just refusing to drop it from under the worker.
Fix:
- Detect 'active' state explicitly and call moveToFailed(err, '0', false)
first. Token '0' bypasses the per-worker lock check (the operator-side
cancel doesn't hold the worker lock). That transitions active -> failed
and frees the queue's concurrency slot.
- If moveToFailed itself fails (lock owned by a live worker), fall back
to job.discard() so at least the result is thrown away.
- If remove() then fails (stalled, broken state), drop the job's Redis
key directly via queue.client. Last-resort obliteration.
- Stop swallowing getJob() errors — if Redis is sad, surface it via
next(err) instead of returning a misleading 404.
- Return { cancelled: true } when the job was active, so the client
can show "Cancelled" rather than "Removed" in any future toast.
While here: thumbnail jobs now run with concurrency 4 by default
(proxy 2, conform 1, import 1 unchanged). Every queue defaulted to
concurrency 1 before, so a single stalled job blocked the entire queue.
All three are overridable via PROXY_CONCURRENCY / THUMBNAIL_CONCURRENCY
/ CONFORM_CONCURRENCY env vars for nodes with more headroom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Jobs page only exposed a delete button for queued + done jobs, so a
stalled-active job (worker died holding a BullMQ concurrency slot) had
no way out from the UI. Operators were watching the queue back up
behind a single stuck thumbnail job with no kill switch.
- Running jobs now show a "Cancel" button (red text). Confirm copy
spells out that the worker may run a few seconds longer in the
background but the queue slot frees up immediately.
- Failed jobs now show the X icon for delete in addition to the
existing Retry button.
- Both routes hit the same DELETE /jobs/:id endpoint; BullMQ's
job.remove() works on any state including stalled-active.
- handleDelete takes an optional mode ('cancel' | 'delete') only to
customise the confirm prompt and error toast wording.
Right-aligned the action cell so the Retry/Cancel/Delete buttons sit
flush right like the rest of the table's actions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this commit /public had two parallel UIs: the React SPA (index.html
+ screens-*.jsx) and a stack of pre-SPA standalone pages (home.html,
recorders.html, jobs.html, ...). The SPA replaces every standalone page,
nothing in the .jsx tree links to them, and the only outside references
were login.html redirecting to home.html and the nginx fallback pointing
at home.html.
Delete 16 standalone pages (~9.2k lines of dead markup, ~430KB on disk):
_primitives-smoke.html api-tokens.html capture.html cluster.html
containers.html edit.html editor.html home.html
jobs.html player.html projects.html recorders.html
settings.html tokens.html upload.html users.html
Keep:
index.html — the React SPA shell
login.html — the sign-in / setup screen
Wire the redirects to the SPA:
- login.html post-signin: home.html -> /
- nginx try_files fallback: /home.html -> /index.html
After this, sign-in lands the operator on the real React app instead of
the stale 2025-era home page. The Editor screen continues to embed the
separate editor service via the /editor/ nginx proxy (unaffected).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apiFetch now redirects to /login.html when the server returns 401, so
flipping AUTH_ENABLED=true on mam-api gives the user the login screen
instead of a half-loaded app that silently failed to fetch /auth/me.
While AUTH_ENABLED=false the server's /auth/me still returns a synthetic
200 user, so this branch is dormant — safe to deploy ahead of the env
flip on the server. After the flip the operator visits /login.html
(directly or via auto-redirect), runs the "Create admin account" flow
once, and lands back on the SPA with a real session.
Guards against a redirect loop if login.html itself somehow lands here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Right-click any event block to open a context menu (Edit, Cancel,
Copy schedule ID, Delete) — actions per status mirror the List view so
the two surfaces stay in lockstep. Menu is viewport-clamped and
dismisses on outside click / scroll, same pattern as the asset menu in
the Library.
Drag-to-resize works for pending schedules only (the schedules PUT
rejects edits to running rows, and terminal statuses are read-only):
- Drag the left edge to move the start time
- Drag the right edge to move the end time
- Drag the body to shift the whole block in time
All gestures snap to 15-minute increments to match the new-schedule
click snap. Minimum duration is clamped to 5 minutes; the block clamps
to the visible day on both edges. While dragging the title shows the
preview range ("Start time → end time") and the block lifts with a
project-tinted shadow.
A short pointer click (< 4px travel) still opens the edit modal — the
click and drag share the same pointerdown so the operator never has
to know which gesture they made first.
Implementation: replaces the <button> block with a <div> hosting three
zones (left handle / body / right handle). Pointer events with
setPointerCapture so drags survive losing the cursor over the block,
and pointerup demotes back to click if travel was below threshold.
Optimistic local update on resize, PUT /schedules/:id with just the
two changed time fields, refetch to reconcile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops dragonflight-premiere-panel-1.0.0-windows-setup.exe (2 MB) and
dragonflight-premiere-panel-1.0.0.zxp (35 KB) at
services/premiere-plugin/build/releases/v1.0.0/ so the binaries have
stable URLs on the forge without needing a separate release artifact
flow.
Heads-up: this commits 2 MB of binary into git history. Future bumps
should use Forgejo Releases (release assets are external to git history
and easy to delete) rather than another commit under releases/.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Time was clipping the full "done May 22 · 2:23 PM · 6h ago" string on
terminal-state rows; Progress's bar felt cramped next to the percent.
Node carries only "primary" / "—" so it can shrink, and Priority's
"normal" / "high" badge doesn't need 80px either. Net widening absorbed
by the flexible Asset column.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Jobs screen only displayed an asset name when the enqueueing code
stuffed assetName into the BullMQ job data. YouTube imports did that;
upload-triggered proxy/thumbnail jobs didn't — so everything except
YouTube showed em dashes in the Asset column.
Fix it centrally: after we collect jobs from BullMQ, look up names
in one bulk SELECT against the assets table for any job that has an
assetId but no asset_name. Applies to /jobs, /jobs/:id, and the SSE
events stream. Lookup failures fall through silently rather than
500-ing the whole list.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-to-end verification on a fresh Windows machine surfaced three issues:
1. pwsh isn't installed by default — Windows ships powershell.exe (5.1).
Switched all script invocations + docs from `pwsh` to `powershell`.
2. .NET's strict XML parser rejects manifest.xml because the <Resources>
comment legally contains `--` (inside `--enable-nodejs`/`--mixed-context`
CEF flag names). Switched build-installer.ps1 to regex extraction,
matching what build-zxp.mjs already does.
3. winget installs Inno Setup 6 to %LOCALAPPDATA%\Programs by default, not
Program Files (x86). Added the user-scope path to the ISCC.exe fallback
list.
Verified: `powershell -File build-all.ps1` produces both artifacts —
dragonflight-premiere-panel-1.0.0.zxp (35 KB, signature valid)
dragonflight-premiere-panel-1.0.0-windows-setup.exe (2 MB).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The EPG JSX components in screens-ingest.jsx ship with the YouTube branch
but the matching stylesheet got lost during the parallel-branch shuffle.
This adds the missing .epg-* block to styles-rest.css and replaces the
dead .cal-* (month-calendar) rules left over from the previous design.
What the styles cover:
- .epg-page / .epg-toolbar — top-level flex layout + date nav row
- .epg-status — sticky "on air" strip with pulse halo on the live dot
- .epg / .epg-corner / .epg-gutter / .epg-canvas-head / .epg-canvas —
the 2x2 sticky grid (top ruler + left gutter both sticky)
- .epg-ruler / .epg-ruler-tick — hour ticks
- .epg-row + .epg-block + .epg-block.live/.failed/.past — event blocks
with project-color 4px inner bar (no side-stripes; impeccable ban)
- .epg-now / .epg-now-pip — vertical hot-red now-line with broadcast glow
- .epg-week + .epg-week-day — stacked 7-day sections for week view
- .epg-empty — recorder-less / loading empty state
Also adds PRODUCT.md and DESIGN.md so future design passes have the
context files the impeccable skill requires. Both drafted from the
existing codebase (tokens, screen patterns) rather than synthesised
from a prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Premiere-running check passed a Boolean to Exec's var ResultCode (Integer)
parameter — Pascal type error. The block also did nothing useful: it only
checked but never warned or prompted. Drop it. {InstallDelete] of the legacy
folder still works through the Tasks checkbox + Check function.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Tokens screen referenced showCalc / setShowCalc in the Cost calculator
button and modal but never declared the state hook, so the component
threw ReferenceError on mount and rendered blank.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The initial pass referenced zxp-sign-cmd@0.2.2 which never shipped (latest
is 2.0.0) and used the v1.x callback API. v2 is promise-based — rewrote
build-zxp.mjs accordingly.
Also commits the freshly-generated self-signed cert + passphrase from the
first local build run. From now on every build reuses these so Adobe's
ZXP signature-continuity rule is satisfied across versions.
Verified end-to-end: `npm install && node build-zxp.mjs` produces
dist/dragonflight-premiere-panel-1.0.0.zxp (34.7 KB), signature verifies,
cert valid until 2051.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the manual robocopy / install-windows.ps1 flow with two real
distributable artifacts:
- dragonflight-premiere-panel-<version>.zxp (Mac + Win)
- dragonflight-premiere-panel-<version>-windows-setup.exe (Win)
The Windows installer copies the bundle to %APPDATA%\Adobe\CEP\extensions,
sets PlayerDebugMode=1 for CSXS 8..13, registers an uninstaller, and
offers to remove any legacy com.wilddragon.mam.panel folder so editors
don't end up with duplicate panels.
The .zxp is signed with a self-signed cert generated on first build and
committed to build/cert/ so signature continuity is preserved across
builds (Adobe rejects ZXP upgrades with a different cert fingerprint).
Also migrates the CEP bundle ID from com.wilddragon.mam.panel to
net.wilddragon.dragonflight.panel to match the wild-dragon -> dragonflight
repo rename. Manifest, .debug, CSInterface.js, install docs, and the
growing-files quickstart all updated.
build/ is normally swept by the root .gitignore; added an explicit
negation so the packaging pipeline stays tracked.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds Ingest → YouTube. UI takes a URL + project, API enqueues a BullMQ
"import" job, worker shells out to yt-dlp, lands the MP4 in S3 at the
same originals/{assetId}/... path uploads use, then hands off to the
existing proxy queue. Imported assets share one lifecycle with uploads
from that point on.
Worker container picks up yt-dlp + python3 (apk on alpine, apt on the
GPU variant). The new 'import' queue is registered in jobs.js so it
appears in the Jobs SSE stream and retry/delete work for free.
Spec: docs/superpowers/specs/2026-05-23-youtube-importer-design.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a paste-URL ingest path under Ingest → YouTube. Worker hosts
yt-dlp, downloads to S3, then hands off to the existing proxy +
thumbnail pipeline so imported assets share one lifecycle with uploads.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Nginx was serving css with `expires 1y; Cache-Control: public, immutable`,
which combined with version-less <link href="styles-rest.css"> meant every
browser permanently pinned whatever stylesheet it cached first. Users were
seeing pre-polish-round-2 CSS even after the new image was deployed —
the calendar grid rendered as a vertical stack of weekday names because
the .cal-* rules didn't exist in the cached file.
Move css into the same bucket as js: must-revalidate via ETag. Fonts,
icons, and raster assets stay in the immutable 1y bucket since they don't
change between deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Time column now anchors on the wall clock when a job is in a terminal
state — "done 2:23 PM · 5m ago" / "failed May 22 · 14:24 · 1h ago" — so the
operator can correlate with logs and other timestamps without hovering.
Queued/running jobs keep the relative-only format since their timestamp is
constantly moving. Widen the column to 180px to accommodate the longer label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- recorders: dispatch df:recorders-changed on create/start/stop/delete so the
list updates immediately instead of waiting for the 10s poll tick
- library: poll every 4s while any asset is live/processing (15s otherwise) and
listen for df:assets-changed so a stopped recorder's LIVE badge drops and
the thumbnail appears without a manual refresh
- auth: synthetic /auth/me (AUTH_ENABLED=false) now uses LOCAL_OPERATOR / USER /
USERNAME instead of hardcoding "Admin", and flags synthetic:true
- shell: Sidebar takes `me` as a prop, drops the misleading "Admin" fallback,
and surfaces an "auth off" hint when the response is synthetic
- jobs: replace the always-empty ETA column with a Time column that shows
queued/started/done/failed N ago (full timestamp on hover); widen column
- schedule: new month-calendar view (default) with events plotted on day cells
by status; clicking a day pre-fills the new-schedule modal with a 30-min
window on that day; List view kept behind a toggle
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- screens-admin.jsx S3SettingsCard: when /settings/s3 fails, log to
console and surface the message in the existing SettingsMsg banner
instead of silently returning empty fields. Also logs the response
payload on success so the next "endpoint blank" report is easier to
diagnose. (closes part of #15)
- screens-ingest.jsx recorder row: wrap the signal value in a dot+text
pair; add CSS so the dot pulses green when status=receiving and
matches the value color otherwise. The pulse is the kind of cue the
Live signal column was missing per #2.
Adds .launcher, .launcher-tile, .launcher-hero, .launcher-grid styles
plus .brand-logo replacement for the gradient-D mark in the sidebar.
The shipped img/dragon-logo.png has a light-gray background; we use
mix-blend-mode: screen so the black dragon silhouette sits on the
dark theme without showing the gray box, and a soft accent glow under
the hero version. Smaller sidebar version uses the same trick.
The sidebar header used a gradient "D" tile as a placeholder. Now it
uses the actual dragon-coiled-D logo so the brand reads consistently
between the launcher hero and the chrome.
Also adds a 'Dashboard' nav item directly under 'Home' so the
operations view is one click away.
The launcher (Home) and the operations view (Dashboard) are now
distinct routes. Home is the landing page; Dashboard is reached from
the sidebar or from the launcher's "Open dashboard" tile.
home → launcher (big-button entry into each section)
dashboard → operations view (was Home; metrics, recent activity, queue)
Crumb labels updated so Home stays one level (just the wordmark) and
Dashboard gets its own breadcrumb.
The original first-version home page (big-button launcher with the
Dragonflight wordmark) is back at /. The Frame.io-style metrics +
recent-activity layout we've been treating as "home" is now the
Dashboard, reachable from the sidebar and from the launcher's
"Open dashboard" button.
- Renames existing Home → Dashboard (all the cards, sparklines, live
feed, job-queue, cluster mini-list are unchanged).
- New Home component: hero with the dragon-coiled-D logo (existing
img/dragon-logo.png), wordmark "DRAGONFLIGHT", a tag line, and 5
big tiles (Library, Recorders, Editor, Jobs, Settings) plus a
smaller Dashboard tile. Live cluster + recorder status pip at the
bottom mirrors what's in the topbar.
- The launcher pulls /metrics/home so the tile counts ("34 assets",
"0 live", "0 running") reflect reality.
master but no browser-playable proxy
Previously the player's "Retry processing" button only appeared for
assets in status='error'. Old recorder captures (e.g. the archived
'sRT Test_…' clips from May) live as status='archived' or 'ready' with
original_s3_key set but proxy_s3_key null. The /stream endpoint
correctly returned {url: null, reason: 'no_proxy'} for those, but the
player just showed "Preview not yet available" with no path forward —
which reads as "ingest worked, won't play, no idea why."
Two changes:
1) Capture {reason, has_source} from /stream so the UI can tell why
playback isn't available.
2) Render a "Generate proxy" button (using the existing
POST /assets/:id/retry endpoint, which the backend now accepts for
any asset with original_s3_key but no proxy_s3_key) whenever the
stream lookup returned no_proxy and the source exists. Original
error-status retry path is preserved.
Closes the visible half of #1 — the user can now self-recover proxy-
less clips from the library without DB surgery.
Two changes for issue #7 (HLS cleanup + orphan reaper) and the user's
"SRT clips ingest but won't play" complaint:
1) New POST /assets/cleanup-live-orphans — lists every directory under
/live/<uuid>/ and deletes the ones whose UUIDs don't match an asset
row. These accumulate when a recorder crashes mid-capture: the live
HLS dir is created but no asset is ever finalized in the DB, so the
files just sit on disk forever.
2) POST /assets/:id/retry now also works for assets that are 'ready'
or 'archived' but have no proxy_s3_key. The original behavior (only
re-queue when status='error') made it impossible to re-generate a
proxy for older recorder captures that landed without one — the
user could see a thumbnail in the library but the player would just
show "Preview not yet available" with no retry path.
path instead of failing the video transcode
Previously IMAGE_CODECS contained the raster ffprobe codec names ('png',
'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls') but not 'svg'.
An SVG-as-asset (e.g. an architecture diagram dragged into a project) was
correctly tagged media_type='image' in the DB but ffprobe reported its
codec as 'svg', which fell through to the video branch, found
durationMs===null, and died with 'Empty or truncated source: codec=svg,
resolution=0x0'. That clogs the failed-jobs list with red rows that have
nothing to do with broken captures.
Two fixes here:
1) Add 'svg' to IMAGE_CODECS so the existing transcodeImage()/poster
path handles it.
2) Also bail to the poster path when the asset row itself says
media_type='image', even if ffprobe didn't return a codec name we
recognize (defensive — catches future formats like AVIF without
requiring an explicit catalog update).
Closes part of #13.
- Topbar search now sits on bg-2 with a stronger border, subtle inset
highlight, and a hover state. Search icon and kbd hint get more
contrast. Focus state lifts the field with a soft accent ring.
- Search results dropdown gets a slightly inset header look so the
list reads as connected to the field.
- Right-click context menu (ctx-menu) gets a stronger background,
a tighter section header, separator color tuning, and a soft
outline so it feels like a popover instead of floating text.
Library's Bins section now always renders (not just when bins exist)
with a + button that prompts for a name and POSTs /api/v1/bins with
the open project's id. Bins re-fetch on project change so the rail
shows project-scoped bins when a project is open, or global view
otherwise.
Bins list now hydrates from local state instead of stale ZAMPP_DATA
so newly-created bins appear without a full reload. Without an open
project the + button is dimmed with a helpful tooltip — "Open a
project to create a bin".
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
Walks GET endpoints for auth, projects, assets, recorders, jobs, bins,
users, groups, cluster, settings, metrics, schedules, sdk, and the
freshly added comments routes. Deep-links one asset + one recorder by
ID so per-asset endpoints (stream, thumbnail, comments) get coverage.
Prints HTTP codes inline and exits non-zero on any failure. Treats
2xx/3xx as pass; 400/401 also pass since they indicate the route
exists and auth/validation is working as designed.
Usage:
deploy/api-smoke.sh # localhost:47432
API=http://10.0.0.25:47432 deploy/api-smoke.sh
NewRecorderModal: hardened ZAMPP_DATA hydration with defensive
defaults so first-load timing doesn't blow up the modal.
- migration 010: asset_comments table (id, asset_id, user_id, body,
frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
PATCH/DELETE with author join (display_name + initials), nullable
user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
empty-state copy ('Add one below to mark a frame'), tooltips on
the timestamp and resolved buttons
Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
Guards against the brief window between app mount and the first
data load completing — empty arrays render gracefully instead
of throwing on .filter / .map.
- Schedule: if start_at is more than a minute in the past, confirm()
before submitting (operator may want to fire immediately, but
shouldn't do it accidentally)
- Recorders: generateClipName now sanitizes the recorder name so the
S3 key / SMB path / ffmpeg arg stays clean — spaces become
underscores, anything outside [A-Za-z0-9._-] is dropped, capped at 40
- Asset detail: audio mute + fullscreen buttons now key off streamUrl
state (rather than videoRef.current which is null on first render)
so they reliably appear when a stream is available
Projects:
- Per-row 3-dot menu in list view: Open / Rename / Delete (PATCH + DELETE)
- ProjectCard's bottom bar now shows real ready/in-flight/error counts
for the project's assets instead of fake 70/20 segments
- After mutations, project list refreshes from /projects + recomputes
asset counts client-side
Bins:
- GET /api/v1/bins now returns every bin across every project when
no project_id is supplied; result rows include project_name + asset_count
- Asset right-click 'Move to bin' filters to bins in the same project as
the asset and surfaces project_name as a tooltip
Jobs:
- 'Retry all failed' button in the header appears when there are
failed jobs and POSTs /retry for each one in parallel
- Failed-row error message now clips with title= tooltip so 3KB
ffmpeg stderr doesn't blow out the row layout
window.PROJECT_COLORS exposed for cross-screen access.
The AssetContextMenu in screens-library.jsx has shipped without
matching styles, so the menu rendered as raw HTML on the page. Adds
.ctx-menu / .ctx-header / .ctx-divider / .ctx-section-label / .ctx-empty
plus button + danger styles matching the existing .row-menu look.
Asset detail:
- Download now fetches /assets/:id/hires presigned URL and triggers a
named browser download instead of doing nothing
- More icon now opens a kebab menu (Copy ID, Delete permanently)
- Approve button removed (no backend); audio + fullscreen icons
in the player controls now actually toggle mute / requestFullscreen
Shell:
- Sidebar Sign-out now POSTs /auth/logout + reloads (no-op when auth disabled, by design)
- Topbar Notifications bell removed (dead, no backend)
- Topbar search wired: typing + Enter routes to Library with the term
pre-loaded into Library's own search box
- Cluster-healthy pip now polls /metrics/home every 30s so it reflects
real online-vs-total instead of always showing green
Editor:
- Dead Export / Publish / Mark in / Mark out / Add to timeline / Step
buttons are now visibly disabled with explanatory titles; a PREVIEW
badge sits next to the sequence name so the WIP state is obvious
Containers / Cluster admin:
- Logs button opens a modal with the docker tail command + Copy button
instead of a JS alert
- Restart now shows an inline toast (pending/ok/fail) instead of alerts
- Cluster Add Node / Drain / Logs replace alert() with a styled advice
modal that supports multi-line commands + Copy
- Dead Cluster topology Graph/List tab toggle removed (only Graph is
implemented anyway)
Previously the responsive rule hid only .search, leaving the dropdown
positioned on its own wrapper. Target .search-wrap so input + results
both hide together.
Adds .search-wrap / .search-results / .search-result styles for the
new topbar command-palette dropdown. Per-kind pill colors distinguish
asset / project / recorder / job / user / nav results at a glance.
Wires onOpenAsset and onOpenProject through Topbar so that selecting
an asset/project from the global search opens the asset detail or
navigates to the project view. Adds openProjectFromAnywhere helper.
Replaces the static topbar input with a working command-palette-style
search that queries ZAMPP_DATA across assets, projects, recorders,
jobs, users, and nav targets. Cmd/Ctrl+K focuses the input, arrow keys
move selection, Enter opens, Esc dismisses. Selecting an asset opens
the asset detail; project opens project view; other kinds navigate.
Proxy failures ("moov atom not found"):
- root cause: failed/aborted SRT/RTMP recordings still uploaded 0-byte
(or ftyp-only ~1KB) objects to S3, which ffmpeg can't probe
- worker proxy.js now bails on inputs < 4 KiB with a clear message
before handing the file to ffmpeg
- capture-manager.stop() returns framesReceived + empty flag
- capture shutdown handler skips POST /assets entirely on empty
sessions, instead calls new POST /assets/:id/mark-empty to flip
the pre-created live asset to 'error' with a note
Library asset right-click menu:
- new AssetContextMenu component on screens-library.jsx; right-click
any asset in grid or list view to open
- actions: Open, Rename, Move to bin (lists up to 10 bins), Remove
from bin, Copy asset ID, Delete permanently (hard=true)
- viewport-aware positioning (won't clip past window edges)
- dismisses on outside click / contextmenu / scroll
- Library now refreshes via /assets after mutations; normalizeAsset
exposed on window so the re-fetch shape matches boot
- ctx-menu styles in styles-rest.css
- POST /api/v1/assets: when transitioning from 'live' to 'processing'
with a hi-res key but no proxy, queue a proxy job instead of just
flipping status='ready'. Recorder-captured clips now get a proxy
+ thumbnail like upload-path assets do
- POST /api/v1/recorders/:id/start now accepts { clipName } in the body;
operator-supplied name (sanitized to [A-Za-z0-9 ._-], capped at 80)
overrides the auto-generated <recorder>_<timestamp> fallback
- RecorderRow gets a 'Clip name (optional)' input visible when stopped;
Enter triggers Record, value sent on POST start, cleared on stop
- New POST /api/v1/assets/:id/generate-proxy and
POST /api/v1/assets/backfill-proxies for one-shot cleanup of pre-fix
clips that have a hi-res master but no proxy
- Home: new /api/v1/metrics/home endpoint buckets last 24h of assets,
jobs done/failed into hourly counts; sparklines now render real
time-series instead of decorative sine waves
- Home stat cards are now clickable (route to relevant page) and the
delta lines show real activity ("+N added in last 24h", "N completed")
- Home live-feed tiles use HlsPreview for recorders with a live_asset_id
- Users: row 3-dot menu is now a real popover with Rename / Reset
password / Delete actions wired to PATCH /users/:id and DELETE
- Users: role is now an inline <select> that PATCHes immediately
- Users: Created column replaces fake 'last active' (no last_login
tracking yet); group count is real
- Groups tab is now functional — list groups, create, expand to
show + manage members (add/remove), delete; backed by existing
/api/v1/groups CRUD
- Policies tab is now an honest 'coming soon' stub
- New icons: key, lock, edit; new .row-menu popover styles
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
Fetches stream URL on hover after 350ms delay; renders muted autoplay
video overlay over the thumbnail. Supports both mp4 and HLS streams.
Only triggers for ready/live assets to avoid pointless API calls.
- Fetch stream URL on asset open; show <video> element for mp4/hls
- Use hls.js for live HLS streams (loaded via CDN in index.html)
- Sync video play/pause/seek/timeupdate to React state
- Show loading state while fetching stream, status message when unavailable
- Add Retry processing button for error-status assets
- totalMs derived from video metadata when available, falls back to parseDuration