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.
confirmDeleteUser and confirmDeleteGroup were building onclick handlers
like onclick="confirmDeleteUser('id','NAME')" using esc() which doesn't
escape single quotes. Usernames or group names containing ' would break
the JS string; a crafted value like `'; alert(1)//` is stored XSS.
Fix: use JSON.stringify(value) to produce a properly-escaped double-quoted
JS string literal, then esc() to HTML-encode the surrounding quotes for
safe embedding in the HTML attribute. Same technique now used in both
renderUsers() and renderGroups().
binCard() was building onclick="renameBinPrompt('id', 'NAME')" by
calling esc() then .replace(/'/g, "\\'"). The problem: esc() converts
' to ', so the replace never fires on raw single quotes. When the
HTML parser evaluates the attribute it decodes ' back to ', breaking
the JS string — and for injected payloads like `'; alert(1)//` this is
stored XSS.
Fix: use JSON.stringify(b.name) to produce a properly-escaped double-
quoted JS string literal, then esc() to HTML-encode the surrounding
double-quotes for safe embedding in the HTML attribute.
When duration_ms is known, dragging the right-trim handle past the end
of the source clip could push timeline_out_frames beyond what the source
material covers. Cap the delta so neither timeline_out_frames nor
source_out_frames can extend past the available source frames.
Also changed assetFrames fallback from origSrcOut (prevents any extension
when duration is unknown) to null, so the guard is simply skipped when
we don't have duration metadata.
The previous algorithm used `if (rem >= DROP)` (i.e. rem >= 4) to decide
whether to advance to the next minute group. This fired immediately at
frame 4, still inside minute 0 of the 10-minute non-drop group, producing
00:01:00;00 for what should be 00:00:00;04. Every timecode display in
the editor was wrong for any position past the first four frames.
Each 10-minute block has one 3600-frame non-drop minute followed by nine
3596-frame drop minutes. The fix checks `rem < FRAMES_FIRST_MIN` (3600)
to identify the non-drop minute, then subtracts it before dividing into
drop-minute slots. Frame labels within drop minutes are shifted by DROP
(+4) so the first usable label is :00;04 as per SMPTE 12M.
V/C/H key shortcuts called updateToolbarActive() which only updated button
visual state — Timeline.setTool() was never called so the cursor stayed on
the previous tool. Fix by calling Timeline.setTool() inside updateToolbarActive.
Also bump api.js reference to ?v=6 to match other pages.