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:
parent
1fcb927d26
commit
354731a363
5 changed files with 204 additions and 31 deletions
116
docs/WORK_LOG_2026_05.md
Normal file
116
docs/WORK_LOG_2026_05.md
Normal 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
|
||||||
|
|
@ -130,23 +130,43 @@ class CaptureManager {
|
||||||
|
|
||||||
// Default: SDI via DeckLink
|
// Default: SDI via DeckLink
|
||||||
// device may be an integer index (0-based) or a full device name string.
|
// 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.
|
// 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);
|
let deckLinkName = String(device);
|
||||||
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
|
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
|
||||||
const idx = parseInt(device, 10);
|
const idx = parseInt(device, 10);
|
||||||
try {
|
try {
|
||||||
const { execSync } = await import('child_process');
|
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 = [];
|
const names = [];
|
||||||
for (const line of out.split('\n')) {
|
for (const line of out.split('\n')) {
|
||||||
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
// Device name lines are indented (start with one or more spaces).
|
||||||
if (m) names.push(m[1]);
|
// 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];
|
if (names[idx]) {
|
||||||
else deckLinkName = `DeckLink Duo (${idx + 1})`;
|
deckLinkName = names[idx];
|
||||||
} catch (_) {
|
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
|
||||||
deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`;
|
} 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 {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -95,14 +95,18 @@ router.get('/devices', (req, res) => {
|
||||||
output = error.stderr ? error.stderr.toString() : error.toString();
|
output = error.stderr ? error.stderr.toString() : error.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse ffmpeg output for DeckLink device names
|
// Parse ffmpeg output for DeckLink device names.
|
||||||
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
// 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');
|
const lines = output.split('\n');
|
||||||
let deviceIndex = 0;
|
let deviceIndex = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
const match = line.match(/^ {2,}(.+?)\s*$/);
|
||||||
if (match) {
|
if (match && match[1]) {
|
||||||
devices.push({
|
devices.push({
|
||||||
index: deviceIndex,
|
index: deviceIndex,
|
||||||
name: match[1],
|
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 raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||||
const devices = [];
|
const devices = [];
|
||||||
for (const line of raw.split('\n')) {
|
for (const line of raw.split('\n')) {
|
||||||
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
const m = line.match(/^ {2,}(.+?)\s*$/);
|
||||||
if (m) devices.push(m[1]);
|
if (m && m[1]) devices.push(m[1]);
|
||||||
}
|
}
|
||||||
return res.json({ ok: true, source_type, devices });
|
return res.json({ ok: true, source_type, devices });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,13 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
const customClipName = sanitizeClipName(req.body && req.body.clipName);
|
const customClipName = sanitizeClipName(req.body && req.body.clipName);
|
||||||
const clipName = customClipName || generateClipName(recorder.name);
|
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
|
// live-asset: create the asset row right now (status='live') so the
|
||||||
// library shows the recording while it is happening.
|
// library shows the recording while it is happening.
|
||||||
const assetIdLive = uuidv4();
|
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,
|
id, project_id, bin_id, filename, display_name, status, media_type,
|
||||||
original_s3_key, created_at, updated_at
|
original_s3_key, created_at, updated_at
|
||||||
) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
|
) 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) {
|
} catch (e) {
|
||||||
console.warn('[recorders] could not pre-create live asset:', e.message);
|
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_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
|
||||||
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
|
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
|
||||||
|
|
||||||
`PROJECT_ID=${recorder.project_id}`,
|
`PROJECT_ID=${takeProjectId}`,
|
||||||
`CLIP_NAME=${clipName}`,
|
`CLIP_NAME=${clipName}`,
|
||||||
`ASSET_ID=${assetIdLive}`,
|
`ASSET_ID=${assetIdLive}`,
|
||||||
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
||||||
|
|
|
||||||
|
|
@ -559,13 +559,21 @@ function Recorders({ navigate, onNew }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
|
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
||||||
const [recorder, setRecorder] = React.useState(initialRecorder);
|
const [recorder, setRecorder] = React.useState(initialRecorder);
|
||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
const [err, setErr] = React.useState(null);
|
const [err, setErr] = React.useState(null);
|
||||||
const [liveStatus, setLiveStatus] = React.useState(null);
|
const [liveStatus, setLiveStatus] = React.useState(null);
|
||||||
const [clipName, setClipName] = React.useState('');
|
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';
|
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]);
|
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
|
||||||
|
|
||||||
// Poll the status endpoint every 3s while recording for live feedback.
|
// Poll the status endpoint every 3s while recording for live feedback.
|
||||||
|
|
@ -605,14 +613,18 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
||||||
// Ship the operator-typed clip name on start; stop has no body.
|
// Ship the operator-typed clip name and project override on start; stop has no body.
|
||||||
const body = (action === 'start' && clipName.trim())
|
const body = action === 'start'
|
||||||
? JSON.stringify({ clipName: clipName.trim() })
|
? JSON.stringify({
|
||||||
|
...(clipName.trim() ? { clipName: clipName.trim() } : {}),
|
||||||
|
...(takeProjectId ? { projectId: takeProjectId } : {}),
|
||||||
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
|
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setPending(false);
|
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('');
|
if (action === 'stop') setClipName('');
|
||||||
onRefresh();
|
onRefresh();
|
||||||
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
|
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
|
||||||
|
|
@ -684,17 +696,31 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="recorder-actions">
|
<div className="recorder-actions">
|
||||||
{!isRec && (
|
{!isRec && (
|
||||||
<input
|
<>
|
||||||
className="field-input"
|
{PROJECTS.length > 0 && (
|
||||||
value={clipName}
|
<select
|
||||||
onChange={e => setClipName(e.target.value)}
|
className="field-input"
|
||||||
placeholder="Clip name (optional)"
|
value={takeProjectId}
|
||||||
disabled={pending}
|
onChange={e => setTakeProjectId(e.target.value)}
|
||||||
maxLength={80}
|
disabled={pending}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
|
style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
|
||||||
style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
|
title="Project clips go to"
|
||||||
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
|
>
|
||||||
/>
|
{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
|
{isRec
|
||||||
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue