feat(settings/growing): storage warning, SMB auth + CIFS mount, per-recorder growing

Implements docs/superpowers/specs/2026-05-31-storage-settings-growing-smb-design.md.

1. Storage warning banner at the top of Settings → Storage (set-once /
   path-change-corrupts-data warning).

2. Growing-files SMB credentials + system CIFS mount (Approach A):
   - settings.js: new global keys growing_smb_mount / growing_smb_username /
     growing_smb_vers; growing_smb_password is write-only (GET returns only
     growing_smb_password_exists; growing_smb_password_clear:true removes it).
   - GrowingSettingsCard: SMB mount/username/password (masked, "saved" state) +
     CIFS version fields.
   - capture Dockerfile: add cifs-utils + util-linux.
   - capture-manager: on growing start, mount //host/share at /growing using a
     root-only credentials file (creds never on the command line); unmount on
     stop; mount failure falls back to S3 streaming so a recording is never lost.
   - recorders.js: pass GROWING_SMB_* env; don't host-bind /growing when a CIFS
     mount is configured (an empty mountpoint is required).

3. Per-recorder growing mode (global toggle removed):
   - Removed the global "capture writes to local SMB share first" checkbox; the
     growing card is now SMB-infrastructure-only.
   - recorders.js reads the per-recorder recorders.growing_enabled column
     (already present from migration 014) instead of the global setting;
     RECORDER_FIELDS += growing_enabled.
   - New-recorder modal: "Growing-files mode" toggle.
   - storage.js overview: "enabled" now means the SMB landing zone is configured
     (mount source set), surfaced as smb_mount; health strip labels updated.

