diff --git a/docs/WORK_LOG_2026_05.md b/docs/WORK_LOG_2026_05.md new file mode 100644 index 0000000..b2311fe --- /dev/null +++ b/docs/WORK_LOG_2026_05.md @@ -0,0 +1,116 @@ +# Work Log — May 2026 + +## Summary +Session focused on auth system architecture, dashboard redesign, and audio track inspector. Multiple iterations on auth approach; settled on simplified local-account model with RBAC. Dashboard rebuilt as control-room status board. Audio tab completed with full metering and fader controls. + +## Auth System Work + +### Commits +- `002e5ac` — auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap +- `d1f9557` — auth: park login flow — circle back +- `9726dbb` — Revert "auth: top-to-bottom rework..." +- `4172b0d` — rip out entire auth/login flow + +### What Happened +Attempted comprehensive auth rewrite including: +- Local user account system with bcrypt hashing +- Role-based access control (admin/editor/viewer) +- Client tagging for audit trails +- Environment-based bootstrap (AUTH_ENABLED flag) +- Session management with PostgreSQL backing + +**Decision**: Reverted entire auth work. Reason: complexity vs. current product stage. System was over-engineered for self-hosted use case where auth is optional. + +**Current State**: Auth disabled by default (AUTH_ENABLED=false). When enabled, system returns synthetic /auth/me endpoint. No persistent user management yet. + +### Related Fixes +- `cfcbec0` — fix(auth): make AUTH_ENABLED=true workable end-to-end +- `e71c330` — fix(auth): remove manual session.save() — was suppressing Set-Cookie header +- `65684aa` — fix(auth): ensure sessions table exists + log session.save errors + +## Dashboard Redesign + +### Commits +- `a48e1d9` — dashboard: rebuild as control-room status board (on air / up next / attention / work) +- `e5e0656` — dashboard: redesign stat cards, compress header, improve density +- `5de1e3d` — dashboard: add dense stat cards, cluster bars, job rows, sparkline fixes +- `48d54a3` — dashboard: add missing dash-* CSS classes; cluster: add stat-row/stat-card CSS + +### What Changed +Transformed dashboard from generic metrics view to broadcast control-room interface: +- **On Air** section: live stream status, bitrate, duration +- **Up Next** section: queued clips/segments +- **Attention** section: warnings, errors, resource alerts +- **Work** section: active jobs, encoding progress + +Added visual components: +- Dense stat cards with icon + value + trend +- Cluster health bars (CPU, memory, disk per node) +- Job progress rows with ETA +- Sparkline charts for trend visualization + +CSS infrastructure added for consistent spacing/sizing across dashboard components. + +## Audio Tab Implementation + +### Commit +- `c48c7e6` — feat(audio-tab): full audio track inspector with meters, mute/solo, faders + +### Features +- Per-track audio meters (VU-style, real-time) +- Mute/solo buttons per track +- Fader controls (0-100 dB range) +- Master output meter +- Track naming/labeling +- Visual feedback for clipping/peaks + +## Other Work + +### Storage & Admin +- `64d739b` — feat(admin): unified Storage settings page with mount/bucket health diagnostics +- `a44d8bd` — feat(admin): live video-presence indicators on cluster DeckLink ports + +### Player & Streaming +- `a86c1c7` — fix(player): stitch S3 ranges around RustFS empty-body bug (#143) +- `d257a19` — fix(player): buffer indicator + 416 instead of 500 on out-of-range S3 +- `37247fd` — fix(video): direct S3 signed URL for streaming + proxy bitrate 1.5Mbps +- `e4d4c00` — feat(proxy): VBR 500k-1M encoding for proxy generation + +### Cluster & Hardware +- `55ff2e7` — feat(cluster): full hardware breakdown per node +- `5ff507b` — fix(node-agent): use nsenter to run nvidia-smi in host mount namespace +- `558c18e` — fix(node-agent): detect GPUs via docker run --gpus all ubuntu:22.04 +- `a6f045b` — fix(node-agent): probe GPU via Docker API async at startup, cache result + +### Release & Cleanup +- `04ce096` — chore: 1.2 ship-prep sweep — close 38 issues +- `f0f6156` — release: add v1.1.0 ZXP artifact (Growing tab + visual system alignment) + +## Blockers / Open Questions + +### Auth System +- **Decision needed**: Should auth be mandatory for production? Current design assumes optional. +- **API endpoints missing**: `/users`, `/auth/me`, `/groups` routes not yet implemented in mam-api +- **Frontend expects**: Users list, groups management, role-based UI filtering + +### Dashboard +- Real data integration needed (currently mock data) +- Cluster stats endpoint integration +- Job queue polling/WebSocket updates + +### Audio +- Backend audio processing pipeline not yet connected +- Metering data source undefined +- Fader changes need routing to encoder + +## Next Steps (Recommended) + +1. **Clarify auth requirements**: Is user management needed for v1.2? If yes, implement `/users` and `/groups` endpoints. +2. **Connect dashboard to live data**: Wire cluster stats, job queue, stream status to real endpoints. +3. **Audio backend integration**: Define audio processing pipeline and metering data flow. +4. **Testing**: Add integration tests for auth flow, dashboard data binding, audio control. + +--- + +**Session ended**: 2026-05-27 06:31 CDT +**Status**: Work logged, auth decision documented, next steps identified diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 703355a..6d858ff 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -130,23 +130,43 @@ class CaptureManager { // Default: SDI via DeckLink // device may be an integer index (0-based) or a full device name string. - // FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo (2)'). + // FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)'). // Map integer index -> name using ffmpeg -sources decklink at runtime. + // + // ffmpeg -sources decklink output format: + // Auto-detected sources for decklink: + // DeckLink Duo 2 + // DeckLink Duo 2 (2) + // Lines containing device names start with whitespace; the header line + // starts with a non-space character. Previous code used a v4l2-style + // hex-address regex that never matched DeckLink output → index 1+ always + // fell through to a wrong fallback, producing black output from port 2+. let deckLinkName = String(device); if (typeof device === 'number' || /^\d+$/.test(String(device))) { const idx = parseInt(device, 10); try { const { execSync } = await import('child_process'); - const out = execSync('ffmpeg -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 }); + const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 }); const names = []; for (const line of out.split('\n')) { - const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); - if (m) names.push(m[1]); + // Device name lines are indented (start with one or more spaces). + // Header/blank lines are skipped. + const m = line.match(/^ {2,}(.+?)\s*$/); + if (m && m[1]) names.push(m[1]); } - if (names[idx]) deckLinkName = names[idx]; - else deckLinkName = `DeckLink Duo (${idx + 1})`; - } catch (_) { - deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`; + if (names[idx]) { + deckLinkName = names[idx]; + console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`); + } else { + // Fallback: cannot determine model name without enumeration. + // Log a warning — operator should check the detected device list. + console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`); + deckLinkName = `DeckLink (${idx})`; + } + } catch (err) { + console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`); + // Pass the numeric index directly; some ffmpeg builds accept it. + deckLinkName = String(device); } } return { diff --git a/services/capture/src/routes/capture.js b/services/capture/src/routes/capture.js index 5c1a5e0..394dd9c 100644 --- a/services/capture/src/routes/capture.js +++ b/services/capture/src/routes/capture.js @@ -95,14 +95,18 @@ router.get('/devices', (req, res) => { output = error.stderr ? error.stderr.toString() : error.toString(); } - // Parse ffmpeg output for DeckLink device names - // Format: [decklink @ ...] "DeckLink Quad 2" (input #0) + // Parse ffmpeg output for DeckLink device names. + // ffmpeg -sources decklink output: + // Auto-detected sources for decklink: + // DeckLink Duo 2 + // DeckLink Duo 2 (2) + // Device name lines are indented (2+ leading spaces); header/blank lines are not. const lines = output.split('\n'); let deviceIndex = 0; for (const line of lines) { - const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); - if (match) { + const match = line.match(/^ {2,}(.+?)\s*$/); + if (match && match[1]) { devices.push({ index: deviceIndex, name: match[1], @@ -140,8 +144,8 @@ router.post('/probe', async (req, res) => { const raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 }); const devices = []; for (const line of raw.split('\n')) { - const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); - if (m) devices.push(m[1]); + const m = line.match(/^ {2,}(.+?)\s*$/); + if (m && m[1]) devices.push(m[1]); } return res.json({ ok: true, source_type, devices }); } catch (err) { diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index a2a6914..9c8e316 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -336,6 +336,13 @@ router.post('/:id/start', async (req, res, next) => { const customClipName = sanitizeClipName(req.body && req.body.clipName); const clipName = customClipName || generateClipName(recorder.name); + // Per-take project override: the Recorders UI can pass projectId on the + // start request to send clips to a different project than the recorder's + // default. Falls back to the recorder's configured project_id. + const takeProjectId = (req.body && req.body.projectId && typeof req.body.projectId === 'string') + ? req.body.projectId + : recorder.project_id; + // live-asset: create the asset row right now (status='live') so the // library shows the recording while it is happening. const assetIdLive = uuidv4(); @@ -346,7 +353,7 @@ router.post('/:id/start', async (req, res, next) => { id, project_id, bin_id, filename, display_name, status, media_type, original_s3_key, created_at, updated_at ) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`, - [assetIdLive, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.${ext}`] + [assetIdLive, takeProjectId, clipName, `projects/${takeProjectId}/masters/${clipName}.${ext}`] ); } catch (e) { console.warn('[recorders] could not pre-create live asset:', e.message); @@ -391,7 +398,7 @@ router.post('/:id/start', async (req, res, next) => { `PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`, `PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`, - `PROJECT_ID=${recorder.project_id}`, + `PROJECT_ID=${takeProjectId}`, `CLIP_NAME=${clipName}`, `ASSET_ID=${assetIdLive}`, `GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`, diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index e68dc61..aa9fb45 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -559,13 +559,21 @@ function Recorders({ navigate, onNew }) { } function RecorderRow({ recorder: initialRecorder, onRefresh }) { + const PROJECTS = window.ZAMPP_DATA?.PROJECTS || []; const [recorder, setRecorder] = React.useState(initialRecorder); const [pending, setPending] = React.useState(false); const [err, setErr] = React.useState(null); const [liveStatus, setLiveStatus] = React.useState(null); const [clipName, setClipName] = React.useState(''); + // Project override for this take. Defaults to the recorder's configured project. + const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || ''); const isRec = recorder.status === 'recording'; + // Keep takeProjectId in sync if the recorder row changes (e.g. after a refresh). + React.useEffect(() => { + setTakeProjectId(initialRecorder.project_id || PROJECTS[0]?.id || ''); + }, [initialRecorder.id]); + React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]); // Poll the status endpoint every 3s while recording for live feedback. @@ -605,14 +613,18 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { setPending(true); setErr(null); setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' })); - // Ship the operator-typed clip name on start; stop has no body. - const body = (action === 'start' && clipName.trim()) - ? JSON.stringify({ clipName: clipName.trim() }) + // Ship the operator-typed clip name and project override on start; stop has no body. + const body = action === 'start' + ? JSON.stringify({ + ...(clipName.trim() ? { clipName: clipName.trim() } : {}), + ...(takeProjectId ? { projectId: takeProjectId } : {}), + }) : undefined; window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body }) .then(() => { setPending(false); - // Clear the input on a successful stop so the next take starts fresh. + // Clear the clip name on a successful stop so the next take starts fresh. + // Leave takeProjectId as-is (operator likely wants the same project for the next take). if (action === 'stop') setClipName(''); onRefresh(); window.dispatchEvent(new CustomEvent('df:recorders-changed')); @@ -684,17 +696,31 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {