  const hasAudio = (info.streams || []).some(s => s.codec_type === 'audio');

  return {
    fps,
    codec:          videoStream?.codec_name  || null,
    resolution:     videoStream ? `${videoStream.width}x${videoStream.height}` : null,
    durationMs:     fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
    fileSizeBytes:  fmt.size     ? parseInt(fmt.size, 10)                       : null,
    hasAudio,
  };
};

import { execFile } from 'child_process';
import { promisify } from 'util';

const execFileAsync = promisify(execFile);

export const runFFmpeg = async (args, options = {}) => {
  const { stdio = 'pipe', ...otherOptions } = options;
  try {
    const { stdout, stderr } = await execFileAsync('ffmpeg', args, {
      stdio,
      ...otherOptions,
    });
    return { stdout, stderr };
  } catch (error) {
    throw new Error(`FFmpeg error: ${error.message}\nStderr: ${error.stderr}`);
  }
};

const runFFprobe = async (args) => {
  try {
    const { stdout } = await execFileAsync('ffprobe', args);
    return { stdout };
  } catch (error) {
    throw new Error(`FFprobe error: ${error.message}\nStderr: ${error.stderr}`);
  }
};

export const getMediaDuration = async (inputPath) => {
  const args = [
    '-v', 'error',
    '-show_entries', 'format=duration',
    '-of', 'default=noprint_wrappers=1:nokey=1',
    inputPath,
  ];
  const { stdout } = await runFFprobe(args);
  return parseFloat(stdout.trim());
};

/**
 * Return structured media metadata for an input file.
 * Result shape:
 *   { fps, codec, resolution, durationMs, fileSizeBytes }
 * Any field may be null if ffprobe cannot determine it.
 */
export const getMediaInfo = async (inputPath) => {
  const args = [
    '-v', 'quiet',
    '-print_format', 'json',
    '-show_streams',
    '-show_format',
    inputPath,
  ];
  const { stdout } = await runFFprobe(args);
  const info = JSON.parse(stdout);

  const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
  const fmt         = info.format || {};

  let fps = null;
  if (videoStream?.r_frame_rate) {
    const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
    if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
  }

  return {
    fps,
    codec:          videoStream?.codec_name  || null,
    resolution:     videoStream ? `${videoStream.width}x${videoStream.height}` : null,
    durationMs:     fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
    fileSizeBytes:  fmt.size     ? parseInt(fmt.size, 10)                       : null,
  };
};

export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
  const args = [
    '-ss', timeCode,
    '-i', inputPath,
    '-vframes', '1',
    '-q:v', '2',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};

export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
  const {
    videoCodec = 'libx264',
    videoPreset = 'fast',
    videoBitrate = '10M',
    audioCodec = 'aac',
    audioBitrate = '192k',
    hasAudio = true,
  } = options;

  // libx264 / yuv420p require even width AND height. Captured frames from SDI
  // or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125). Force-even
  // before pixel-format conversion so the encoder never sees odd dimensions.
  const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";

  // analyzeduration/probesize must be set BEFORE -i. Some ProRes captures
  // write unusual timebases (60k tbn) that ffmpeg cannot resolve with the
  // default 5MB probe — bump to 100MB so we always read enough of the file.
  const args = [
    '-analyzeduration', '100M',
    '-probesize',       '100M',
    '-i', inputPath,
    '-vf', vf,
    '-c:v', videoCodec,
    '-preset', videoPreset,
    '-b:v', videoBitrate,
  ];

  if (hasAudio) {
    args.push('-c:a', audioCodec, '-b:a', audioBitrate);
  } else {
    args.push('-an');
  }

  args.push('-movflags', '+faststart', '-y', outputPath);

  await runFFmpeg(args);
};

// Single-frame poster — used as a fallback "proxy" for still-image assets
// so the library can show them without a transcoded video.
export const transcodeImage = async (inputPath, outputPath) => {
  await runFFmpeg([
    '-i', inputPath,
    '-vf', "scale='min(1920,iw)':-2",
    '-q:v', '3',
    '-y', outputPath,
  ]);
};

export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
  const args = [
    '-i', inputPath,
    '-ss', inPoint,
    '-to', outPoint,
    '-c', 'copy',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};

export const concatSegments = async (segmentListFile, outputPath) => {
  const args = [
    '-f', 'concat',
    '-safe', '0',
    '-i', segmentListFile,
    '-c', 'copy',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};

+  const hasAudio = (info.streams || []).some(s => s.codec_type === 'audio');

codec:          videoStream?.codec_name  || null,


The file /tmp/wd/wild-dragon/services/worker/src/workers/proxy.js has been updated successfully. (file state is current in your context — no need to Read it back)

    // Extract source metadata (fps, codec, resolution, duration, size, audio)
    await job.updateProgress(20);
    let mediaInfo = {};
    let hasAudio = true;
    try {
      mediaInfo = await getMediaInfo(inputPath);
      hasAudio = !!mediaInfo.hasAudio;
      console.log(`[proxy] Metadata for ${assetId}: ${JSON.stringify(mediaInfo)}`);
    } catch (err) {
      console.warn(`[proxy] getMediaInfo failed for ${assetId}: ${err.message}`);
    }

    // Still images skip the video proxy — they have no temporal stream and
    // x264 with a one-frame PNG input fails (Could not open encoder before EOF).
    // Generate a scaled JPEG poster instead; the thumbnail job will downsize it.
    const isImage = mediaInfo.codec && IMAGE_CODECS.has(mediaInfo.codec.toLowerCase());

    if (isImage) {
      const imageOutputKey = outputKey.replace(/\.mp4$/, '.jpg');
      const imageOutputPath = outputPath.replace(/\.mp4$/, '.jpg');
      console.log(`[proxy] Image asset ${assetId} (${mediaInfo.codec}) — emitting poster instead of video proxy`);
      await job.updateProgress(40);
      await transcodeImage(inputPath, imageOutputPath);
      await job.updateProgress(70);
      await uploadToS3(S3_BUCKET, imageOutputKey, imageOutputPath);
      await job.updateProgress(90);
      await query(
        `UPDATE assets
         SET thumbnail_s3_key = $1,
             proxy_s3_key     = NULL,
             resolution       = COALESCE($2, resolution),
             file_size        = COALESCE($3, file_size),
             status           = 'ready',
             updated_at       = NOW()
         WHERE id = $4`,
        [imageOutputKey, mediaInfo.resolution ?? null, mediaInfo.fileSizeBytes ?? null, assetId]
      );
      await unlink(imageOutputPath).catch(() => {});
      await job.updateProgress(100);
      return { assetId, outputKey: imageOutputKey };
    }

    // Empty/truncated capture: probe returned a video stream but ffmpeg can't
    // read any frames. Bail with a clear message instead of dumping ~3KB of
    // ffmpeg stderr into the failed-jobs list.
    if (mediaInfo.durationMs === null && mediaInfo.codec) {
      throw new Error(
        `Empty or truncated source: codec=${mediaInfo.codec}, ` +
        `resolution=${mediaInfo.resolution || 'unknown'}, no readable frames.`
      );
    }

    // Transcode to H.264 proxy
    await job.updateProgress(30);
    console.log(`[proxy] Transcoding asset ${assetId}`);
    await transcodeVideo(inputPath, outputPath, {
      videoCodec:   'libx264',
      videoPreset:  'fast',
      videoBitrate: '10M',
      audioCodec:   'aac',
      audioBitrate: '192k',
      hasAudio,
    });

import { join } from 'path';
import { unlink } from 'fs/promises'
import { tmpdir } from 'os';
import { Queue } from 'bullmq';
import { query } from '../db/client.js';
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
import { transcodeVideo, transcodeImage, getMediaInfo } from '../ffmpeg/executor.js';

// Codec names ffprobe reports for still-image inputs. These bypass the video
// transcode entirely — see proxyWorker below.
const IMAGE_CODECS = new Set(['png', 'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls']);

const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';

// Dispatch thumbnail job once proxy is ready — same Redis connection as the worker
const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};

const thumbnailQueue = new Queue('thumbnail', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

export const proxyWorker = async (job) => {
  const { assetId, inputKey, outputKey } = job.data;

  const tmpDir = tmpdir();
  const inputPath  = join(tmpDir, `proxy-input-${job.id}.mov`);
  const outputPath = join(tmpDir, `proxy-output-${job.id}.mp4`);

  try {
    // Download original from S3
    await job.updateProgress(10);
    console.log(`[proxy] Downloading ${inputKey} for asset ${assetId}`);
    await downloadFromS3(S3_BUCKET, inputKey, inputPath);

    // Extract source metadata (fps, codec, resolution, duration, size)
    await job.updateProgress(20);
    let mediaInfo = {};
    try {
      mediaInfo = await getMediaInfo(inputPath);
      console.log(`[proxy] Metadata for ${assetId}: ${JSON.stringify(mediaInfo)}`);
    } catch (err) {
      console.warn(`[proxy] getMediaInfo failed for ${assetId}: ${err.message}`);
    }

    // Transcode to H.264 proxy
    await job.updateProgress(30);
    console.log(`[proxy] Transcoding asset ${assetId}`);
    await transcodeVideo(inputPath, outputPath, {
      videoCodec:   'libx264',
      videoPreset:  'fast',
      videoBitrate: '10M',
      audioCodec:   'aac',
      audioBitrate: '192k',
    });

    // Upload proxy to S3
    await job.updateProgress(70);
    console.log(`[proxy] Uploading to ${outputKey}`);
    await uploadToS3(S3_BUCKET, outputKey, outputPath);

    // Update asset record — store extracted metadata + proxy key
    // Use COALESCE so we never overwrite fields the capture service already set
    await job.updateProgress(90);
    await query(
      `UPDATE assets
       SET proxy_s3_key = $1,
           fps          = COALESCE($2, fps),
           codec        = COALESCE($3, codec),
           resolution   = COALESCE($4, resolution),
           duration_ms  = COALESCE($5, duration_ms),
           file_size    = COALESCE($6, file_size),
           updated_at   = NOW()
       WHERE id = $7`,
      [
        outputKey,
        mediaInfo.fps          ?? null,
        mediaInfo.codec        ?? null,
        mediaInfo.resolution   ?? null,
        mediaInfo.durationMs   ?? null,
        mediaInfo.fileSizeBytes ?? null,
        assetId,
      ]
    );

    // Now proxy exists in S3 — safe to queue thumbnail generation
    const thumbnailKey = `thumbnails/${assetId}.jpg`;
    await thumbnailQueue.add('generate', {
      assetId,
      proxyKey:  outputKey,
      outputKey: thumbnailKey,
    });

    console.log(`[proxy] Asset ${assetId} proxy complete, thumbnail job queued`);
    await job.updateProgress(100);

    return { assetId, outputKey };
  } catch (error) {
    console.error(`[proxy] Error processing asset ${assetId}:`, error);
    await query(
      `UPDATE assets SET status = 'error', updated_at = NOW() WHERE id = $1`,
      [assetId]
    );
    throw error;
  } finally {
    await Promise.all([
      unlink(inputPath).catch(() => {}),
      unlink(outputPath).catch(() => {}),
    ]);
  }
};

-    // Extract source metadata (fps, codec, resolution, duration, size)

+    // Extract source metadata (fps, codec, resolution, duration, size, audio)

+    // Still images skip the video proxy — they have no temporal stream and

+    // x264 with a one-frame PNG input fails (Could not open encoder before EOF).

+    // Generate a scaled JPEG poster instead; the thumbnail job will downsize it.

+    const isImage = mediaInfo.codec && IMAGE_CODECS.has(mediaInfo.codec.toLowerCase());

+      const imageOutputKey = outputKey.replace(/\.mp4$/, '.jpg');

+      const imageOutputPath = outputPath.replace(/\.mp4$/, '.jpg');

+      console.log(`[proxy] Image asset ${assetId} (${mediaInfo.codec}) — emitting poster instead of video proxy`);

+      await transcodeImage(inputPath, imageOutputPath);

+      await uploadToS3(S3_BUCKET, imageOutputKey, imageOutputPath);

+             resolution       = COALESCE($2, resolution),

+             file_size        = COALESCE($3, file_size),

+             status           = 'ready',

+        [imageOutputKey, mediaInfo.resolution ?? null, mediaInfo.fileSizeBytes ?? null, assetId]

+      await unlink(imageOutputPath).catch(() => {});

+      return { assetId, outputKey: imageOutputKey };

+    // Empty/truncated capture: probe returned a video stream but ffmpeg can't

+    // read any frames. Bail with a clear message instead of dumping ~3KB of

+    // ffmpeg stderr into the failed-jobs list.

+    if (mediaInfo.durationMs === null && mediaInfo.codec) {

+        `Empty or truncated source: codec=${mediaInfo.codec}, ` +

videoBitrate: '10M',
audioCodec:   'aac',
audioBitrate: '192k',
+      hasAudio,
});
// Upload proxy to S3


Edit

  return {
    fps,
    codec:          videoStream?.codec_name  || null,
    resolution:     videoStream ? `${videoStream.width}x${videoStream.height}` : null,
    durationMs:     fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
    fileSizeBytes:  fmt.size     ? parseInt(fmt.size, 10)                       : null,
  };
};

  const hasAudio = (info.streams || []).some(s => s.codec_type === 'audio');

  return {
    fps,
    codec:          videoStream?.codec_name  || null,
    resolution:     videoStream ? `${videoStream.width}x${videoStream.height}` : null,
    durationMs:     fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
    fileSizeBytes:  fmt.size     ? parseInt(fmt.size, 10)                       : null,
    hasAudio,
  };
};


Lets do a debugging & polish session on https://forge.wilddragon.net/zgaetano/wild-dragon , its currently deployed to Truenas. http://10.0.0.25:47434/   SRT / Recorder / Ingest page is still buggy, pr…


Debug ingest, monitors, buttons and prototype file editing


/* ===== Live preview (HLS) ====================================
   Shared by RecorderRow + MonitorTile. The capture container writes
   HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
   and nginx.conf); we attach hls.js to a <video> when a recorder is
   actively recording and has a live asset.
   ============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
  const videoRef = React.useRef(null);
  const [err, setErr] = React.useState(null);

  React.useEffect(() => {
    if (!assetId || !videoRef.current) return;
    const url = '/live/' + assetId + '/index.m3u8';
    const v = videoRef.current;

    // Safari can play HLS natively; everything else needs hls.js.
    if (v.canPlayType('application/vnd.apple.mpegurl')) {
      v.src = url;
      const onErr = () => setErr('playback failed');
      v.addEventListener('error', onErr);
      return () => v.removeEventListener('error', onErr);
    }
    if (!window.Hls) { setErr('hls.js missing'); return; }

    const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
    hls.loadSource(url);
    hls.attachMedia(v);
    hls.on(window.Hls.Events.ERROR, (_e, data) => {
      if (data.fatal) setErr(data.details || 'hls error');
    });
    return () => { try { hls.destroy(); } catch (_) {} };
  }, [assetId]);

  return (

      <video
        ref={videoRef}
        autoPlay
        playsInline
        muted={muted}
        controls={controls}
        style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
      />
      {err && (

          {err}

      )}

  );
}

/* ===== Recorders ===== */


The file /tmp/wd/wild-dragon/services/web-ui/public/screens-ingest.jsx has been updated successfully. (file state is current in your context — no need to Read it back)

/* ===== Live preview (HLS) ====================================
   Shared by RecorderRow + MonitorTile. The capture container writes
   HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
   and nginx.conf); we attach hls.js to a <video> when a recorder is
   actively recording and has a live asset.
   ============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
  const videoRef = React.useRef(null);
  const [err, setErr] = React.useState(null);

  React.useEffect(() => {
    if (!assetId || !videoRef.current) return;
    const url = '/live/' + assetId + '/index.m3u8';
    const v = videoRef.current;

    // Safari can play HLS natively; everything else needs hls.js.
    if (v.canPlayType('application/vnd.apple.mpegurl')) {
      v.src = url;
      const onErr = () => setErr('playback failed');
      v.addEventListener('error', onErr);
      return () => v.removeEventListener('error', onErr);
    }
    if (!window.Hls) { setErr('hls.js missing'); return; }

    const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
    hls.loadSource(url);
    hls.attachMedia(v);
    hls.on(window.Hls.Events.ERROR, (_e, data) => {
      if (data.fatal) setErr(data.details || 'hls error');
    });
    return () => { try { hls.destroy(); } catch (_) {} };
  }, [assetId]);

  return (

      <video
        ref={videoRef}
        autoPlay
        playsInline
        muted={muted}
        controls={controls}
        style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
      />
      {err && (

          {err}

      )}

  );
}

/* ===== Recorders ===== */

+   Shared by RecorderRow + MonitorTile. The capture container writes

+   HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js

+   and nginx.conf); we attach hls.js to a <video> when a recorder is

+   actively recording and has a live asset.

+function HlsPreview({ assetId, muted = true, controls = false, className }) {

+  const [err, setErr] = React.useState(null);

+    if (!assetId || !videoRef.current) return;

+    const url = '/live/' + assetId + '/index.m3u8';

+    // Safari can play HLS natively; everything else needs hls.js.

+    if (v.canPlayType('application/vnd.apple.mpegurl')) {

+      const onErr = () => setErr('playback failed');

+      v.addEventListener('error', onErr);

+      return () => v.removeEventListener('error', onErr);

+    if (!window.Hls) { setErr('hls.js missing'); return; }

+    const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });

+    hls.on(window.Hls.Events.ERROR, (_e, data) => {

+      if (data.fatal) setErr(data.details || 'hls error');

+    return () => { try { hls.destroy(); } catch (_) {} };

+    <div className={className} style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>

+        style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}

+        <div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',

+                      color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.5)' }}>


return (

      <FauxFrame />
      {isLive &&
}

return (

      {isLive && feed.live_asset_id
        ? <HlsPreview assetId={feed.live_asset_id} />
        : <FauxFrame />}
      {isLive &&
}

+        ? <HlsPreview assetId={feed.live_asset_id} />

{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}

<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>


        {isRec
          ? <LiveStrip seed={recorder.id.length * 3} count={6} />
          :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}

        {isRec && recorder.live_asset_id
          ? <HlsPreview assetId={recorder.live_asset_id} />
          : isRec
            ? <LiveStrip seed={recorder.id.length * 3} count={6} />
            :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}


        {isRec
          ? <LiveStrip seed={recorder.id.length * 3} count={6} />
          :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}

        {isRec && recorder.live_asset_id
          ? <HlsPreview assetId={recorder.live_asset_id} />
          : isRec
            ? <LiveStrip seed={recorder.id.length * 3} count={6} />
            :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}

<div className={'recorder-row ' + recorder.status}>

-          ? <LiveStrip seed={recorder.id.length * 3} count={6} />

-          :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}

+        {isRec && recorder.live_asset_id

+          ? <HlsPreview assetId={recorder.live_asset_id} />

+            ? <LiveStrip seed={recorder.id.length * 3} count={6} />

+            :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}

<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>


return (

      <FauxFrame />
      {isLive &&
}

return (

      {isLive && feed.live_asset_id
        ? <HlsPreview assetId={feed.live_asset_id} />
        : <FauxFrame />}
      {isLive &&
}


function Settings() {
  const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
  const [s3Loading, setS3Loading] = React.useState(true);
  const [s3Saving,  setS3Saving]  = React.useState(false);
  const [s3Testing, setS3Testing] = React.useState(false);
  const [s3Msg,     setS3Msg]     = React.useState(null);
  const [secretExists, setSecretExists] = React.useState(false);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/s3')
      .then(data => {
        setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });
        setSecretExists(!!data.s3_secret_key_exists);
        setS3Loading(false);
      })
      .catch(() => setS3Loading(false));
  }, []);

  const saveS3 = () => {
    setS3Saving(true); setS3Msg(null);
    window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
      .then(() => { setS3Saving(false); setS3Msg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
      .catch(e  => { setS3Saving(false); setS3Msg({ ok: false, text: e.message }); });
  };

  const testS3 = () => {
    setS3Testing(true); setS3Msg(null);
    window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
      .then(r => { setS3Testing(false); setS3Msg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
      .catch(e => { setS3Testing(false); setS3Msg({ ok: false, text: e.message }); });
  };

  return (

Settings

        System configuration · changes apply without restart

            {[
              { id: 'storage', label: 'S3 / Object storage', icon: 'hdd' },
              { id: 'gpu',     label: 'GPU / Transcoding',   icon: 'gpu' },
              { id: 'sdi',     label: 'SDI capture',          icon: 'video' },
              { id: 'ampp',    label: 'AMPP integration',     icon: 'link' },
            ].map((s, i) => (
              <a key={s.id} className={`settings-nav-item ${i === 0 ? 'active' : ''}`}>
                <Icon name={s.icon} size={14} />{s.label}

            ))}

            <SettingsCard
              icon="hdd"
              title="S3 / Object Storage"
              sub="S3-compatible bucket for media asset storage"
              tag={secretExists ? connected : not configured}
            >
              {s3Loading ? (

Loading…

              ) : (<>
                <SField label="Endpoint URL">
                  <input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
                </SField>

                  <SField label="Region">
                    <input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" />
                  </SField>
                  <SField label="Bucket">
                    <input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" />
                  </SField>

                <SField label="Access key ID">
                  <input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" />
                </SField>
                <SField label="Secret access key">
                  <input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} />
                </SField>
                {s3Msg && (

                    {s3Msg.text}

                )}

                  <button className="btn primary sm" onClick={saveS3} disabled={s3Saving}>{s3Saving ? 'Saving…' : 'Save & apply'}</button>
                  <button className="btn ghost sm" onClick={testS3} disabled={s3Testing}>{s3Testing ? 'Testing…' : 'Test connection'}</button>

              </>)}
            </SettingsCard>

  );
}

function Settings() {
  const [section, setSection] = React.useState('storage');

  const SECTIONS = [
    { id: 'storage',  label: 'S3 / Object storage', icon: 'hdd' },
    { id: 'gpu',      label: 'GPU / Transcoding',   icon: 'gpu' },
    { id: 'growing',  label: 'Growing files (SMB)', icon: 'hdd' },
    { id: 'sdi',      label: 'SDI capture',         icon: 'video' },
    { id: 'ampp',     label: 'AMPP integration',    icon: 'link' },
  ];

  return (

Settings

        System configuration · changes apply without restart

            {SECTIONS.map(s => (
              <a key={s.id}
                 className={`settings-nav-item ${section === s.id ? 'active' : ''}`}
                 onClick={() => setSection(s.id)}
                 style={{ cursor: 'pointer' }}>
                <Icon name={s.icon} size={14} />{s.label}

            ))}

            {section === 'storage' && <S3SettingsCard />}
            {section === 'gpu'     && <GpuSettingsCard />}
            {section === 'growing' && <GrowingSettingsCard />}
            {section === 'sdi'     && <SdiSettingsCard />}
            {section === 'ampp'    && <AmppSettingsCard />}

  );
}

function S3SettingsCard() {
  const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
  const [loading, setLoading] = React.useState(true);
  const [saving,  setSaving]  = React.useState(false);
  const [testing, setTesting] = React.useState(false);
  const [msg,     setMsg]     = React.useState(null);
  const [secretExists, setSecretExists] = React.useState(false);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/s3')
      .then(data => {
        setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });
        setSecretExists(!!data.s3_secret_key_exists);
        setLoading(false);
      })
      .catch(() => setLoading(false));
  }, []);

  const save = () => {
    setSaving(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
      .catch(e  => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };
  const test = () => {
    setTesting(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
      .then(r => { setTesting(false); setMsg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
  };

  return (
    <SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"
      tag={secretExists ? connected : not configured}>
      {loading ?
Loading…
 : (<>
        <SField label="Endpoint URL">
          <input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
        </SField>

          <SField label="Region"><input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField>
          <SField label="Bucket"><input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>

        <SField label="Access key ID"><input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /></SField>
        <SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} /></SField>
        <SettingsMsg msg={msg} />

          <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
          <button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>

      </>)}
    </SettingsCard>
  );
}

function GpuSettingsCard() {
  const [cfg, setCfg] = React.useState(null);
  const [saving, setSaving] = React.useState(false);
  const [msg, setMsg] = React.useState(null);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/transcoding').then(setCfg).catch(() => setCfg({}));
  }, []);

  const save = () => {
    setSaving(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };

  if (!cfg) return <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration">
Loading…
</SettingsCard>;

  const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
  const enabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;

  return (
    <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration for proxy generation"
      tag={enabled ? enabled : disabled}>
      <SField label="Enable hardware encoding">

          <input type="checkbox" checked={enabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
          Route proxy jobs through GPU encoders when available

      </SField>

        <SField label="Codec">
          <select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
            <option value="h264_nvenc">h264_nvenc</option>
            <option value="hevc_nvenc">hevc_nvenc</option>
            <option value="h264_vaapi">h264_vaapi</option>
            <option value="hevc_vaapi">hevc_vaapi</option>
          </select>
        </SField>
        <SField label="Preset">
          <select className="field-input" value={cfg.gpu_preset || 'p4'} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
            {['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)}
          </select>
        </SField>

        <SField label="Bitrate (Mbps)">
          <input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="8" />
        </SField>
        <SField label="Rate control">
          <select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
            <option value="cbr">CBR</option>
            <option value="vbr">VBR</option>
            <option value="cqp">CQP</option>
          </select>
        </SField>

      <SettingsMsg msg={msg} />

        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>

    </SettingsCard>
  );
}

function GrowingSettingsCard() {
  const [cfg, setCfg] = React.useState(null);
  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',
    }));
  }, []);

  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.' }); })
      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };

  if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…">
…
</SettingsCard>;
  const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
  const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;

  return (
    <SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
      tag={enabled ? enabled : disabled}>
      <SField label="Enable growing-file capture">

          <input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
          Capture writes to the local SMB share first; Premier can edit while it's still growing.

      </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" />
      </SField>
      <SField label="SMB share URL (for editors)">
        <input className="field-input mono" value={cfg.growing_smb_url || ''} onChange={e => set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />
      </SField>
      <SField label="Promote-to-S3 idle threshold (seconds)">
        <input className="field-input mono" type="number" value={cfg.growing_promote_after_seconds || ''} onChange={e => set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
      </SField>
      <SettingsMsg msg={msg} />

        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>

    </SettingsCard>
  );
}

function SdiSettingsCard() {
  return (
    <SettingsCard icon="video" title="SDI capture" sub="DeckLink device routing and defaults"
      tag={per-recorder}>

        SDI settings are configured per-recorder. Use{' '}
        Ingest → Recorders → New recorder{' '}
        to pick the DeckLink port, codec, and audio routing.

         { e.preventDefault(); window.dispatchEvent(new CustomEvent('dragonflight-navigate', { detail: 'capture' })); }}>
          <Icon name="video" />Open Capture dashboard

    </SettingsCard>
  );
}

function AmppSettingsCard() {
  const [cfg, setCfg] = React.useState(null);
  const [saving, setSaving] = React.useState(false);
  const [testing, setTesting] = React.useState(false);
  const [msg, setMsg] = React.useState(null);
  const [tokenExists, setTokenExists] = React.useState(false);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/ampp').then(d => {
      setCfg({ ampp_base_url: d.ampp_base_url || '', ampp_token: '' });
      setTokenExists(!!d.ampp_token_exists);
    }).catch(() => setCfg({ ampp_base_url: '', ampp_token: '' }));
  }, []);

  const save = () => {
    setSaving(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/ampp', { method: 'PUT', body: JSON.stringify(cfg) })
      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); if (cfg.ampp_token) setTokenExists(true); })
      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };
  const test = () => {
    setTesting(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/ampp/test', { method: 'POST', body: JSON.stringify(cfg) })
      .then(r => { setTesting(false); setMsg({ ok: true, text: r.message || 'Connection OK' }); })
      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
  };

  if (!cfg) return <SettingsCard icon="link" title="AMPP integration" sub="Loading…">
…
</SettingsCard>;

  return (
    <SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"
      tag={tokenExists ? connected : not configured}>
      <SField label="AMPP base URL">
        <input className="field-input mono" value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
      </SField>
      <SField label="API token">
        <input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} />
      </SField>
      <SettingsMsg msg={msg} />

        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
        <button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>

    </SettingsCard>
  );
}

function SettingsMsg({ msg }) {
  if (!msg) return null;
  return (

      {msg.text}

  );
}


The file /tmp/wd/wild-dragon/services/web-ui/public/screens-admin.jsx has been updated successfully. (file state is current in your context — no need to Read it back)

function Settings() {
  const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
  const [s3Loading, setS3Loading] = React.useState(true);
  const [s3Saving,  setS3Saving]  = React.useState(false);
  const [s3Testing, setS3Testing] = React.useState(false);
  const [s3Msg,     setS3Msg]     = React.useState(null);
  const [secretExists, setSecretExists] = React.useState(false);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/s3')
      .then(data => {
        setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });
        setSecretExists(!!data.s3_secret_key_exists);
        setS3Loading(false);
      })
      .catch(() => setS3Loading(false));
  }, []);

  const saveS3 = () => {
    setS3Saving(true); setS3Msg(null);
    window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
      .then(() => { setS3Saving(false); setS3Msg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
      .catch(e  => { setS3Saving(false); setS3Msg({ ok: false, text: e.message }); });
  };

  const testS3 = () => {
    setS3Testing(true); setS3Msg(null);
    window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
      .then(r => { setS3Testing(false); setS3Msg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
      .catch(e => { setS3Testing(false); setS3Msg({ ok: false, text: e.message }); });
  };

  return (

Settings

        System configuration · changes apply without restart

            {[
              { id: 'storage', label: 'S3 / Object storage', icon: 'hdd' },
              { id: 'gpu',     label: 'GPU / Transcoding',   icon: 'gpu' },
              { id: 'sdi',     label: 'SDI capture',          icon: 'video' },
              { id: 'ampp',    label: 'AMPP integration',     icon: 'link' },
            ].map((s, i) => (
              <a key={s.id} className={`settings-nav-item ${i === 0 ? 'active' : ''}`}>
                <Icon name={s.icon} size={14} />{s.label}

            ))}

            <SettingsCard
              icon="hdd"
              title="S3 / Object Storage"
              sub="S3-compatible bucket for media asset storage"
              tag={secretExists ? connected : not configured}
            >
              {s3Loading ? (

Loading…

              ) : (<>
                <SField label="Endpoint URL">
                  <input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
                </SField>

                  <SField label="Region">
                    <input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" />
                  </SField>
                  <SField label="Bucket">
                    <input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" />
                  </SField>

                <SField label="Access key ID">
                  <input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" />
                </SField>
                <SField label="Secret access key">
                  <input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} />
                </SField>
                {s3Msg && (

                    {s3Msg.text}

                )}

                  <button className="btn primary sm" onClick={saveS3} disabled={s3Saving}>{s3Saving ? 'Saving…' : 'Save & apply'}</button>
                  <button className="btn ghost sm" onClick={testS3} disabled={s3Testing}>{s3Testing ? 'Testing…' : 'Test connection'}</button>

              </>)}
            </SettingsCard>

  );
}

function Settings() {
  const [section, setSection] = React.useState('storage');

  const SECTIONS = [
    { id: 'storage',  label: 'S3 / Object storage', icon: 'hdd' },
    { id: 'gpu',      label: 'GPU / Transcoding',   icon: 'gpu' },
    { id: 'growing',  label: 'Growing files (SMB)', icon: 'hdd' },
    { id: 'sdi',      label: 'SDI capture',         icon: 'video' },
    { id: 'ampp',     label: 'AMPP integration',    icon: 'link' },
  ];

  return (

Settings

        System configuration · changes apply without restart

            {SECTIONS.map(s => (
              <a key={s.id}
                 className={`settings-nav-item ${section === s.id ? 'active' : ''}`}
                 onClick={() => setSection(s.id)}
                 style={{ cursor: 'pointer' }}>
                <Icon name={s.icon} size={14} />{s.label}

            ))}

            {section === 'storage' && <S3SettingsCard />}
            {section === 'gpu'     && <GpuSettingsCard />}
            {section === 'growing' && <GrowingSettingsCard />}
            {section === 'sdi'     && <SdiSettingsCard />}
            {section === 'ampp'    && <AmppSettingsCard />}

  );
}

function S3SettingsCard() {
  const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
  const [loading, setLoading] = React.useState(true);
  const [saving,  setSaving]  = React.useState(false);
  const [testing, setTesting] = React.useState(false);
  const [msg,     setMsg]     = React.useState(null);
  const [secretExists, setSecretExists] = React.useState(false);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/s3')
      .then(data => {
        setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });
        setSecretExists(!!data.s3_secret_key_exists);
        setLoading(false);
      })
      .catch(() => setLoading(false));
  }, []);

  const save = () => {
    setSaving(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
      .catch(e  => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };
  const test = () => {
    setTesting(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
      .then(r => { setTesting(false); setMsg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
  };

  return (
    <SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"
      tag={secretExists ? connected : not configured}>
      {loading ?
Loading…
 : (<>
        <SField label="Endpoint URL">
          <input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
        </SField>

          <SField label="Region"><input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField>
          <SField label="Bucket"><input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>

        <SField label="Access key ID"><input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /></SField>
        <SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} /></SField>
        <SettingsMsg msg={msg} />

          <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
          <button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>

      </>)}
    </SettingsCard>
  );
}

function GpuSettingsCard() {
  const [cfg, setCfg] = React.useState(null);
  const [saving, setSaving] = React.useState(false);
  const [msg, setMsg] = React.useState(null);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/transcoding').then(setCfg).catch(() => setCfg({}));
  }, []);

  const save = () => {
    setSaving(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };

  if (!cfg) return <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration">
Loading…
</SettingsCard>;

  const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
  const enabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;

  return (
    <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration for proxy generation"
      tag={enabled ? enabled : disabled}>
      <SField label="Enable hardware encoding">

          <input type="checkbox" checked={enabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
          Route proxy jobs through GPU encoders when available

      </SField>

        <SField label="Codec">
          <select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
            <option value="h264_nvenc">h264_nvenc</option>
            <option value="hevc_nvenc">hevc_nvenc</option>
            <option value="h264_vaapi">h264_vaapi</option>
            <option value="hevc_vaapi">hevc_vaapi</option>
          </select>
        </SField>
        <SField label="Preset">
          <select className="field-input" value={cfg.gpu_preset || 'p4'} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
            {['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)}
          </select>
        </SField>

        <SField label="Bitrate (Mbps)">
          <input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="8" />
        </SField>
        <SField label="Rate control">
          <select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
            <option value="cbr">CBR</option>
            <option value="vbr">VBR</option>
            <option value="cqp">CQP</option>
          </select>
        </SField>

      <SettingsMsg msg={msg} />

        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>

    </SettingsCard>
  );
}

function GrowingSettingsCard() {
  const [cfg, setCfg] = React.useState(null);
  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',
    }));
  }, []);

  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.' }); })
      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };

  if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…">
…
</SettingsCard>;
  const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
  const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;

  return (
    <SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
      tag={enabled ? enabled : disabled}>
      <SField label="Enable growing-file capture">

          <input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
          Capture writes to the local SMB share first; Premier can edit while it's still growing.

      </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" />
      </SField>
      <SField label="SMB share URL (for editors)">
        <input className="field-input mono" value={cfg.growing_smb_url || ''} onChange={e => set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />
      </SField>
      <SField label="Promote-to-S3 idle threshold (seconds)">
        <input className="field-input mono" type="number" value={cfg.growing_promote_after_seconds || ''} onChange={e => set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
      </SField>
      <SettingsMsg msg={msg} />

        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>

    </SettingsCard>
  );
}

function SdiSettingsCard() {
  return (
    <SettingsCard icon="video" title="SDI capture" sub="DeckLink device routing and defaults"
      tag={per-recorder}>

        SDI settings are configured per-recorder. Use{' '}
        Ingest → Recorders → New recorder{' '}
        to pick the DeckLink port, codec, and audio routing.

         { e.preventDefault(); window.dispatchEvent(new CustomEvent('dragonflight-navigate', { detail: 'capture' })); }}>
          <Icon name="video" />Open Capture dashboard

    </SettingsCard>
  );
}

function AmppSettingsCard() {
  const [cfg, setCfg] = React.useState(null);
  const [saving, setSaving] = React.useState(false);
  const [testing, setTesting] = React.useState(false);
  const [msg, setMsg] = React.useState(null);
  const [tokenExists, setTokenExists] = React.useState(false);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/ampp').then(d => {
      setCfg({ ampp_base_url: d.ampp_base_url || '', ampp_token: '' });
      setTokenExists(!!d.ampp_token_exists);
    }).catch(() => setCfg({ ampp_base_url: '', ampp_token: '' }));
  }, []);

  const save = () => {
    setSaving(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/ampp', { method: 'PUT', body: JSON.stringify(cfg) })
      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); if (cfg.ampp_token) setTokenExists(true); })
      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };
  const test = () => {
    setTesting(true); setMsg(null);
    window.ZAMPP_API.fetch('/settings/ampp/test', { method: 'POST', body: JSON.stringify(cfg) })
      .then(r => { setTesting(false); setMsg({ ok: true, text: r.message || 'Connection OK' }); })
      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
  };

  if (!cfg) return <SettingsCard icon="link" title="AMPP integration" sub="Loading…">
…
</SettingsCard>;

  return (
    <SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"
      tag={tokenExists ? connected : not configured}>
      <SField label="AMPP base URL">
        <input className="field-input mono" value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
      </SField>
      <SField label="API token">
        <input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} />
      </SField>
      <SettingsMsg msg={msg} />

        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
        <button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>

    </SettingsCard>
  );
}

function SettingsMsg({ msg }) {
  if (!msg) return null;
  return (

      {msg.text}

  );
}

-  const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });

-  const [s3Loading, setS3Loading] = React.useState(true);

-  const [s3Saving,  setS3Saving]  = React.useState(false);

-  const [s3Testing, setS3Testing] = React.useState(false);

-  const [s3Msg,     setS3Msg]     = React.useState(null);

-  const [secretExists, setSecretExists] = React.useState(false);

+  const [section, setSection] = React.useState('storage');

-    window.ZAMPP_API.fetch('/settings/s3')

-        setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });

-        setSecretExists(!!data.s3_secret_key_exists);

-      .catch(() => setS3Loading(false));

+    { id: 'storage',  label: 'S3 / Object storage', icon: 'hdd' },

+    { id: 'gpu',      label: 'GPU / Transcoding',   icon: 'gpu' },

+    { id: 'growing',  label: 'Growing files (SMB)', icon: 'hdd' },

+    { id: 'sdi',      label: 'SDI capture',         icon: 'video' },

+    { id: 'ampp',     label: 'AMPP integration',    icon: 'link' },

-    window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })

-      .then(() => { setS3Saving(false); setS3Msg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })

-      .catch(e  => { setS3Saving(false); setS3Msg({ ok: false, text: e.message }); });

-    setS3Testing(true); setS3Msg(null);

-    window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })

-      .then(r => { setS3Testing(false); setS3Msg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })

-      .catch(e => { setS3Testing(false); setS3Msg({ ok: false, text: e.message }); });

<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 24, alignItems: 'start' }}>

-              { id: 'storage', label: 'S3 / Object storage', icon: 'hdd' },

-              { id: 'gpu',     label: 'GPU / Transcoding',   icon: 'gpu' },

-              { id: 'sdi',     label: 'SDI capture',          icon: 'video' },

-              { id: 'ampp',    label: 'AMPP integration',     icon: 'link' },

-              <a key={s.id} className={`settings-nav-item ${i === 0 ? 'active' : ''}`}>

+                 className={`settings-nav-item ${section === s.id ? 'active' : ''}`}

+                 onClick={() => setSection(s.id)}

+                 style={{ cursor: 'pointer' }}>

<Icon name={s.icon} size={14} />{s.label}

<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>

-              title="S3 / Object Storage"

-              sub="S3-compatible bucket for media asset storage"

-              tag={secretExists ? connected : not configured}

-                <SField label="Endpoint URL">

-                  <input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />

-                <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>

-                  <SField label="Region">

-                    <input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" />

-                  <SField label="Bucket">

-                    <input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" />

-                <SField label="Access key ID">

-                  <input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" />

-                <SField label="Secret access key">

-                  <input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} />

-                  <div style={{ fontSize: 12, padding: '6px 10px', borderRadius: 5, border: '1px solid', background: s3Msg.ok ? 'var(--success-soft)' : 'var(--danger-soft)', borderColor: s3Msg.ok ? 'var(--success)' : 'var(--danger)', color: s3Msg.ok ? 'var(--success)' : 'var(--danger)' }}>

-                <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>

-                  <button className="btn primary sm" onClick={saveS3} disabled={s3Saving}>{s3Saving ? 'Saving…' : 'Save & apply'}</button>

-                  <button className="btn ghost sm" onClick={testS3} disabled={s3Testing}>{s3Testing ? 'Testing…' : 'Test connection'}</button>

+            {section === 'storage' && <S3SettingsCard />}

+            {section === 'gpu'     && <GpuSettingsCard />}

+            {section === 'growing' && <GrowingSettingsCard />}

+            {section === 'sdi'     && <SdiSettingsCard />}

+            {section === 'ampp'    && <AmppSettingsCard />}

+  const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });

+  const [loading, setLoading] = React.useState(true);

+  const [saving,  setSaving]  = React.useState(false);

+  const [testing, setTesting] = React.useState(false);

+  const [msg,     setMsg]     = React.useState(null);

+  const [secretExists, setSecretExists] = React.useState(false);

+    window.ZAMPP_API.fetch('/settings/s3')

+        setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });

+        setSecretExists(!!data.s3_secret_key_exists);

+    window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })

+      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })

+      .catch(e  => { setSaving(false); setMsg({ ok: false, text: e.message }); });

+    window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })

+      .then(r => { setTesting(false); setMsg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })

+      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });

+    <SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"

+      tag={secretExists ? connected : not configured}>

+          <input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />

+        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>

+          <SField label="Region"><input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField>

+          <SField label="Bucket"><input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>

+        <SField label="Access key ID"><input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /></SField>

+        <SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} /></SField>

+        <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>

+          <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>

+          <button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>

+  const [cfg, setCfg] = React.useState(null);

+  const [saving, setSaving] = React.useState(false);

+  const [msg, setMsg] = React.useState(null);

+    window.ZAMPP_API.fetch('/settings/transcoding').then(setCfg).catch(() => setCfg({}));

+    window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })

+      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })

+      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });

+  if (!cfg) return <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration">
Loading…
</SettingsCard>;

+  const enabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;

+    <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration for proxy generation"

+      tag={enabled ? enabled : disabled}>

+      <SField label="Enable hardware encoding">

+        <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>

+          <input type="checkbox" checked={enabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />

+          Route proxy jobs through GPU encoders when available

+      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>

+          <select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>

+            <option value="h264_nvenc">h264_nvenc</option>

+            <option value="hevc_nvenc">hevc_nvenc</option>

+            <option value="h264_vaapi">h264_vaapi</option>

+            <option value="hevc_vaapi">hevc_vaapi</option>

+          <select className="field-input" value={cfg.gpu_preset || 'p4'} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>

+        <SField label="Bitrate (Mbps)">

+          <input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="8" />

+          <select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>

+            <option value="cbr">CBR</option>

+            <option value="vbr">VBR</option>

+            <option value="cqp">CQP</option>

+      <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>

+        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>

+  const [cfg, setCfg] = React.useState(null);

+  const [saving, setSaving] = React.useState(false);

+  const [msg, setMsg] = React.useState(null);

+    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', { method: 'PUT', body: JSON.stringify(cfg) })

+      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })

+      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });

+  if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…">
…
</SettingsCard>;

+  const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;

+    <SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"

+      tag={enabled ? enabled : disabled}>

+      <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))} />

+          Capture writes to the local SMB share first; Premier can edit while it's still growing.

+      <SField label="Container mount path">

+        <input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />

+      <SField label="SMB share URL (for editors)">

+        <input className="field-input mono" value={cfg.growing_smb_url || ''} onChange={e => set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />

+      <SField label="Promote-to-S3 idle threshold (seconds)">

+        <input className="field-input mono" type="number" value={cfg.growing_promote_after_seconds || ''} onChange={e => set('growing_promote_after_seconds', e.target.value)} placeholder="8" />

+      <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>

+    <SettingsCard icon="video" title="SDI capture" sub="DeckLink device routing and defaults"

+      <div style={{ color: 'var(--text-3)', fontSize: 12.5, lineHeight: 1.6 }}>

+        SDI settings are configured per-recorder. Use{' '}

+        Ingest → Recorders → New recorder{' '}

+        to pick the DeckLink port, codec, and audio routing.

+        <a className="btn ghost sm" href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('dragonflight-navigate', { detail: 'capture' })); }}>

+          <Icon name="video" />Open Capture dashboard

+  const [cfg, setCfg] = React.useState(null);

+  const [saving, setSaving] = React.useState(false);

+  const [testing, setTesting] = React.useState(false);

+  const [msg, setMsg] = React.useState(null);

+  const [tokenExists, setTokenExists] = React.useState(false);

+    window.ZAMPP_API.fetch('/settings/ampp').then(d => {

+      setCfg({ ampp_base_url: d.ampp_base_url || '', ampp_token: '' });

+      setTokenExists(!!d.ampp_token_exists);

+    }).catch(() => setCfg({ ampp_base_url: '', ampp_token: '' }));

+    window.ZAMPP_API.fetch('/settings/ampp', { method: 'PUT', body: JSON.stringify(cfg) })

+      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); if (cfg.ampp_token) setTokenExists(true); })

+      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });

+    window.ZAMPP_API.fetch('/settings/ampp/test', { method: 'POST', body: JSON.stringify(cfg) })

+      .then(r => { setTesting(false); setMsg({ ok: true, text: r.message || 'Connection OK' }); })

+      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });

+  if (!cfg) return <SettingsCard icon="link" title="AMPP integration" sub="Loading…">
…
</SettingsCard>;

+    <SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"

+      tag={tokenExists ? connected : not configured}>

+        <input className="field-input mono" value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />

+        <input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} />

+      <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>

+    <div style={{ fontSize: 12, padding: '6px 10px', borderRadius: 5, border: '1px solid',

+                  background: msg.ok ? 'var(--success-soft)' : 'var(--danger-soft)',

+                  borderColor: msg.ok ? 'var(--success)' : 'var(--danger)',

+                  color: msg.ok ? 'var(--success)' : 'var(--danger)' }}>


Now let me add the `/settings/growing` API endpoint, the growing capture path, and prototype the SMB tee.


// ── Capture service routing ───────────────────────────────────────────────────

// ── Growing files (SMB landing zone) ─────────────────────────────────────────
// Lets capture write its master output to a fast local SMB share instead of
// streaming directly to S3. Premiere can mount the share and edit the file
// 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'];

router.get('/growing', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings WHERE key = ANY($1)`,
      [GROWING_KEYS]
    );
    const out = {
      growing_enabled: 'false',
      growing_path: '/growing',
      growing_smb_url: '',
      growing_promote_after_seconds: '8',
    };
    for (const { key, value } of result.rows) out[key] = value;
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/growing', async (req, res, next) => {
  try {
    for (const key of GROWING_KEYS) {
      if (req.body[key] !== undefined) {
        await pool.query(
          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
          [key, String(req.body[key])]
        );
      }
    }
    res.json({ message: 'Growing-files settings saved' });
  } catch (err) {
    next(err);
  }
});

// ── Capture service routing ───────────────────────────────────────────────────


The file /tmp/wd/wild-dragon/services/mam-api/src/routes/settings.js has been updated successfully. (file state is current in your context — no need to Read it back)

// ── Growing files (SMB landing zone) ─────────────────────────────────────────
// Lets capture write its master output to a fast local SMB share instead of
// streaming directly to S3. Premiere can mount the share and edit the file
// 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'];

router.get('/growing', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings WHERE key = ANY($1)`,
      [GROWING_KEYS]
    );
    const out = {
      growing_enabled: 'false',
      growing_path: '/growing',
      growing_smb_url: '',
      growing_promote_after_seconds: '8',
    };
    for (const { key, value } of result.rows) out[key] = value;
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/growing', async (req, res, next) => {
  try {
    for (const key of GROWING_KEYS) {
      if (req.body[key] !== undefined) {
        await pool.query(
          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
          [key, String(req.body[key])]
        );
      }
    }
    res.json({ message: 'Growing-files settings saved' });
  } catch (err) {
    next(err);
  }
});

// ── Capture service routing ───────────────────────────────────────────────────

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { getAmppConfig } from '../ampp/client.js';
import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';

const router = express.Router();
router.use(requireAuth);

// ── S3 / Object Storage ───────────────────────────────────────────────────────

router.get('/s3', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')`
    );
    const out = {
      s3_endpoint:   '',
      s3_bucket:     getS3Bucket(),
      s3_access_key: '',
      s3_secret_key_exists: false,
      s3_region:     'us-east-1',
    };
    for (const { key, value } of result.rows) {
      if (key === 's3_secret_key') {
        out.s3_secret_key_exists = !!value;
      } else {
        out[key] = value || '';
      }
    }
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/s3', async (req, res, next) => {
  try {
    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;

    if (!s3_endpoint) return res.status(400).json({ error: 's3_endpoint is required' });
    if (!s3_bucket)   return res.status(400).json({ error: 's3_bucket is required' });

    const keys = { s3_endpoint, s3_bucket, s3_region: s3_region || 'us-east-1' };
    if (s3_access_key) keys.s3_access_key = s3_access_key;
    if (s3_secret_key) keys.s3_secret_key = s3_secret_key;

    for (const [key, value] of Object.entries(keys)) {
      await pool.query(
        `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
         ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
        [key, value.trim()]
      );
    }

    // Fetch the full current config (including any previously saved secret)
    const saved = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
         AND value IS NOT NULL AND value <> ''`
    );
    const cfg = {};
    for (const { key, value } of saved.rows) {
      switch (key) {
        case 's3_endpoint':   cfg.endpoint  = value; break;
        case 's3_bucket':     cfg.bucket    = value; break;
        case 's3_access_key': cfg.accessKey = value; break;
        case 's3_secret_key': cfg.secretKey = value; break;
        case 's3_region':     cfg.region    = value; break;
      }
    }
    rebuildS3Client(cfg);

    res.json({ message: 'S3 settings saved and applied' });
  } catch (err) {
    next(err);
  }
});

// Test with the values the browser just typed (before saving), or with saved
// creds if the fields are left blank. Secret is sent only if user typed it.
router.post('/s3/test', async (req, res, next) => {
  try {
    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;

    // Merge submitted values with anything already saved in the DB
    const saved = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
         AND value IS NOT NULL AND value <> ''`
    );
    const fromDb = {};
    for (const { key, value } of saved.rows) fromDb[key] = value;

    const cfg = {
      endpoint:  (s3_endpoint  || fromDb.s3_endpoint  || '').trim(),
      bucket:    (s3_bucket    || fromDb.s3_bucket    || getS3Bucket()).trim(),
      accessKey: (s3_access_key || fromDb.s3_access_key || '').trim(),
      secretKey: (s3_secret_key || fromDb.s3_secret_key || '').trim(),
      region:    (s3_region    || fromDb.s3_region    || 'us-east-1').trim(),
    };

    if (!cfg.endpoint) return res.status(400).json({ error: 'S3 endpoint is required' });
    if (!cfg.bucket)   return res.status(400).json({ error: 'S3 bucket is required' });

    const client = buildTestClient(cfg);
    const result = await testS3Connection(client, cfg.bucket);
    res.json(result);
  } catch (err) {
    res.status(400).json({ ok: false, error: err.message });
  }
});

// ── AMPP integration ───────────────────────────────────────────────────────────

router.get('/ampp', async (req, res, next) => {
  try {
    const result = await pool.query(
      "SELECT key, value FROM settings WHERE key IN ('ampp_base_url', 'ampp_token')"
    );
    const out = {};
    for (const row of result.rows) {
      if (row.key === 'ampp_token') {
        out.ampp_token_exists = true;
      } else {
        out[row.key] = row.value;
      }
    }
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/ampp', async (req, res, next) => {
  try {
    const { ampp_base_url, ampp_token } = req.body;
    if (!ampp_base_url) return res.status(400).json({ error: 'ampp_base_url is required' });
    const baseUrl = ampp_base_url.trim().replace(/\/$/, '');
    await pool.query(
      `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_base_url', $1, NOW())
       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
      [baseUrl]
    );
    if (ampp_token) {
      await pool.query(
        `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_token', $1, NOW())
         ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
        [ampp_token.trim()]
      );
    }
    res.json({ message: 'AMPP settings saved' });
  } catch (err) {
    next(err);
  }
});

router.post('/ampp/test', async (req, res, next) => {
  try {
    const config = await getAmppConfig();
    if (!config) return res.status(400).json({ error: 'AMPP credentials not configured' });
    const testUrl = `${config.ampp_base_url}/api/v1/store/folder/folders?limit=1`;
    const testRes = await fetch(testUrl, {
      headers: { Authorization: `Bearer ${config.ampp_token}`, 'Content-Type': 'application/json' },
    });
    if (!testRes.ok) return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
    res.json({ message: 'AMPP connection successful' });
  } catch (err) {
    res.status(400).json({ error: `Connection failed: ${err.message}` });
  }
});

// ── Hardware inventory ────────────────────────────────────────────────────────

router.get('/hardware', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT id, hostname, role, ip_address, capabilities,
              EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
       FROM cluster_nodes
       ORDER BY role, hostname`
    );
    const nodes = result.rows.map(n => ({
      id:           n.id,
      hostname:     n.hostname,
      role:         n.role,
      ip_address:   n.ip_address,
      online:       n.stale_seconds < 120,
      capabilities: n.capabilities || {},
    }));
    res.json({ nodes });
  } catch (err) {
    next(err);
  }
});

// ── GPU / Transcoding ─────────────────────────────────────────────────────────

router.get('/transcoding', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps','gpu_node',
                     'gpu_extension','gpu_framerate','gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
    );
    const out = {
      gpu_transcode_enabled:  'false',
      gpu_codec:              'h264_nvenc',
      gpu_preset:             'p4',
      gpu_bitrate_mbps:       '8',
      gpu_node:               '',
      gpu_extension:          'mp4',
      gpu_framerate:          'passthrough',
      gpu_rc_mode:            'cbr',
      gpu_audio_codec:        'aac',
      gpu_audio_bitrate_kbps: '192',
    };
    for (const { key, value } of result.rows) out[key] = value;
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/transcoding', async (req, res, next) => {
  try {
    const allowed = [
      'gpu_transcode_enabled', 'gpu_codec', 'gpu_preset', 'gpu_bitrate_mbps', 'gpu_node',
      'gpu_extension', 'gpu_framerate', 'gpu_rc_mode', 'gpu_audio_codec', 'gpu_audio_bitrate_kbps',
    ];
    for (const key of allowed) {
      if (req.body[key] !== undefined) {
        await pool.query(
          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
          [key, String(req.body[key])]
        );
      }
    }
    res.json({ message: 'Transcoding settings saved' });
  } catch (err) {
    next(err);
  }
});

// ── Capture service routing ───────────────────────────────────────────────────

router.get('/capture-service', async (req, res, next) => {
  try {
    const result = await pool.query(
      "SELECT value FROM settings WHERE key = 'capture_service_url'"
    );
    res.json({ capture_service_url: result.rows[0]?.value || '' });
  } catch (err) {
    next(err);
  }
});

