fix(capture): fix DeckLink device name enumeration for SDI port 2+; add per-take project selector on Recorders page

- capture-manager.js, routes/capture.js: fix ffmpeg -sources decklink
  parse regex from v4l2 hex-address format (never matched DeckLink output)
  to correct indented-line format. Port 2+ (index 1+) was falling through
  to a wrong model-name fallback, causing ffmpeg to open the wrong input
  and produce black frames. Now logs the detected device list and the
  selected name at start.

- recorders.js (/start): accept per-take projectId override in request
  body. If provided, clips go to that project instead of the recorder's
  default project_id. Used for both the live-asset INSERT and the
  PROJECT_ID env var passed to the capture container.

- screens-ingest.jsx (RecorderRow): add project dropdown shown when
  recorder is stopped. Defaults to the recorder's configured project;
  operator can change it before hitting Record without editing the
  recorder config.
This commit is contained in:
Zac Gaetano 2026-05-28 22:25:56 +00:00
parent 1fcb927d26
commit 354731a363
5 changed files with 204 additions and 31 deletions

116
docs/WORK_LOG_2026_05.md Normal file
View file

@ -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

View file

@ -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 {

View file

@ -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) {

View file

@ -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'}`,

View file

@ -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 }) {
</div>
<div className="recorder-actions">
{!isRec && (
<input
className="field-input"
value={clipName}
onChange={e => setClipName(e.target.value)}
placeholder="Clip name (optional)"
disabled={pending}
maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/>
<>
{PROJECTS.length > 0 && (
<select
className="field-input"
value={takeProjectId}
onChange={e => setTakeProjectId(e.target.value)}
disabled={pending}
style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
title="Project clips go to"
>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
<input
className="field-input"
value={clipName}
onChange={e => setClipName(e.target.value)}
placeholder="Clip name (optional)"
disabled={pending}
maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 160, padding: '5px 8px', fontSize: 12 }}
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/>
</>
)}
{isRec
? <button className="btn danger sm" onClick={toggle} disabled={pending}>