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
Dockerfile is now a two-stage build that compiles FFmpeg from source with --enable-decklink against the Blackmagic SDK 16.x headers in services/capture/sdk/ (operator-supplied, gitignored). build-with-decklink.sh + patch_decklink.py drive the build.
docker-compose.yml mounts /dev/shm, /run/dbus, /run/systemd into mam-api, capture, web-ui so the BMD runtime can talk to the host.
capture-manager.js wraps SDI sources with -vf yadif=mode=1 (deinterlace).
recorders.html defaults to SDI source type now that we have a working DeckLink path.
Promoted 14 new tokens (--accent-hover, --signal-{good,bad,warn}-hover,
--accent-bright, --thumb-black, --overlay, --shadow, --ease-out-{quart,expo},
--dur-{fast,normal,slide}, --z-topbar) and substituted every raw oklch /
cubic-bezier / hardcoded z-index occurrence in the 12 primitive files.
cubic-bezier appearances dropped from 8 files to 0 (only in tokens.css).
Bundle byte count: 138 KB -> 139 KB. Visual regression: zero (smoke page
still renders identically).
Three bugs found during task 20 verify, all fixed:
1. Tailwind CLI does NOT read postcss.config.js. Switched Dockerfile to
npx postcss + postcss-cli so the postcss plugin chain actually runs.
2. postcss-import was not installed but app.css uses @import for the
primitive component files. Added postcss-import + cssnano (for prod
minification under --env production).
3. @import statements must come BEFORE any other rules per CSS spec.
app.css had @tailwind base/components ABOVE @import, so postcss-import
silently skipped every component @import. Moved all @imports to the
top, @tailwind directives below. Bundle went from 121KB with 0 wd-*
classes to 138KB with 116 wd-* classes.
Also added tailwind safelist for wd-/is-/nav-dev-badge so the wave-2
migration of HTML files cannot accidentally tree-shake primitives.
Fonts: Inter 400/500/600 + JetBrains Mono 400/600 (woff2 from rsms/inter and JetBrains/JetBrainsMono github).
Dockerfile: two-stage build. Stage 1 (node:20-alpine) runs tailwindcss --minify to emit public/dist/app.css. Stage 2 (nginx:alpine) ships the static result.
NOTE on task 19 (nginx caching for /dist /fonts): SKIPPED. The existing nginx.conf already caches *.css and *.woff2 for 1y immutable via the generic location ~* \\.(css|...|woff2|...)$ regex. Adding explicit /dist/ and /fonts/ blocks would be redundant.
Flex-child overflow footgun: .slide-panel-body had flex:1 and overflow-y:auto
but without min-height:0 it never shrank below content height, so the new
Master/Proxy codec blocks overflowed past the panel bottom and the footer
(Cancel / Probe / Save buttons) was unreachable. Lock the panel to 100vh,
add min-height:0 to the body. Also drop redundant margin-bottom on
.codec-block since the body already has gap spacing.
The timeline editor isn't ready yet. Replace the 49 KB prototype page
with a clean construction screen (still rendering the standard sidebar
so users can navigate away). The 'IN DEV' badge on the sidebar nav item
is injected by auth-guard.js across all pages.
Adds a tiny CSS rule + DOM patch that walks .nav-item links on every
page and appends an 'IN DEV' badge to those matching a known in-dev
page (currently just editor.html). Avoids touching all 13 HTML files
for the same single-line nav change.
- Replace flat codec dropdowns with Master/Proxy blocks, each with
Video/Audio/Container tabs.
- Replace BM1/BM2 device dropdown with cluster-node picker plus
inline BMDCards.render(...) SVG -- click a port to set device_index.
- Wire full codec field set (video bitrate, framerate, audio codec/
bitrate/channels, container) end-to-end to /api/v1/recorders.
- Auto-hide bitrate input for profile-driven codecs (ProRes, DNxHR,
PCM, FLAC); show for H.264/265/NVENC, AAC, AC-3, Opus, DNxHD.
- Resolve SDI source display in cards via /cluster/devices/blackmagic
(hostname + model + port) instead of raw device index.
Finishes the pending item from
docs/superpowers/plans/2026-05-21-cluster-codec-revamp.md.
Renders an inline SVG of the detected card (Duo 2 / Quad 2 / Mini
Recorder / Mini Monitor / UltraStudio 4K Mini, with a generic fallback)
showing each connector in its real physical position. Click to select.
Used by recorders.html for SDI source selection.
- Media panel gains a search input that filters the clip list in real time
(case-insensitive match on display_name / filename)
- Timeline toolbar shows total sequence duration (e.g. 00:05:23;14) and
frame rate, updated whenever clips change or a sequence is opened
- parseFloat() guard on state.seq.frame_rate so a NUMERIC string from
Postgres never leaks into Timeline.render() / applyHistory()
- openSequence() and applyHistory() now pass state.seq.frame_rate to
Timeline.render() instead of hardcoded 59.94 — clips render on the
correct frame grid for every sequence
- New-sequence panel gains a frame-rate selector (23.976 / 24 / 25 /
29.97 / 30 / 50 / 59.94 / 60); createNewSequence() posts frame_rate
to the API
- Press ? to open a keyboard shortcut help overlay; Escape to close
- Drop setTimeout/scheduleRefresh loop in favour of EventSource on
/api/v1/jobs/events (pushes every 2 s from the server)
- Refresh dot turns green on open, goes grey + "Reconnecting…" on error
(EventSource auto-reconnects natively)
- Type-filter is now applied client-side against the full SSE payload so
the dropdown change no longer triggers an HTTP round-trip
- killJob / retryJob / clearCompleted no longer call loadJobs(); the next
SSE push (≤2 s) reflects the change automatically
- mam-api: add GET /api/v1/assets/:id/video streaming proxy that fetches
from RustFS/S3 and pipes to browser with range-request support, bypassing
direct S3 access from Chrome
- mam-api: fix /stream route to return /video proxy URL for both proxy and
original-mp4 assets; return null cleanly for non-playable sources
- s3/client: set requestChecksumCalculation/responseChecksumValidation to
WHEN_REQUIRED to suppress x-amz-checksum-mode header on signed URLs
- editor: fix loadSourceAsset to set state.sourceAsset even when no proxy
exists (info toast instead of bail-out) so Insert/Overwrite still work
- editor: add drag-and-drop from media panel to timeline — items are now
draggable, timeline container accepts drops and calls Timeline.addClip
with the asset at playhead position
- editor: add tl-drag-over CSS highlight on timeline during drag
Double-clicking a clip name in the library shows an in-place text input.
Enter/blur commits the new display_name via PATCH; Escape cancels.
Clicking the card body or action buttons still work normally.
Adds an "All / Ready / Processing / Error / Live" pill filter row and
a "Newest / Oldest / Name / Duration / Size" sort selector to the asset
toolbar. Both operate client-side on the loaded asset list so there is
no additional API overhead. State resets to "All / Newest" whenever a
different project or bin is selected.
Tag values were inserted into innerHTML unsanitized — a tag containing
HTML would execute as markup. Switch to DOM-only construction for the
tag badges. Also bump api.js cache-buster to v=6.
Token names containing single quotes (e.g. "O'Brien's key") broke the
onclick attribute string by closing the JS string literal early.
Apply JSON.stringify+esc pattern so name is safely embedded as a
JSON string literal instead of a raw single-quoted string.
Error assets now show an amber circular-arrow action button on hover.
Clicking it calls POST /api/v1/assets/:id/retry, resets status to
'processing', and refreshes the grid — no manual DB intervention needed
when a proxy job fails.