feat(recorders): 16ch SDI audio capture + per-recorder channel select + menu redesign

Audio:
- deltacast-bridge: always extract all 4 SDI audio groups (16ch), interleave to
  one 16ch s16le stream per port FIFO; format JSON reports audio_channels:16
- capture-manager: declare FIFO as 16ch input; keep first N discrete channels
  (2/8/16) via pan channelmap on the master (no downmix); HLS preview stays
  stereo. effAudioChannels drives -ac on the master container.
- config modal: Audio channels select (2/8/16)
- channel count already flows mam-api->node-agent->capture via RECORDING_AUDIO_CHANNELS

UI redesign (production craft):
- recorders grouped into per-node hardware 'rack' cards (online/offline state)
- lifecycle accent rail: grey DISABLED / green ENABLED / pulsing-red RECORDING
- promoted capture-port chip, monospaced metadata, Enable as primary CTA
- dedicated recorder CSS block; built on existing design tokens
This commit is contained in:
Zac Gaetano 2026-06-04 03:34:41 +00:00
parent de509c66ab
commit 095306d9cf
4 changed files with 434 additions and 71 deletions

View file

@ -24,7 +24,7 @@
* *
* For each port that acquires signal, emits one JSON line to stderr: * For each port that acquires signal, emits one JSON line to stderr:
* {"port":N,"width":W,"height":H,"fps_num":N,"fps_den":D, * {"port":N,"width":W,"height":H,"fps_num":N,"fps_den":D,
* "pix_fmt":"uyvy422","audio_rate":48000,"audio_channels":2, * "pix_fmt":"uyvy422","audio_rate":48000,"audio_channels":16,
* "slot_id":"deltacast-<device>-<port>"} * "slot_id":"deltacast-<device>-<port>"}
* *
* Compile with -DLEGACY_FIFO=1 to disable shm writes and fall back to * Compile with -DLEGACY_FIFO=1 to disable shm writes and fall back to
@ -198,8 +198,14 @@ static void *audio_thread(void *arg) {
PortState *ps = (PortState *)arg; PortState *ps = (PortState *)arg;
const int AUDIO_RATE = 48000; const int AUDIO_RATE = 48000;
const int CHANNELS = 2; /* The bridge ALWAYS captures the full 16 embedded channels (4 SDI audio
const size_t FRAME_BYTES = (size_t)CHANNELS * 2; /* s16le stereo */ * groups × 1 stereo pair each). Per-recorder channel selection (keep first
* N) happens downstream in the capture ffmpeg via a channelmap the bridge
* publishes one consistent 16ch s16le interleaved stream per port so a
* single FIFO serves every consumer regardless of how many channels they
* want. */
enum { GROUPS = 4, CH_PER_GROUP = 2, CHANNELS = GROUPS * CH_PER_GROUP }; /* = 16 */
const size_t FRAME_BYTES = (size_t)CHANNELS * 2; /* s16le, 16ch */
int fps_num = ps->vi.fps_num > 0 ? ps->vi.fps_num : 25; int fps_num = ps->vi.fps_num > 0 ? ps->vi.fps_num : 25;
int fps_den = ps->vi.fps_den > 0 ? ps->vi.fps_den : 1; int fps_den = ps->vi.fps_den > 0 ? ps->vi.fps_den : 1;
long samples_per_frame = ((long)AUDIO_RATE * fps_den + fps_num / 2) / fps_num; long samples_per_frame = ((long)AUDIO_RATE * fps_den + fps_num / 2) / fps_num;
@ -209,10 +215,17 @@ static void *audio_thread(void *arg) {
ULONG max_samples = VHD_GetNbSamples((VHD_VIDEOSTANDARD)ps->video_std, ULONG max_samples = VHD_GetNbSamples((VHD_VIDEOSTANDARD)ps->video_std,
(VHD_CLOCKDIVISOR)ps->clock_div, (VHD_CLOCKDIVISOR)ps->clock_div,
VHD_ASR_48000, 0); VHD_ASR_48000, 0);
/* Per-group capture buffer (2ch packed s16le) — one per SDI audio group.
* Sized for the SDK's stereo block size; we extract each group into its
* own gbuf[g] then interleave the 4 groups into the 16ch out buffer. */
ULONG block_size = VHD_GetBlockSize(VHD_AF_16, VHD_AM_STEREO); ULONG block_size = VHD_GetBlockSize(VHD_AF_16, VHD_AM_STEREO);
size_t vhd_buf_sz = ((size_t)max_samples + 64) * (block_size ? block_size : FRAME_BYTES); size_t gbuf_sz = ((size_t)max_samples + 64) * (block_size ? block_size : 4);
size_t buf_sz = vhd_buf_sz > tick_bytes ? vhd_buf_sz : tick_bytes; unsigned char *gbuf[GROUPS];
unsigned char *buf = calloc(1, buf_sz); for (int g = 0; g < GROUPS; g++) { gbuf[g] = calloc(1, gbuf_sz); if (!gbuf[g]) return NULL; }
/* Interleaved 16ch output buffer (and the silence buffer reuses it). */
size_t out_cap = (size_t)(max_samples + 64) * FRAME_BYTES;
if (out_cap < tick_bytes) out_cap = tick_bytes;
unsigned char *buf = calloc(1, out_cap);
if (!buf) return NULL; if (!buf) return NULL;
/* Open the VHD audio stream once for the lifetime of the bridge. /* Open the VHD audio stream once for the lifetime of the bridge.
@ -237,14 +250,18 @@ static void *audio_thread(void *arg) {
VHD_SetStreamProperty(stream, VHD_CORE_SP_TRANSFER_SCHEME, VHD_TRANSFER_SLAVED); VHD_SetStreamProperty(stream, VHD_CORE_SP_TRANSFER_SCHEME, VHD_TRANSFER_SLAVED);
VHD_SetStreamProperty(stream, VHD_SDI_SP_INTERFACE, iface); VHD_SetStreamProperty(stream, VHD_SDI_SP_INTERFACE, iface);
/* Configure BOTH channels of the stereo pair (group 0). The actual PCM /* Configure all 4 audio groups as stereo pairs. Each group's packed
* samples land in pAudioChannels[0].pData (packed L/R s16le). Channel * L/R s16le samples land in pAudioGroups[g].pAudioChannels[0].pData;
* [1] must declare Mode+BufferFormat so the SDK recognizes the pair. */ * channel [1] must still declare Mode+BufferFormat so the SDK
ai.pAudioGroups[0].pAudioChannels[0].Mode = VHD_AM_STEREO; * recognizes the pair. Groups with no embedded audio simply return 0
ai.pAudioGroups[0].pAudioChannels[0].BufferFormat = VHD_AF_16; * samples and are zero-filled during interleave. */
ai.pAudioGroups[0].pAudioChannels[0].pData = buf; for (int g = 0; g < GROUPS; g++) {
ai.pAudioGroups[0].pAudioChannels[1].Mode = VHD_AM_STEREO; ai.pAudioGroups[g].pAudioChannels[0].Mode = VHD_AM_STEREO;
ai.pAudioGroups[0].pAudioChannels[1].BufferFormat = VHD_AF_16; ai.pAudioGroups[g].pAudioChannels[0].BufferFormat = VHD_AF_16;
ai.pAudioGroups[g].pAudioChannels[0].pData = gbuf[g];
ai.pAudioGroups[g].pAudioChannels[1].Mode = VHD_AM_STEREO;
ai.pAudioGroups[g].pAudioChannels[1].BufferFormat = VHD_AF_16;
}
if (VHD_StartStream(stream) == VHDERR_NOERROR) { if (VHD_StartStream(stream) == VHDERR_NOERROR) {
have_vhd_audio = 1; have_vhd_audio = 1;
@ -298,10 +315,46 @@ static void *audio_thread(void *arg) {
* stream length diverge from the video stream length. */ * stream length diverge from the video stream length. */
r = VHD_LockSlotHandle(stream, &slot); r = VHD_LockSlotHandle(stream, &slot);
if (r == VHDERR_NOERROR) { if (r == VHDERR_NOERROR) {
ai.pAudioGroups[0].pAudioChannels[0].DataSize = (ULONG)buf_sz; /* Ask the SDK for up to gbuf_sz bytes per group. After
* extraction each group's DataSize holds the bytes actually
* written (2ch s16le). Group 0 paces the frame count; groups
* with no audio report 0 and are zero-filled. */
for (int g = 0; g < GROUPS; g++)
ai.pAudioGroups[g].pAudioChannels[0].DataSize = (ULONG)gbuf_sz;
if (VHD_SlotExtractAudio(slot, &ai) == VHDERR_NOERROR) { if (VHD_SlotExtractAudio(slot, &ai) == VHDERR_NOERROR) {
ULONG sz = ai.pAudioGroups[0].pAudioChannels[0].DataSize; /* Frames present = bytes from the most-populated group
if (sz > 0 && (size_t)sz <= buf_sz) out_bytes = (size_t)sz; * divided by one stereo frame (4 bytes). Real SDI audio
* is sample-aligned across groups; using the max keeps
* us robust if a quiet group returns fewer bytes. */
ULONG g0 = ai.pAudioGroups[0].pAudioChannels[0].DataSize;
size_t frames = (size_t)g0 / 4; /* 2ch * s16 = 4 bytes/frame */
for (int g = 1; g < GROUPS; g++) {
size_t gf = (size_t)ai.pAudioGroups[g].pAudioChannels[0].DataSize / 4;
if (gf > frames) frames = gf;
}
if (frames > 0) {
size_t need = frames * FRAME_BYTES;
if (need > out_cap) { frames = out_cap / FRAME_BYTES; need = frames * FRAME_BYTES; }
/* Interleave: for each sample frame, emit the 2
* samples of each group in order 16ch frame
* [G0L G0R G1L G1R G2L G2R G3L G3R]. Groups shorter
* than `frames` (or absent) contribute silence. */
int16_t *out = (int16_t *)buf;
for (size_t f = 0; f < frames; f++) {
for (int g = 0; g < GROUPS; g++) {
size_t gframes = (size_t)ai.pAudioGroups[g].pAudioChannels[0].DataSize / 4;
const int16_t *gs = (const int16_t *)gbuf[g];
if (f < gframes) {
out[f * CHANNELS + g * 2 + 0] = gs[f * 2 + 0];
out[f * CHANNELS + g * 2 + 1] = gs[f * 2 + 1];
} else {
out[f * CHANNELS + g * 2 + 0] = 0;
out[f * CHANNELS + g * 2 + 1] = 0;
}
}
}
out_bytes = need;
}
} }
VHD_UnlockSlotHandle(slot); VHD_UnlockSlotHandle(slot);
@ -360,6 +413,7 @@ static void *audio_thread(void *arg) {
VHD_CloseStreamHandle(stream); VHD_CloseStreamHandle(stream);
} }
free(buf); free(buf);
for (int g = 0; g < GROUPS; g++) free(gbuf[g]);
return NULL; return NULL;
} }
@ -760,7 +814,7 @@ int main(int argc, char *argv[]) {
"\"fps_num\":%d,\"fps_den\":%d," "\"fps_num\":%d,\"fps_den\":%d,"
"\"interlaced\":%s," "\"interlaced\":%s,"
"\"pix_fmt\":\"uyvy422\"," "\"pix_fmt\":\"uyvy422\","
"\"audio_channels\":2,\"audio_rate\":48000," "\"audio_channels\":16,\"audio_rate\":48000,"
"\"device\":%u," "\"device\":%u,"
"\"slot_id\":\"%s\"}\n", "\"slot_id\":\"%s\"}\n",
ports[pi], ports[pi],

View file

@ -662,7 +662,18 @@ class CaptureManager {
const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001'; const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const fcInterlaced = process.env.DELTACAST_INTERLACED === '1'; const fcInterlaced = process.env.DELTACAST_INTERLACED === '1';
console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} audio=${audioFifoPath}`); // The deltacast bridge now publishes a fixed 16-channel s16le stream per
// port (all 4 SDI audio groups). The recorder selects how many of those
// channels to keep in the master — RECORDING_AUDIO_CHANNELS (2/8/16),
// injected by node-agent from the recorder config. We declare the FIFO as
// 16ch on input and KEEP THE FIRST N discrete channels downstream (no
// downmix) via an audio channel-map on the encode output.
const FIFO_CHANNELS = 16;
let wantCh = parseInt(process.env.RECORDING_AUDIO_CHANNELS || '2', 10);
if (!Number.isFinite(wantCh) || wantCh < 1) wantCh = 2;
if (wantCh > FIFO_CHANNELS) wantCh = FIFO_CHANNELS;
console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} audio=${audioFifoPath} ch=${wantCh}/${FIFO_CHANNELS}`);
// Spawn fc_pipe: opens the framecache slot with its own read cursor and // Spawn fc_pipe: opens the framecache slot with its own read cursor and
// streams raw UYVY422 frames to stdout. ffmpeg reads from the pipe as // streams raw UYVY422 frames to stdout. ffmpeg reads from the pipe as
@ -698,11 +709,14 @@ class CaptureManager {
// Audio FIFO → ffmpeg input 1. Keep wallclock on audio so A/V sync // Audio FIFO → ffmpeg input 1. Keep wallclock on audio so A/V sync
// aligns by arrival time; aresample=async=1 (applied on the master // aligns by arrival time; aresample=async=1 (applied on the master
// output) resamples audio to match the video CFR timestamps. // output) resamples audio to match the video CFR timestamps.
// The FIFO carries the full 16ch the bridge publishes; channel
// SELECTION (keep first N) is applied as an output filter so the
// discrete broadcast channels are preserved, not downmixed.
'-use_wallclock_as_timestamps', '1', '-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512', '-thread_queue_size', '512',
'-f', 's16le', '-f', 's16le',
'-ar', '48000', '-ar', '48000',
'-ac', '2', '-ac', String(FIFO_CHANNELS),
'-i', audioFifoPath, '-i', audioFifoPath,
], ],
isNetwork: false, isNetwork: false,
@ -710,6 +724,11 @@ class CaptureManager {
audioFifo: null, audioFifo: null,
interlaced: fcInterlaced, interlaced: fcInterlaced,
audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */ audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */
// Number of source channels available on the FIFO, and how many the
// recorder wants kept (first N). The encode builder turns wantCh into a
// channelmap so the master holds exactly those discrete channels.
sourceAudioChannels: FIFO_CHANNELS,
wantAudioChannels: wantCh,
_fcPipeProcess: fcPipeProcess, /* stored for clean stop */ _fcPipeProcess: fcPipeProcess, /* stored for clean stop */
}; };
} }
@ -1000,10 +1019,26 @@ exit "$BMXRC"
const proxyKey = null; const proxyKey = null;
this._sessionIdForBridge = sessionId; this._sessionIdForBridge = sessionId;
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false, audioInputIndex = 0 } = await this._buildInputArgs({ const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false, audioInputIndex = 0,
sourceAudioChannels = null, wantAudioChannels = null } = await this._buildInputArgs({
sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey, sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
}); });
// Channel selection for the master: when the source FIFO carries more
// discrete channels than the recorder wants (e.g. 16ch SDI → 2ch master),
// keep the FIRST N channels as discrete streams (no downmix) via a `pan`
// filter `c0=c0|c1=c1|…`. effAudioChannels is what the master container
// actually holds and what `-ac` must declare.
const effAudioChannels = (sourceAudioChannels && wantAudioChannels)
? Math.min(wantAudioChannels, sourceAudioChannels)
: audioChannels;
const needChannelSelect = !!(sourceAudioChannels && wantAudioChannels && wantAudioChannels < sourceAudioChannels);
const channelSelectFilter = needChannelSelect
? `pan=${effAudioChannels}c|` + Array.from({ length: effAudioChannels }, (_, i) => `c${i}=c${i}`).join('|')
: null;
// Override the codec channel count so -ac matches the selected layout.
if (sourceAudioChannels && wantAudioChannels) audioChannels = effAudioChannels;
// ── Pre-roll: discard initial unstable frames ──────────────────────────── // ── Pre-roll: discard initial unstable frames ────────────────────────────
if (bridgeProcess && (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) { if (bridgeProcess && (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`); console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`);
@ -1121,11 +1156,18 @@ exit "$BMXRC"
// ffmpeg doesn't fail trying to map a nonexistent audio stream. // ffmpeg doesn't fail trying to map a nonexistent audio stream.
const hasAudio = audioInputIndex >= 0 && !isNetFcPipe; const hasAudio = audioInputIndex >= 0 && !isNetFcPipe;
const masterAudioMap = hasAudio ? ['-map', audioMap] : []; const masterAudioMap = hasAudio ? ['-map', audioMap] : [];
const masterAudioFilter = hasAudio // Master audio: optional first-N channel select (discrete, no downmix),
? ['-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0'] : []; // then async resample to lock A/V sync. Chain both into one -af.
const masterFilterChain = [
...(channelSelectFilter ? [channelSelectFilter] : []),
'aresample=async=1:min_hard_comp=0.100000:first_pts=0',
].join(',');
const masterAudioFilter = hasAudio ? ['-af', masterFilterChain] : [];
const hlsAudioMap = hasAudio ? ['-map', audioMap] : []; const hlsAudioMap = hasAudio ? ['-map', audioMap] : [];
// HLS preview is always stereo for browser playback — downmix the first
// pair regardless of how many channels the master keeps.
const hlsAudioCodec = hasAudio const hlsAudioCodec = hasAudio
? ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100'] : []; ? ['-af', 'pan=stereo|c0=c0|c1=c1', '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2'] : [];
hiresArgs = [ hiresArgs = [
...inputArgs, ...inputArgs,
'-filter_complex', filterStr, '-filter_complex', filterStr,

View file

@ -616,6 +616,7 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc'); const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
const [bitrate, setBitrate] = React.useState((recorder.recording_video_bitrate || '25').replace(/M$/i, '')); const [bitrate, setBitrate] = React.useState((recorder.recording_video_bitrate || '25').replace(/M$/i, ''));
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true); const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
const [audioCh, setAudioCh] = React.useState(recorder.recording_audio_channels || 2);
const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || ''); const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null); const [err, setErr] = React.useState(null);
@ -633,6 +634,7 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
label: label.trim() || null, label: label.trim() || null,
recording_codec: effCodec, recording_codec: effCodec,
growing_enabled: growing, growing_enabled: growing,
recording_audio_channels: Number(audioCh),
project_id: projectId || null, project_id: projectId || null,
}; };
if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M'; if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M';
@ -707,6 +709,19 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
</div> </div>
)} )}
<div className="field">
<label className="field-label">Audio channels</label>
<select className="field-input" value={audioCh} disabled={isRec}
onChange={e => setAudioCh(e.target.value)} style={{ appearance: 'auto' }}>
<option value={2}>2 stereo (first SDI pair)</option>
<option value={8}>8 first 4 SDI pairs</option>
<option value={16}>16 all 4 SDI groups</option>
</select>
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
SDI embeds up to 16 channels; the master keeps the first N (discrete, no downmix).
</div>
</div>
<div className="field"> <div className="field">
<label className="field-label">Default project</label> <label className="field-label">Default project</label>
<select className="field-input" value={projectId} disabled={isRec} <select className="field-input" value={projectId} disabled={isRec}
@ -868,22 +883,27 @@ function Recorders({ navigate, onNew }) {
</div> </div>
<div className="page-body"> <div className="page-body">
{recorders.length === 0 ? ( {recorders.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}> <div className="recorder-empty-state">
No capture hardware discovered yet. <Icon name="server" size={28} />
<div style={{ marginTop: 8, fontSize: 12 }}> <div className="recorder-empty-title">No capture hardware discovered yet</div>
<div className="recorder-empty-sub">
Recorders are auto-detected from each node's SDI / Deltacast ports as it heartbeats. Recorders are auto-detected from each node's SDI / Deltacast ports as it heartbeats.
</div> </div>
</div> </div>
) : ( ) : (
groups.map(g => ( groups.map(g => (
<div key={g.nodeId} className="recorder-node-group" style={{ marginBottom: 18, opacity: g.meta.online ? 1 : 0.55 }}> <div key={g.nodeId} className={'recorder-rack' + (g.meta.online ? '' : ' is-offline')}>
<div className="recorder-node-head" style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 2px 8px' }}> <div className="recorder-rack-head">
<Icon name="server" size={13} style={{ opacity: 0.7 }} /> <span className="recorder-rack-icon"><Icon name="server" size={15} /></span>
<span style={{ fontWeight: 600, fontSize: 13 }}>{g.meta.hostname}</span> <div className="recorder-rack-id">
<span className={'badge ' + (g.meta.online ? 'success' : 'neutral')}> <span className="recorder-rack-host">{g.meta.hostname}</span>
{g.meta.online ? 'online' : 'offline'} <span className={'recorder-rack-state ' + (g.meta.online ? 'online' : 'offline')}>
</span> <span className="recorder-rack-dot" />
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{g.list.length} ports</span> {g.meta.online ? 'online' : 'offline'}
</span>
</div>
<div className="spacer" />
<span className="recorder-rack-ports mono">{g.list.length} {g.list.length === 1 ? 'port' : 'ports'}</span>
</div> </div>
<div className="recorders-list"> <div className="recorders-list">
{g.list.map(r => ( {g.list.map(r => (
@ -1026,7 +1046,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
}; };
return ( return (
<div className={'recorder-row ' + recorder.status + (isEnabled ? '' : ' is-disabled')}> <div className={'recorder-row ' + recorder.status + (isEnabled ? (isRec ? '' : ' is-armed') : ' is-disabled')}>
{confirmModal} {confirmModal}
<div className="recorder-preview"> <div className="recorder-preview">
{isRec && recorder.live_asset_id {isRec && recorder.live_asset_id
@ -1036,11 +1056,13 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : (isEnabled ? 'video' : 'power')} size={20} style={{ opacity: 0.4 }} /></div>} : <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : (isEnabled ? 'video' : 'power')} size={20} style={{ opacity: 0.4 }} /></div>}
</div> </div>
<div className="recorder-info"> <div className="recorder-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div className="recorder-titleline">
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.displayName}</span> <span className="recorder-name">{recorder.displayName}</span>
{recorder.label && ( {recorder.label && (
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-4)' }} title="Hardware name">{recorder.hwName}</span> <span className="recorder-hw mono" title="Hardware name">{recorder.hwName}</span>
)} )}
</div>
<div className="recorder-badges">
{isRec {isRec
? <span className="badge live"><StatusDot status="recording" /> RECORDING</span> ? <span className="badge live"><StatusDot status="recording" /> RECORDING</span>
: isEnabled : isEnabled
@ -1048,15 +1070,15 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
: <span className="badge neutral">DISABLED</span>} : <span className="badge neutral">DISABLED</span>}
<span className="badge outline">{recorder.source}</span> <span className="badge outline">{recorder.source}</span>
{recorder.capturePort && ( {recorder.capturePort && (
<span className="badge outline" title="Capture port" style={{ background: 'rgba(74,158,255,0.12)', borderColor: 'rgba(74,158,255,0.4)', color: 'var(--accent)' }}> <span className="badge recorder-port-chip" title="Capture port">
<Icon name="signal" size={10} style={{ marginRight: 4, verticalAlign: -1 }} />{recorder.capturePort} <Icon name="signal" size={10} style={{ marginRight: 4, verticalAlign: -1 }} />{recorder.capturePort}
</span> </span>
)} )}
{recorder.growing && <span className="badge accent" title="Growing-file (edit-while-record)">GROWING</span>} {recorder.growing && <span className="badge accent" title="Growing-file (edit-while-record)">GROWING</span>}
</div> </div>
<div className="recorder-sub"> <div className="recorder-sub">
<span>{recorder.codec}</span><span>·</span> <span>{recorder.codec}</span><span className="recorder-sub-sep">·</span>
<span>{recorder.res}</span><span>·</span> <span>{recorder.res}</span><span className="recorder-sub-sep">·</span>
<span>{recorder.framerate}</span> <span>{recorder.framerate}</span>
</div> </div>
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>} {err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
@ -1080,58 +1102,59 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
<div className="recorder-actions"> <div className="recorder-actions">
{/* Record controls only when ENABLED (standby sidecar up) and not recording. */} {/* Record controls only when ENABLED (standby sidecar up) and not recording. */}
{isEnabled && !isRec && ( {isEnabled && !isRec && (
<> <div className="recorder-take">
{PROJECTS.length > 0 && ( {PROJECTS.length > 0 && (
<select <select
className="field-input" className="field-input recorder-take-project"
value={takeProjectId} value={takeProjectId}
onChange={e => setTakeProjectId(e.target.value)} onChange={e => setTakeProjectId(e.target.value)}
disabled={pending} disabled={pending}
style={{ width: 150, padding: '5px 8px', fontSize: 12, appearance: 'auto' }} style={{ appearance: 'auto' }}
title="Project clips go to" title="Project clips go to"
> >
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)} {PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select> </select>
)} )}
<input <input
className="field-input" className="field-input recorder-take-clip"
value={clipName} value={clipName}
onChange={e => setClipName(e.target.value)} onChange={e => setClipName(e.target.value)}
placeholder="Clip name (optional)" placeholder="Clip name (optional)"
disabled={pending} disabled={pending}
maxLength={80} maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }} onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 150, padding: '5px 8px', fontSize: 12 }}
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp." title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/> />
</> </div>
)} )}
{isRec ? ( <div className="recorder-controls">
<button className="btn danger sm" onClick={toggle} disabled={pending}> {isRec ? (
{pending ? '…' : <><span className="rec-dot" />Stop</>} <button className="btn danger sm recorder-rec-btn" onClick={toggle} disabled={pending}>
</button> {pending ? '…' : <><span className="rec-dot" />Stop</>}
) : isEnabled ? ( </button>
<button className="btn subtle sm" onClick={toggle} disabled={pending}> ) : isEnabled ? (
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>} <button className="btn subtle sm recorder-rec-btn" onClick={toggle} disabled={pending}>
</button> {pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
) : null} </button>
) : null}
{/* Enable / Disable — the lifecycle control. Hidden while recording. */} {/* Enable / Disable — the lifecycle control. Hidden while recording. */}
{!isRec && ( {!isRec && (
isEnabled isEnabled
? <button className="btn ghost sm" onClick={() => setEnabled(false)} disabled={pending} title="Stop the standby sidecar and free the capture port"> ? <button className="btn ghost sm recorder-life-btn" onClick={() => setEnabled(false)} disabled={pending} title="Stop the standby sidecar and free the capture port">
<Icon name="power" size={12} />Disable <Icon name="power" size={12} />Disable
</button> </button>
: <button className="btn primary sm" onClick={() => setEnabled(true)} disabled={pending || offline} : <button className="btn primary sm recorder-life-btn is-enable" onClick={() => setEnabled(true)} disabled={pending || offline}
title={offline ? 'Node offline — cannot enable' : 'Start the standby sidecar so this port is ready to record'}> title={offline ? 'Node offline — cannot enable' : 'Start the standby sidecar so this port is ready to record'}>
<Icon name="power" size={12} />Enable <Icon name="power" size={12} />Enable
</button> </button>
)} )}
<button className="icon-btn" onClick={onConfigure} title="Configure recorder" aria-label="Configure recorder" style={{ color: 'var(--text-3)' }}> <button className="icon-btn recorder-cfg-btn" onClick={onConfigure} title="Configure recorder" aria-label="Configure recorder">
<Icon name="settings" /> <Icon name="settings" />
</button> </button>
</div>
</div> </div>
</div> </div>
); );

View file

@ -882,3 +882,247 @@ button.btn.primary:active {
margin-bottom: 10px; margin-bottom: 10px;
font-family: var(--font-mono); font-family: var(--font-mono);
} }
/* ============================================================
Recorder menu production redesign.
Recorders are PHYSICAL capture ports grouped under their
node (a hardware "rack"). Lifecycle: DISABLED (dormant)
ENABLED/armed (live standby) RECORDING (on air). Built on
the existing design tokens, badges and .btn classes no new
design language, just elevated rhythm and signal.
============================================================ */
/* ---- Rack (node group) ---- */
.recorder-rack {
background: linear-gradient(180deg, var(--bg-1), var(--bg-0));
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 6px 6px 8px;
margin-bottom: 18px;
box-shadow: var(--shadow-card);
transition: opacity 120ms ease, border-color 120ms ease;
}
.recorder-rack.is-offline {
opacity: 0.5;
filter: saturate(0.55);
}
.recorder-rack-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 9px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.recorder-rack-icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--r-sm);
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text-3);
flex-shrink: 0;
}
.recorder-rack-id {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.recorder-rack-host {
font-size: 14px;
font-weight: 650;
letter-spacing: 0.01em;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recorder-rack-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
font-family: var(--font-mono);
flex-shrink: 0;
}
.recorder-rack-state.online { color: var(--success); }
.recorder-rack-state.offline { color: var(--text-4); }
.recorder-rack-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.recorder-rack-state.online .recorder-rack-dot {
box-shadow: 0 0 0 3px var(--success-soft);
}
.recorder-rack-ports {
font-size: 11px;
color: var(--text-4);
letter-spacing: 0.02em;
flex-shrink: 0;
}
.recorders-list { gap: 8px; padding: 0 4px; }
/* ---- Row + lifecycle states ---- */
.recorder-row {
position: relative;
padding: 12px 14px 12px 16px;
transition: border-color 120ms ease, background 120ms ease, opacity 120ms ease;
}
/* The lifecycle accent rail on the left edge of every row. */
.recorder-row::before {
content: "";
position: absolute;
left: 0; top: 8px; bottom: 8px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--border-strong);
transition: background 120ms ease, box-shadow 120ms ease;
}
/* DISABLED — dormant. Muted, recedes. Enable is the CTA. */
.recorder-row.is-disabled {
background: transparent;
border-color: var(--border);
opacity: 0.82;
}
.recorder-row.is-disabled::before { background: var(--bg-4); }
.recorder-row.is-disabled .recorder-name { color: var(--text-2); }
.recorder-row.is-disabled .recorder-preview { opacity: 0.7; }
/* ENABLED / armed — ready, live standby up. Calm but present. */
.recorder-row.is-armed {
border-color: var(--border-strong);
}
.recorder-row.is-armed::before { background: var(--success); }
/* RECORDING — on air. Hot. */
.recorder-row.recording {
border-color: rgba(255,59,48,0.4);
background:
linear-gradient(90deg, rgba(255,59,48,0.06), transparent 38%);
}
.recorder-row.recording::before {
background: var(--live);
box-shadow: 0 0 10px rgba(255,59,48,0.55);
animation: recRailPulse 1.6s ease-in-out infinite;
}
@keyframes recRailPulse {
0%, 100% { box-shadow: 0 0 8px rgba(255,59,48,0.45); }
50% { box-shadow: 0 0 16px rgba(255,59,48,0.85); }
}
.recorder-row.error::before { background: var(--danger); }
/* ---- Info column ---- */
.recorder-titleline {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.recorder-name {
font-weight: 600;
font-size: 13.5px;
color: var(--text-1);
letter-spacing: 0.01em;
}
.recorder-hw {
font-size: 10.5px;
color: var(--text-4);
}
.recorder-badges {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 2px;
}
/* Capture port chip the physical input identity. Reads as a
precise hardware tag, not a generic badge. */
.badge.recorder-port-chip {
background: var(--accent-soft);
border: 1px solid var(--accent-soft-2);
color: var(--accent-text);
font-weight: 650;
}
.recorder-sub {
font-size: 11.5px;
color: var(--text-3);
font-family: var(--font-mono);
letter-spacing: 0.01em;
}
.recorder-sub-sep { color: var(--text-4); opacity: 0.7; }
/* ---- Stats ---- */
.recorder-stats { grid-template-columns: 96px 1fr; gap: 16px; }
.recorder-stat .stat-val { color: var(--text-2); font-family: var(--font-mono); }
.recorder-row.recording .recorder-stat .stat-val.mono { color: var(--text-1); }
/* ---- Actions ---- */
.recorder-actions { gap: 8px; }
.recorder-take {
display: flex;
align-items: center;
gap: 6px;
}
.recorder-take-project,
.recorder-take-clip {
height: 26px;
padding: 0 8px;
font-size: 12px;
}
.recorder-take-project { width: 140px; }
.recorder-take-clip { width: 152px; }
.recorder-controls {
display: flex;
align-items: center;
gap: 6px;
}
.recorder-rec-btn { min-width: 84px; justify-content: center; }
/* Enable is the primary lifecycle CTA on dormant ports. */
.recorder-life-btn { min-width: 90px; justify-content: center; }
.recorder-life-btn.is-enable { font-weight: 600; }
.recorder-cfg-btn { color: var(--text-3); }
.recorder-cfg-btn:hover { color: var(--text-1); }
/* ---- Empty state ---- */
.recorder-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 64px 24px;
text-align: center;
color: var(--text-4);
background:
radial-gradient(ellipse at top, rgba(232,130,28,0.04), transparent 60%);
border: 1px dashed var(--border-strong);
border-radius: var(--r-lg);
}
.recorder-empty-title {
font-size: 14px;
font-weight: 600;
color: var(--text-2);
}
.recorder-empty-sub {
font-size: 12px;
color: var(--text-4);
max-width: 420px;
}
/* ---- Responsive: keep take controls coherent when the row stacks ---- */
@media (max-width: 1280px) {
.recorder-take { flex: 1; }
.recorder-take-project,
.recorder-take-clip { width: auto; flex: 1; min-width: 110px; }
}