Commit graph

733 commits

Author SHA1 Message Date
dbc67636b2 UXP v2.1.4: bump manifest version 2026-05-28 08:32:35 -04:00
460b590d46 UXP v2.1.4: timeline.js — replace API.requestFollow with API.requestExternal for batch relink S3 downloads 2026-05-28 08:32:18 -04:00
f3a640a7c5 UXP v2.1.4: api.js — remove redirect:manual (not supported in UXP fetch); UXP auto-follows redirects 2026-05-28 08:31:09 -04:00
baa289f6c3 UXP v2.1.4: import-flow — drop redirect:manual (not supported in UXP fetch, causes null body); use arrayBuffer() fallback if body.getReader unavailable 2026-05-28 08:30:30 -04:00
e5f218655e UXP v2.1.3 release artifact 2026-05-28 07:50:52 -04:00
8119b57b45 UXP v2.1.3: bump manifest version 2026-05-28 07:50:29 -04:00
9765fd91f7 UXP v2.1.3: main.js — fix inline relink handler (sync getActiveProject, no require inline) 2026-05-28 07:50:18 -04:00
a25e4b6071 UXP v2.1.3: timeline.js — correct Premiere DOM API calls per official docs 2026-05-28 07:48:57 -04:00
046d99f57a UXP v2.1.3: import-flow — use window.path.join, replace fs.createWriteStream with fd-based chunked write 2026-05-28 07:47:44 -04:00
fcc737e05b UXP v2.1.2 release artifact 2026-05-28 07:20:14 -04:00
8f93302f45 UXP v2.1.2: bump manifest version 2026-05-28 07:20:09 -04:00
17ca9bfc75 UXP v2.1.2: import-flow — replace path.join (not in UXP) with manual join helper 2026-05-28 07:19:46 -04:00
f8fa0fa010 UXP v2.1.1 release artifact 2026-05-28 02:25:30 -04:00
907058de83 UXP v2.1.1: bump manifest version 2026-05-28 02:25:21 -04:00
bfe0316067 UXP v2.1.1: add conform-proj-select to Conform panel 2026-05-28 02:24:58 -04:00
5d94838830 UXP v2.1.1: main.js — fix recordImport from proxy/hires return values, add conform project select, fix conform panel 2026-05-28 02:24:07 -04:00
76fff5efc2 UXP v2.1.1: import-flow.js — expose _tempPath/_streamToFile, return {localPath,safeName} from proxy/hires 2026-05-28 02:22:58 -04:00
5432c2dfa1 UXP v2.1.0 release artifact 2026-05-28 02:21:36 -04:00
b3b2655272 UXP v2.1.0: bump version in manifest 2026-05-28 02:20:43 -04:00
16366267c4 UXP v2.1.0: main.js — full rewrite, wire all panels, tabs, export, conform, relink, mount live 2026-05-28 01:01:29 -04:00
066718c968 UXP v2.1.0: timeline.js — new module: sequence read, FCP XML, export, conform, batch relink via UXP premierepro API 2026-05-28 01:00:19 -04:00
60d0b09c63 UXP v2.1.0: ui.js — add formatDuration, sanitizeFilename, slide panel helpers, escapeXml 2026-05-28 00:59:21 -04:00
2608d7a465 UXP v2.1.0: library.js — project filter, status badges, details panel, growing tab, growing poll 2026-05-28 00:58:57 -04:00
cd18988d6d UXP v2.1.0: api.js — add projects, live-path, sequences, conform, batch-trim endpoints 2026-05-28 00:57:59 -04:00
be57eb0a50 UXP v2.1.0: CSS — full rewrite, all new panels, tabs, details, badges 2026-05-28 00:57:23 -04:00
25356ca439 UXP v2.1.0: full feature parity with V1 CEP — tabs, details, export, conform, relink, mount live 2026-05-28 00:56:15 -04:00
Claude
4bea3c94f8 fix(premiere-plugin-uxp): v2.0.2 — resolve temp folder defensively (no os.tmpdir)
UXP's `os` is a stripped subset of Node's — `os.tmpdir()` isn't exposed in
the build PPro 26.0.x ships, so both Import Proxy and Import Hi-Res failed
immediately with "os.tmpdir is not a function".

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

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

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

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

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

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

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

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

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

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

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

Two changes:

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

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

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

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

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

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

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

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

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

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

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

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

screens-asset.jsx — player health + buffer visualization:
- New states: playerState ('idle'|'loading'|'playing'|'paused'|'seeking'|
  'waiting'|'stalled'|'error'), playerError, buffered (array of {start,end}
  ms from HTMLMediaElement.buffered), stallStart, stallElapsedMs
- Wired video element events: onProgress, onWaiting, onStalled, onPlaying,
  onCanPlay, onCanPlayThrough, onSeeking, onSeeked, onError
- onError captures MediaError code+message into a console.error and the
  on-screen badge so freeze causes are now visible
- Status badge overlay (top-right of player): shows SEEKING / BUFFERING /
  STALLED / ERROR + elapsed seconds since the stall began
- PlaybackBar renders buffered ranges as translucent grey segments so you
  can see what the browser has loaded vs. what's still pending — makes
  seek-related freezes immediately obvious
2026-05-26 20:25:40 +00:00