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
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'}`,
|
||||
|
|
|
|||
|
|
@ -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,6 +696,19 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
|||
</div>
|
||||
<div className="recorder-actions">
|
||||
{!isRec && (
|
||||
<>
|
||||
{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}
|
||||
|
|
@ -692,9 +717,10 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
|||
disabled={pending}
|
||||
maxLength={80}
|
||||
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
|
||||
style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
|
||||
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}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue