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:
parent
de509c66ab
commit
095306d9cf
4 changed files with 434 additions and 71 deletions
|
|
@ -24,7 +24,7 @@
|
|||
*
|
||||
* For each port that acquires signal, emits one JSON line to stderr:
|
||||
* {"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>"}
|
||||
*
|
||||
* 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;
|
||||
|
||||
const int AUDIO_RATE = 48000;
|
||||
const int CHANNELS = 2;
|
||||
const size_t FRAME_BYTES = (size_t)CHANNELS * 2; /* s16le stereo */
|
||||
/* The bridge ALWAYS captures the full 16 embedded channels (4 SDI audio
|
||||
* 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_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;
|
||||
|
|
@ -209,10 +215,17 @@ static void *audio_thread(void *arg) {
|
|||
ULONG max_samples = VHD_GetNbSamples((VHD_VIDEOSTANDARD)ps->video_std,
|
||||
(VHD_CLOCKDIVISOR)ps->clock_div,
|
||||
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);
|
||||
size_t vhd_buf_sz = ((size_t)max_samples + 64) * (block_size ? block_size : FRAME_BYTES);
|
||||
size_t buf_sz = vhd_buf_sz > tick_bytes ? vhd_buf_sz : tick_bytes;
|
||||
unsigned char *buf = calloc(1, buf_sz);
|
||||
size_t gbuf_sz = ((size_t)max_samples + 64) * (block_size ? block_size : 4);
|
||||
unsigned char *gbuf[GROUPS];
|
||||
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;
|
||||
|
||||
/* 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_SDI_SP_INTERFACE, iface);
|
||||
|
||||
/* Configure BOTH channels of the stereo pair (group 0). The actual PCM
|
||||
* samples land in pAudioChannels[0].pData (packed L/R s16le). Channel
|
||||
* [1] must declare Mode+BufferFormat so the SDK recognizes the pair. */
|
||||
ai.pAudioGroups[0].pAudioChannels[0].Mode = VHD_AM_STEREO;
|
||||
ai.pAudioGroups[0].pAudioChannels[0].BufferFormat = VHD_AF_16;
|
||||
ai.pAudioGroups[0].pAudioChannels[0].pData = buf;
|
||||
ai.pAudioGroups[0].pAudioChannels[1].Mode = VHD_AM_STEREO;
|
||||
ai.pAudioGroups[0].pAudioChannels[1].BufferFormat = VHD_AF_16;
|
||||
/* Configure all 4 audio groups as stereo pairs. Each group's packed
|
||||
* L/R s16le samples land in pAudioGroups[g].pAudioChannels[0].pData;
|
||||
* channel [1] must still declare Mode+BufferFormat so the SDK
|
||||
* recognizes the pair. Groups with no embedded audio simply return 0
|
||||
* samples and are zero-filled during interleave. */
|
||||
for (int g = 0; g < GROUPS; g++) {
|
||||
ai.pAudioGroups[g].pAudioChannels[0].Mode = VHD_AM_STEREO;
|
||||
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) {
|
||||
have_vhd_audio = 1;
|
||||
|
|
@ -298,10 +315,46 @@ static void *audio_thread(void *arg) {
|
|||
* stream length diverge from the video stream length. */
|
||||
r = VHD_LockSlotHandle(stream, &slot);
|
||||
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) {
|
||||
ULONG sz = ai.pAudioGroups[0].pAudioChannels[0].DataSize;
|
||||
if (sz > 0 && (size_t)sz <= buf_sz) out_bytes = (size_t)sz;
|
||||
/* Frames present = bytes from the most-populated group
|
||||
* 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);
|
||||
|
||||
|
|
@ -360,6 +413,7 @@ static void *audio_thread(void *arg) {
|
|||
VHD_CloseStreamHandle(stream);
|
||||
}
|
||||
free(buf);
|
||||
for (int g = 0; g < GROUPS; g++) free(gbuf[g]);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
|
@ -760,7 +814,7 @@ int main(int argc, char *argv[]) {
|
|||
"\"fps_num\":%d,\"fps_den\":%d,"
|
||||
"\"interlaced\":%s,"
|
||||
"\"pix_fmt\":\"uyvy422\","
|
||||
"\"audio_channels\":2,\"audio_rate\":48000,"
|
||||
"\"audio_channels\":16,\"audio_rate\":48000,"
|
||||
"\"device\":%u,"
|
||||
"\"slot_id\":\"%s\"}\n",
|
||||
ports[pi],
|
||||
|
|
|
|||
|
|
@ -662,7 +662,18 @@ class CaptureManager {
|
|||
const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
|
||||
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
|
||||
// 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
|
||||
// aligns by arrival time; aresample=async=1 (applied on the master
|
||||
// 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',
|
||||
'-thread_queue_size', '512',
|
||||
'-f', 's16le',
|
||||
'-ar', '48000',
|
||||
'-ac', '2',
|
||||
'-ac', String(FIFO_CHANNELS),
|
||||
'-i', audioFifoPath,
|
||||
],
|
||||
isNetwork: false,
|
||||
|
|
@ -710,6 +724,11 @@ class CaptureManager {
|
|||
audioFifo: null,
|
||||
interlaced: fcInterlaced,
|
||||
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 */
|
||||
};
|
||||
}
|
||||
|
|
@ -1000,10 +1019,26 @@ exit "$BMXRC"
|
|||
const proxyKey = null;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// 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 ────────────────────────────
|
||||
if (bridgeProcess && (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
|
||||
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.
|
||||
const hasAudio = audioInputIndex >= 0 && !isNetFcPipe;
|
||||
const masterAudioMap = hasAudio ? ['-map', audioMap] : [];
|
||||
const masterAudioFilter = hasAudio
|
||||
? ['-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0'] : [];
|
||||
// Master audio: optional first-N channel select (discrete, no downmix),
|
||||
// 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] : [];
|
||||
// HLS preview is always stereo for browser playback — downmix the first
|
||||
// pair regardless of how many channels the master keeps.
|
||||
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 = [
|
||||
...inputArgs,
|
||||
'-filter_complex', filterStr,
|
||||
|
|
|
|||
|
|
@ -616,6 +616,7 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
|
|||
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
|
||||
const [bitrate, setBitrate] = React.useState((recorder.recording_video_bitrate || '25').replace(/M$/i, ''));
|
||||
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 [saving, setSaving] = React.useState(false);
|
||||
const [err, setErr] = React.useState(null);
|
||||
|
|
@ -633,6 +634,7 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
|
|||
label: label.trim() || null,
|
||||
recording_codec: effCodec,
|
||||
growing_enabled: growing,
|
||||
recording_audio_channels: Number(audioCh),
|
||||
project_id: projectId || null,
|
||||
};
|
||||
if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M';
|
||||
|
|
@ -707,6 +709,19 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) {
|
|||
</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">
|
||||
<label className="field-label">Default project</label>
|
||||
<select className="field-input" value={projectId} disabled={isRec}
|
||||
|
|
@ -868,22 +883,27 @@ function Recorders({ navigate, onNew }) {
|
|||
</div>
|
||||
<div className="page-body">
|
||||
{recorders.length === 0 ? (
|
||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
No capture hardware discovered yet.
|
||||
<div style={{ marginTop: 8, fontSize: 12 }}>
|
||||
<div className="recorder-empty-state">
|
||||
<Icon name="server" size={28} />
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
groups.map(g => (
|
||||
<div key={g.nodeId} className="recorder-node-group" style={{ marginBottom: 18, opacity: g.meta.online ? 1 : 0.55 }}>
|
||||
<div className="recorder-node-head" style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 2px 8px' }}>
|
||||
<Icon name="server" size={13} style={{ opacity: 0.7 }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{g.meta.hostname}</span>
|
||||
<span className={'badge ' + (g.meta.online ? 'success' : 'neutral')}>
|
||||
{g.meta.online ? 'online' : 'offline'}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{g.list.length} ports</span>
|
||||
<div key={g.nodeId} className={'recorder-rack' + (g.meta.online ? '' : ' is-offline')}>
|
||||
<div className="recorder-rack-head">
|
||||
<span className="recorder-rack-icon"><Icon name="server" size={15} /></span>
|
||||
<div className="recorder-rack-id">
|
||||
<span className="recorder-rack-host">{g.meta.hostname}</span>
|
||||
<span className={'recorder-rack-state ' + (g.meta.online ? 'online' : 'offline')}>
|
||||
<span className="recorder-rack-dot" />
|
||||
{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 className="recorders-list">
|
||||
{g.list.map(r => (
|
||||
|
|
@ -1026,7 +1046,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={'recorder-row ' + recorder.status + (isEnabled ? '' : ' is-disabled')}>
|
||||
<div className={'recorder-row ' + recorder.status + (isEnabled ? (isRec ? '' : ' is-armed') : ' is-disabled')}>
|
||||
{confirmModal}
|
||||
<div className="recorder-preview">
|
||||
{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>
|
||||
<div className="recorder-info">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.displayName}</span>
|
||||
<div className="recorder-titleline">
|
||||
<span className="recorder-name">{recorder.displayName}</span>
|
||||
{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
|
||||
? <span className="badge live"><StatusDot status="recording" /> RECORDING</span>
|
||||
: isEnabled
|
||||
|
|
@ -1048,15 +1070,15 @@ function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOn
|
|||
: <span className="badge neutral">DISABLED</span>}
|
||||
<span className="badge outline">{recorder.source}</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
{recorder.growing && <span className="badge accent" title="Growing-file (edit-while-record)">GROWING</span>}
|
||||
</div>
|
||||
<div className="recorder-sub">
|
||||
<span>{recorder.codec}</span><span>·</span>
|
||||
<span>{recorder.res}</span><span>·</span>
|
||||
<span>{recorder.codec}</span><span className="recorder-sub-sep">·</span>
|
||||
<span>{recorder.res}</span><span className="recorder-sub-sep">·</span>
|
||||
<span>{recorder.framerate}</span>
|
||||
</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">
|
||||
{/* Record controls only when ENABLED (standby sidecar up) and not recording. */}
|
||||
{isEnabled && !isRec && (
|
||||
<>
|
||||
<div className="recorder-take">
|
||||
{PROJECTS.length > 0 && (
|
||||
<select
|
||||
className="field-input"
|
||||
className="field-input recorder-take-project"
|
||||
value={takeProjectId}
|
||||
onChange={e => setTakeProjectId(e.target.value)}
|
||||
disabled={pending}
|
||||
style={{ width: 150, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
|
||||
style={{ 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"
|
||||
className="field-input recorder-take-clip"
|
||||
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: 150, padding: '5px 8px', fontSize: 12 }}
|
||||
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRec ? (
|
||||
<button className="btn danger sm" onClick={toggle} disabled={pending}>
|
||||
{pending ? '…' : <><span className="rec-dot" />Stop</>}
|
||||
</button>
|
||||
) : isEnabled ? (
|
||||
<button className="btn subtle sm" onClick={toggle} disabled={pending}>
|
||||
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="recorder-controls">
|
||||
{isRec ? (
|
||||
<button className="btn danger sm recorder-rec-btn" onClick={toggle} disabled={pending}>
|
||||
{pending ? '…' : <><span className="rec-dot" />Stop</>}
|
||||
</button>
|
||||
) : isEnabled ? (
|
||||
<button className="btn subtle sm recorder-rec-btn" onClick={toggle} disabled={pending}>
|
||||
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Enable / Disable — the lifecycle control. Hidden while recording. */}
|
||||
{!isRec && (
|
||||
isEnabled
|
||||
? <button className="btn ghost sm" onClick={() => setEnabled(false)} disabled={pending} title="Stop the standby sidecar and free the capture port">
|
||||
<Icon name="power" size={12} />Disable
|
||||
</button>
|
||||
: <button className="btn primary sm" onClick={() => setEnabled(true)} disabled={pending || offline}
|
||||
title={offline ? 'Node offline — cannot enable' : 'Start the standby sidecar so this port is ready to record'}>
|
||||
<Icon name="power" size={12} />Enable
|
||||
</button>
|
||||
)}
|
||||
{/* Enable / Disable — the lifecycle control. Hidden while recording. */}
|
||||
{!isRec && (
|
||||
isEnabled
|
||||
? <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
|
||||
</button>
|
||||
: <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'}>
|
||||
<Icon name="power" size={12} />Enable
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="icon-btn" onClick={onConfigure} title="Configure recorder" aria-label="Configure recorder" style={{ color: 'var(--text-3)' }}>
|
||||
<Icon name="settings" />
|
||||
</button>
|
||||
<button className="icon-btn recorder-cfg-btn" onClick={onConfigure} title="Configure recorder" aria-label="Configure recorder">
|
||||
<Icon name="settings" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -882,3 +882,247 @@ button.btn.primary:active {
|
|||
margin-bottom: 10px;
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue