From 095306d9cfac43d931e57a953339b908b30156de Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 4 Jun 2026 03:34:41 +0000 Subject: [PATCH] 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 --- services/capture/deltacast-bridge/main.c | 90 ++++++-- services/capture/src/capture-manager.js | 54 ++++- services/web-ui/public/screens-ingest.jsx | 117 ++++++----- services/web-ui/public/styles-fixes.css | 244 ++++++++++++++++++++++ 4 files changed, 434 insertions(+), 71 deletions(-) diff --git a/services/capture/deltacast-bridge/main.c b/services/capture/deltacast-bridge/main.c index 9c12c54..2a4076e 100644 --- a/services/capture/deltacast-bridge/main.c +++ b/services/capture/deltacast-bridge/main.c @@ -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--"} * * 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], diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index b0fc06c..02ea650 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -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, diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index ccfde15..79cad50 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -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 }) { )} +
+ + +
+ SDI embeds up to 16 channels; the master keeps the first N (discrete, no downmix). +
+
+
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 => )} )} 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." /> - +
)} - {isRec ? ( - - ) : isEnabled ? ( - - ) : null} +
+ {isRec ? ( + + ) : isEnabled ? ( + + ) : null} - {/* Enable / Disable — the lifecycle control. Hidden while recording. */} - {!isRec && ( - isEnabled - ? - : - )} + {/* Enable / Disable — the lifecycle control. Hidden while recording. */} + {!isRec && ( + isEnabled + ? + : + )} - + +
); diff --git a/services/web-ui/public/styles-fixes.css b/services/web-ui/public/styles-fixes.css index 960d904..4117765 100644 --- a/services/web-ui/public/styles-fixes.css +++ b/services/web-ui/public/styles-fixes.css @@ -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; } +}