router.put('/capture-service', async (req, res, next) => {
  try {
    const url = (req.body.capture_service_url || '').trim().replace(/\/$/, '');
    await pool.query(
      `INSERT INTO settings (key, value, updated_at) VALUES ('capture_service_url', $1, NOW())
       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
      [url]
    );
    res.json({ message: 'Capture service URL saved' });
  } catch (err) {
    next(err);
  }
});

export default router;

+// ── Growing files (SMB landing zone) ─────────────────────────────────────────

+// Lets capture write its master output to a fast local SMB share instead of

+// streaming directly to S3. Premiere can mount the share and edit the file

+// 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'];

+router.get('/growing', async (req, res, next) => {

+      `SELECT key, value FROM settings WHERE key = ANY($1)`,

+      growing_promote_after_seconds: '8',

+    for (const { key, value } of result.rows) out[key] = value;

+router.put('/growing', async (req, res, next) => {

+      if (req.body[key] !== undefined) {

+          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())

+           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,

+    res.json({ message: 'Growing-files settings saved' });


import { spawn } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';

const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';

import { spawn } from 'child_process';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';

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
// (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';


The file /tmp/wd/wild-dragon/services/capture/src/capture-manager.js has been updated successfully. (file state is current in your context — no need to Read it back)

import { spawn } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';

const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';

import { spawn } from 'child_process';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';

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
// (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';

+// 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

+// (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';

// ── 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.


    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
    };
  }

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      growingPath: currentSession.growingPath || null,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
    };
  }

+      growingPath: currentSession.growingPath || null,


    const sessionId = uuidv4();
    const hiresExt  = CONTAINER_EXT[container] || 'mov';
    const proxyExt  = CONTAINER_EXT[proxyContainer] || 'mp4';
    const hiresKey  = `projects/${projectId}/masters/${clipName}.${hiresExt}`;

    const sessionId = uuidv4();
    const hiresExt  = CONTAINER_EXT[container] || 'mov';
    const proxyExt  = CONTAINER_EXT[proxyContainer] || 'mp4';
    const hiresKey  = `projects/${projectId}/masters/${clipName}.${hiresExt}`;

    // 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
      ? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
      : null;
    if (growingPath) {
      try { mkdirSync(dirname(growingPath), { recursive: true }); }
      catch (err) { console.error('[capture] could not create growing dir:', err.message); }
    }


    const sessionId = uuidv4();
    const hiresExt  = CONTAINER_EXT[container] || 'mov';
    const proxyExt  = CONTAINER_EXT[proxyContainer] || 'mp4';
    const hiresKey  = `projects/${projectId}/masters/${clipName}.${hiresExt}`;

    // 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
      ? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
      : null;
    if (growingPath) {
      try { mkdirSync(dirname(growingPath), { recursive: true }); }
      catch (err) { console.error('[capture] could not create growing dir:', err.message); }
    }

+    // 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

+      ? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`

+      try { mkdirSync(dirname(growingPath), { recursive: true }); }

+      catch (err) { console.error('[capture] could not create growing dir:', err.message); }

// Network sources cannot be opened by two FFmpeg processes simultaneously

// (one socket = one consumer). For SRT/RTMP the BullMQ worker generates


    const hiresProcess = spawn('ffmpeg', [
      ...inputArgs,
      ...sdiFilterArgs,
      ...hiresCodecArgs,
      'pipe:1',
    ], { stdio: ['ignore', 'pipe', 'pipe'] });

    const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);

    const processes = { hires: hiresProcess };
    const uploads = { hires: hiresUpload };

    // When growing-files is on, write directly to the SMB share so Premier
    // can mount and edit the live file. Promotion worker uploads to S3 on EOF.
    // Otherwise, stream the master to S3 via stdout pipe (legacy behavior).
    const hiresOutput = growingPath ? growingPath : 'pipe:1';
    const hiresStdio  = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];

    const hiresProcess = spawn('ffmpeg', [
      ...inputArgs,
      ...sdiFilterArgs,
      ...hiresCodecArgs,
      hiresOutput,
    ], { stdio: hiresStdio });

    const hiresUpload = growingPath
      ? Promise.resolve({ growingPath })
      : createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);

    const processes = { hires: hiresProcess };
    const uploads = { hires: hiresUpload };


    const hiresProcess = spawn('ffmpeg', [
      ...inputArgs,
      ...sdiFilterArgs,
      ...hiresCodecArgs,
      'pipe:1',
    ], { stdio: ['ignore', 'pipe', 'pipe'] });

    const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);

    const processes = { hires: hiresProcess };
    const uploads = { hires: hiresUpload };

    // When growing-files is on, write directly to the SMB share so Premier
    // can mount and edit the live file. Promotion worker uploads to S3 on EOF.
    // Otherwise, stream the master to S3 via stdout pipe (legacy behavior).
    const hiresOutput = growingPath ? growingPath : 'pipe:1';
    const hiresStdio  = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];

    const hiresProcess = spawn('ffmpeg', [
      ...inputArgs,
      ...sdiFilterArgs,
      ...hiresCodecArgs,
      hiresOutput,
    ], { stdio: hiresStdio });

    const hiresUpload = growingPath
      ? Promise.resolve({ growingPath })
      : createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);

    const processes = { hires: hiresProcess };
    const uploads = { hires: hiresUpload };

const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];

+    // When growing-files is on, write directly to the SMB share so Premier

+    // can mount and edit the live file. Promotion worker uploads to S3 on EOF.

+    // Otherwise, stream the master to S3 via stdout pipe (legacy behavior).

+    const hiresOutput = growingPath ? growingPath : 'pipe:1';

+    const hiresStdio  = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];

-    ], { stdio: ['ignore', 'pipe', 'pipe'] });

-    const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);

+      ? Promise.resolve({ growingPath })

+      : createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);


    this.state.currentSession = {
      sessionId,
      projectId,
      binId,
      clipName,
      device,
      sourceType,
      sourceUrl,
      hiresKey,
      proxyKey,
      startedAt,
      duration: 0,
      uploads,
      codecs: {

    this.state.currentSession = {
      sessionId,
      projectId,
      binId,
      clipName,
      device,
      sourceType,
      sourceUrl,
      hiresKey,
      proxyKey,
      growingPath,
      startedAt,
      duration: 0,
      uploads,
      codecs: {


    this.state.currentSession = {
      sessionId,
      projectId,
      binId,
      clipName,
      device,
      sourceType,
      sourceUrl,
      hiresKey,
      proxyKey,
      startedAt,
      duration: 0,
      uploads,
      codecs: {

    this.state.currentSession = {
      sessionId,
      projectId,
      binId,
      clipName,
      device,
      sourceType,
      sourceUrl,
      hiresKey,
      proxyKey,
      growingPath,
      startedAt,
      duration: 0,
      uploads,
      codecs: {

sourceUrl,
hiresKey,
proxyKey,
+      growingPath,
startedAt,
duration: 0,
uploads,


    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
    };
  }

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      growingPath: currentSession.growingPath || null,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
    };
  }


Recon: clone repo, snapshot deployed UI, inventory TrueNAS storage

Investigate ingest pipeline: why 12 jobs failed, why session 224002 didn't become an asset

Wire real video preview into Recorders + Monitors (replace LiveStrip/FauxFrame)

Fix Settings page – currently only S3 panel; verify what is broken on deploy

Button audit pass across all pages

Provision SMB growing-files share on TrueNAS NVME

Implement growing-file → S3 promotion pipeline

Prototype Premier CEP panel with proxy switching (build on existing premiere-plugin scaffold)

Deploy to zampp1 + push to main


TodoWrite

Recon: clone repo, snapshot deployed UI, inventory TrueNAS storage

Proxy worker: handle odd dimensions + image inputs + bad probe

Wire real video preview into Recorders + Monitors (HlsPreview)

Settings page: real tabs + GPU/Growing/SDI/AMPP panels

Add /settings/growing endpoint + capture-manager growing-file mode

Wire recorders.js to pass GROWING env to capture container; bind /growing mount

Promotion worker: SMB → S3 on idle

Provision SMB growing-files share on TrueNAS NVME

Premier CEP panel: surface growing assets + Mount Live button

Button audit pass + small UX fixes

Deploy to zampp1 + push to main


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Button audit pass across all pages

Deploy to zampp1 + push to main

Proxy worker: handle odd dimensions + image inputs + bad probe

Wire real video preview into Recorders + Monitors (HlsPreview)

Settings page: real tabs + GPU/Growing/SDI/AMPP panels

Add /settings/growing endpoint + capture-manager growing-file mode

Wire recorders.js to pass GROWING env to capture container; bind /growing mount

Promotion worker: SMB → S3 on idle

Premier CEP panel: surface growing assets + Mount Live button

Button audit pass + small UX fixes

Deploy to zampp1 + push to main


Now wire `GROWING_ENABLED` into the recorders route so it flows to the container, mount `/growing/` on host, and write the promotion worker.


Edit

    const s3Endpoint = process.env.S3_ENDPOINT;
    const s3Bucket = process.env.S3_BUCKET;
    const s3AccessKey = process.env.S3_ACCESS_KEY;
    const s3SecretKey = process.env.S3_SECRET_KEY;
    const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
    const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';

    const clipName = generateClipName(recorder.name);

    const s3Endpoint = process.env.S3_ENDPOINT;
    const s3Bucket = process.env.S3_BUCKET;
    const s3AccessKey = process.env.S3_ACCESS_KEY;
    const s3SecretKey = process.env.S3_SECRET_KEY;
    const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
    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;

    const clipName = generateClipName(recorder.name);