No DB migration required (recorders.growing_enabled exists; new settings are
key/value rows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-31 14:50:31 -04:00
parent 3122dfd1b9
commit 5968d4f681
7 changed files with 266 additions and 36 deletions

View file

@ -67,10 +67,14 @@ RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
FROM node:20-bookworm
# Runtime deps for compiled ffmpeg libs
# Runtime deps for compiled ffmpeg libs.
# cifs-utils provides mount.cifs so growing-files capture can mount the SMB
# landing-zone share inside the (privileged) container at start (Approach A).
# util-linux supplies mount/umount/mountpoint.
RUN apt-get update && apt-get install -y --no-install-recommends \
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
cifs-utils util-linux \
&& rm -rf /var/lib/apt/lists/*
# Copy compiled ffmpeg/ffprobe

View file

@ -1,5 +1,5 @@
import { spawn } from 'child_process';
import { mkdirSync } from 'node:fs';
import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';
@ -9,11 +9,76 @@ const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
// (the host-bound /growing volume is used instead, or S3 streaming if growing
// is off).
const GROWING_SMB_MOUNT = process.env.GROWING_SMB_MOUNT || '';
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
const SMB_CREDS_FILE = '/run/smb-creds';
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
// mounted, or a host bind-mount is present).
function isMounted(path) {
try { execFileSync('mountpoint', ['-q', path]); return true; }
catch { return false; }
}
// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
// file (NOT the command line) so they never appear in `ps`/process listings.
// Returns true on success (or if already mounted), false on failure — callers
// fall back to S3 streaming so a recording is never lost.
function mountGrowingShare() {
if (!GROWING_SMB_MOUNT) return false;
try {
if (isMounted(GROWING_PATH)) {
console.log('[capture] growing share already mounted at', GROWING_PATH);
return true;
}
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
writeFileSync(
SMB_CREDS_FILE,
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
{ mode: 0o600 }
);
const opts = [
`credentials=${SMB_CREDS_FILE}`,
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
`vers=${GROWING_SMB_VERS}`,
].join(',');
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
{ stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
return true;
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr);
return false;
}
}
// Best-effort unmount on session stop. Ignores "not mounted".
function unmountGrowingShare() {
if (!GROWING_SMB_MOUNT) return;
try {
if (isMounted(GROWING_PATH)) {
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] unmounted growing share at', GROWING_PATH);
}
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
console.warn('[capture] growing share unmount failed (ignored):', stderr);
}
}
// ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration.
@ -283,7 +348,15 @@ class CaptureManager {
// Growing-files: write master to the local SMB share instead of streaming
// to S3. Path is relative to the container's GROWING_PATH mount.
const growingPath = GROWING_ENABLED
//
// Approach A: if a CIFS source is configured, mount it now. A mount failure
// is non-fatal — we fall back to S3 streaming so the recording is never
// lost.
let growingActive = GROWING_ENABLED;
if (growingActive && GROWING_SMB_MOUNT) {
if (!mountGrowingShare()) growingActive = false; // fall back to S3
}
const growingPath = growingActive
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
: null;
if (growingPath) {
@ -455,6 +528,11 @@ class CaptureManager {
if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
// Release the CIFS mount (best-effort) once the ffmpeg writers are done with
// it. The promotion worker reads the staged file from the host/S3 side, not
// through this container's mount, so unmounting here is safe.
unmountGrowingShare();
try {
const uploadPromises = [currentSession.uploads.hires];
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);

View file

@ -154,6 +154,7 @@ const RECORDER_FIELDS = [
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
'growing_enabled',
];
function pickRecorderFields(body) {
@ -363,14 +364,25 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
// Growing-files mode is a global setting (settings table). When on, the
// capture container writes the master to its /growing/ mount instead of
// streaming it to S3 — Premiere can mount the SMB share and edit it live.
const growingRow = await pool.query(
`SELECT value FROM settings WHERE key = 'growing_enabled'`
);
const growingEnabled =
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true;
// Growing-files mode is a PER-RECORDER setting (recorders.growing_enabled).
// When on, the capture container writes the master to its /growing/ mount
// instead of streaming it to S3 — editors can mount the SMB share and cut it
// live. The SMB share itself (mount source + credentials) is shared
// infrastructure configured globally in Settings → Storage.
const growingEnabled = recorder.growing_enabled === true;
// Shared growing-files SMB infrastructure (global settings). Used to mount
// the CIFS share inside the capture container (services/capture mounts it
// with these credentials when GROWING_SMB_MOUNT is set).
const growingInfra = {};
{
const r = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`,
[['growing_smb_mount', 'growing_smb_username', 'growing_smb_password', 'growing_smb_vers']]
);
for (const { key, value } of r.rows) growingInfra[key] = value;
}
const smbMount = growingEnabled ? (growingInfra.growing_smb_mount || '') : '';
// Operator-supplied clip name wins over the auto-timestamped fallback.
// The Recorders UI passes this on the start request when the user types
@ -455,6 +467,13 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
`GROWING_PATH=/growing`,
// SMB mount details for the in-container CIFS mount (Approach A). Empty
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
// (or to S3 streaming if growing isn't enabled).
`GROWING_SMB_MOUNT=${smbMount}`,
`GROWING_SMB_USERNAME=${growingInfra.growing_smb_username || ''}`,
`GROWING_SMB_PASSWORD=${growingInfra.growing_smb_password || ''}`,
`GROWING_SMB_VERS=${growingInfra.growing_smb_vers || '3.0'}`,
];
// Deltacast: pass port count so the capture container can enumerate
@ -530,7 +549,15 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`);
} catch (_) { /* no /dev/deltacast* nodes on this host */ }
}
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
// /growing handling:
// - SMB mount configured → DON'T host-bind; the capture container mounts
// the CIFS share at /growing itself (Approach A). A bind-mount here
// would shadow the in-container mount.
// - growing on but no SMB mount → legacy host bind-mount fallback.
// - growing off → no /growing mount at all.
if (growingEnabled && !smbMount) {
hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
}
const localEnv = [...env];
if (useGpu) {

View file

@ -258,21 +258,45 @@ router.put('/transcoding', async (req, res, next) => {
// while it's still being written; the promotion worker later moves the
// finalized file to S3 and flips the asset to status='ready'.
const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];
// Growing-files mode is now a PER-RECORDER setting (recorders.growing_enabled);
// the legacy global `growing_enabled` key is no longer read at recorder start.
// These global keys describe the shared SMB landing-zone infrastructure only:
// - growing_path container mount point (default /growing)
// - growing_smb_url smb://... display string for editors (Premiere)
// - growing_smb_mount //host/share CIFS source the capture container mounts
// - growing_smb_username SMB user for the system-side CIFS mount
// - growing_smb_password SMB password (WRITE-ONLY; never returned)
// - growing_smb_vers CIFS protocol version (default 3.0)
// - growing_promote_after_seconds idle threshold before S3 promotion
const GROWING_KEYS = [
'growing_path', 'growing_smb_url', 'growing_smb_mount',
'growing_smb_username', 'growing_smb_vers', 'growing_promote_after_seconds',
];
// growing_smb_password is handled separately: stored on PUT but NEVER returned
// on GET (only a *_exists flag), mirroring s3_secret_key.
router.get('/growing', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`,
[GROWING_KEYS]
[[...GROWING_KEYS, 'growing_smb_password']]
);
const out = {
growing_enabled: 'false',
growing_path: '/growing',
growing_smb_url: '',
growing_smb_mount: '',
growing_smb_username: '',
growing_smb_vers: '3.0',
growing_promote_after_seconds: '8',
growing_smb_password_exists: false,
};
for (const { key, value } of result.rows) out[key] = value;
for (const { key, value } of result.rows) {
if (key === 'growing_smb_password') {
out.growing_smb_password_exists = !!(value && value.length);
} else {
out[key] = value;
}
}
res.json(out);
} catch (err) {
next(err);
@ -290,6 +314,19 @@ router.put('/growing', async (req, res, next) => {
);
}
}
// SMB password is write-only. A non-empty value sets/replaces it. To remove
// it, send growing_smb_password_clear:true. A blank/omitted password field
// leaves the stored value untouched (so operators don't retype it on every
// save).
if (req.body.growing_smb_password_clear === true) {
await pool.query(`DELETE FROM settings WHERE key = 'growing_smb_password'`);
} else if (typeof req.body.growing_smb_password === 'string' && req.body.growing_smb_password.length > 0) {
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ('growing_smb_password', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
[req.body.growing_smb_password]
);
}
res.json({ message: 'Growing-files settings saved' });
} catch (err) {
next(err);

View file

@ -14,10 +14,12 @@ const exec = promisify(execCb);
const router = express.Router();
// Defaults mirrored from settings.js so the overview never returns nulls.
// Growing-file mode is now per-recorder; "enabled" here means the shared SMB
// landing zone is CONFIGURED (a mount source is set), not a global on/off.
const GROWING_DEFAULTS = {
growing_enabled: 'false',
growing_path: '/growing',
growing_smb_url: '',
growing_smb_mount: '',
growing_promote_after_seconds: '8',
};
@ -100,7 +102,9 @@ router.get('/overview', async (req, res, next) => {
try {
// Growing files — merge defaults with whatever's in `settings`.
const growingRaw = { ...GROWING_DEFAULTS, ...(await readSettings(Object.keys(GROWING_DEFAULTS))) };
const growingEnabled = growingRaw.growing_enabled === 'true' || growingRaw.growing_enabled === true;
// "enabled" now means the shared SMB landing zone is configured (a mount
// source is set). Per-recorder toggles decide which recorders actually use it.
const growingEnabled = !!(growingRaw.growing_smb_mount && growingRaw.growing_smb_mount.trim());
const containerPath = growingRaw.growing_path || '/growing';
const mount = await probeGrowingPath(containerPath);
@ -117,6 +121,7 @@ router.get('/overview', async (req, res, next) => {
// existing deploy uses this symlink — surface it for operator context.
host_path: '/mnt/NVME/MAM/wild-dragon-growing',
smb_url: growingRaw.growing_smb_url || '',
smb_mount: growingRaw.growing_smb_mount || '',
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
exists: mount.exists,
writable: mount.writable,

View file

@ -161,6 +161,7 @@ function NewRecorderModal({ open, onClose }) {
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
const [proxyOn, setProxyOn] = React.useState(true);
const [growingOn, setGrowingOn] = React.useState(false);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null);
@ -206,6 +207,7 @@ function NewRecorderModal({ open, onClose }) {
source_type: sourceType.toLowerCase(),
project_id: projectId || undefined,
generate_proxy: proxyOn,
growing_enabled: growingOn,
recording_codec: recCodec,
recording_container: recContainer,
// Framerate + resolution are auto-detected from the source signal/stream.
@ -473,6 +475,20 @@ function NewRecorderModal({ open, onClose }) {
</div>
</div>
<div className="modal-toggle-row">
<label className="switch">
<input type="checkbox" checked={growingOn} onChange={e => setGrowingOn(e.target.checked)} />
<span className="switch-track"><span className="switch-knob" /></span>
</label>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>Growing-files mode</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
Write the live master to the SMB share so editors can cut while it's still recording.
Requires the SMB share to be configured in Settings Storage.
</div>
</div>
</div>
{proxyOn && (
<div className="modal-section">
<div className="modal-section-head"><span>Proxy</span></div>

View file

@ -1749,6 +1749,7 @@ function Settings() {
function StorageSection() {
return (
<>
<StorageWarningBanner />
<MountHealthStrip />
<S3SettingsCard />
<GrowingSettingsCard />
@ -1756,6 +1757,27 @@ function StorageSection() {
);
}
// Set-once deployment warning. Storage paths are written into asset rows and
// the S3 layout at ingest time; changing them after assets exist orphans files
// and can corrupt the library's view of where masters/proxies live.
function StorageWarningBanner() {
return (
<div role="alert" style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '14px 16px', marginBottom: 14, borderRadius: 10,
border: '1px solid var(--danger)',
background: 'color-mix(in srgb, var(--danger) 12%, transparent)',
}}>
<Icon name="alert" size={20} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em', lineHeight: 1.5, color: 'var(--text-1)' }}>
WARNING THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT.
CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS.
PLEASE USE WITH CAUTION.
</div>
</div>
);
}
function formatBytes(n) {
if (n == null || isNaN(n)) return '·';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
@ -1828,8 +1850,8 @@ function MountHealthStrip() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 12.5 }}>Growing files</strong>
{g.enabled
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'mounted' : 'unreachable'} detail={g.error || ''} />
: <span className="badge neutral">disabled</span>}
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'configured' : 'unreachable'} detail={g.error || ''} />
: <span className="badge neutral">not configured</span>}
{g.enabled && g.exists && (
<HealthPill ok={g.writable} label={g.writable ? 'writable' : 'read-only'} />
)}
@ -1842,7 +1864,8 @@ function MountHealthStrip() {
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Container</span><span className="mono">{g.container_path || '·'}</span>
<span>Host</span><span className="mono">{g.host_path || '·'}</span>
<span>SMB</span><span className="mono">{g.smb_url || '·'}</span>
<span>SMB mount</span><span className="mono">{g.smb_mount || '·'}</span>
<span>SMB (editors)</span><span className="mono">{g.smb_url || '·'}</span>
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
</div>
@ -2036,35 +2059,75 @@ function GpuSettingsCard() {
function GrowingSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [pwd, setPwd] = React.useState(''); // new password to set; '' = leave unchanged
const [pwdExists, setPwdExists] = React.useState(false);
const [clearPwd, setClearPwd] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({
growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8',
}));
window.ZAMPP_API.fetch('/settings/growing')
.then(d => { setCfg(d); setPwdExists(!!d.growing_smb_password_exists); })
.catch(() => setCfg({
growing_path: '/growing', growing_smb_url: '', growing_smb_mount: '',
growing_smb_username: '', growing_smb_vers: '3.0', growing_promote_after_seconds: '8',
}));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
const body = {
growing_path: cfg.growing_path,
growing_smb_url: cfg.growing_smb_url,
growing_smb_mount: cfg.growing_smb_mount,
growing_smb_username: cfg.growing_smb_username,
growing_smb_vers: cfg.growing_smb_vers,
growing_promote_after_seconds: cfg.growing_promote_after_seconds,
};
if (clearPwd) body.growing_smb_password_clear = true;
else if (pwd) body.growing_smb_password = pwd;
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(body) })
.then(() => {
setSaving(false); setMsg({ ok: true, text: 'Saved.' });
if (clearPwd) { setPwdExists(false); setClearPwd(false); }
else if (pwd) { setPwdExists(true); setPwd(''); }
})
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
const mountConfigured = !!(cfg.growing_smb_mount && cfg.growing_smb_mount.trim());
return (
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="Shared SMB landing zone. Enable per-recorder under Ingest → Recorders."
tag={mountConfigured ? <span className="badge success">configured</span> : <span className="badge neutral">not configured</span>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<SField label="Enable growing-file capture">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Capture writes to the local SMB share first; Premier can edit while it's still growing.</span>
</label>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 10, lineHeight: 1.5 }}>
Growing-file mode is enabled <strong style={{ color: 'var(--text-2)' }}>per recorder</strong> (New recorder Growing-files mode).
These settings describe the SMB share that capture mounts and writes the live master to.
</div>
<SField label="SMB mount source (CIFS)">
<input className="field-input mono" value={cfg.growing_smb_mount || ''} onChange={e => set('growing_smb_mount', e.target.value)} placeholder="//10.0.0.25/mam-growing" />
</SField>
<SField label="SMB username">
<input className="field-input mono" value={cfg.growing_smb_username || ''} onChange={e => set('growing_smb_username', e.target.value)} placeholder="capture" autoComplete="off" />
</SField>
<SField label="SMB password">
<input className="field-input mono" type="password" autoComplete="new-password"
value={pwd}
disabled={clearPwd}
onChange={e => setPwd(e.target.value)}
placeholder={pwdExists ? '•••••••• (saved — leave blank to keep)' : 'Enter SMB password'} />
{pwdExists && (
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--text-3)', marginTop: 4 }}>
<input type="checkbox" checked={clearPwd} onChange={e => { setClearPwd(e.target.checked); if (e.target.checked) setPwd(''); }} />
Remove saved password
</label>
)}
</SField>
<SField label="CIFS protocol version">
<input className="field-input mono" value={cfg.growing_smb_vers || ''} onChange={e => set('growing_smb_vers', e.target.value)} placeholder="3.0" />
</SField>
<SField label="Container mount path">
<input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />