1	import { execFile } from 'child_process';
2	import { promisify } from 'util';
3
4	const execFileAsync = promisify(execFile);
5
6	export const runFFmpeg = async (args, options = {}) => {
7	  const { stdio = 'pipe', ...otherOptions } = options;
8	  try {
9	    const { stdout, stderr } = await execFileAsync('ffmpeg', args, {
10	      stdio,
11	      ...otherOptions,
12	    });
13	    return { stdout, stderr };
14	  } catch (error) {
15	    throw new Error(`FFmpeg error: ${error.message}\nStderr: ${error.stderr}`);
16	  }
17	};
18
19	const runFFprobe = async (args) => {
20	  try {
21	    const { stdout } = await execFileAsync('ffprobe', args);
22	    return { stdout };
23	  } catch (error) {
24	    throw new Error(`FFprobe error: ${error.message}\nStderr: ${error.stderr}`);
25	  }
26	};
27
28	export const getMediaDuration = async (inputPath) => {
29	  const args = [
30	    '-v', 'error',
31	    '-show_entries', 'format=duration',
32	    '-of', 'default=noprint_wrappers=1:nokey=1',
33	    inputPath,
34	  ];
35	  const { stdout } = await runFFprobe(args);
36	  return parseFloat(stdout.trim());
37	};
38
39	/**
40	 * Return structured media metadata for an input file.
41	 * Result shape:
42	 *   { fps, codec, resolution, durationMs, fileSizeBytes }
43	 * Any field may be null if ffprobe cannot determine it.
44	 */
45	export const getMediaInfo = async (inputPath) => {
46	  const args = [
47	    '-v', 'quiet',
48	    '-print_format', 'json',
49	    '-show_streams',
50	    '-show_format',
51	    inputPath,
52	  ];
53	  const { stdout } = await runFFprobe(args);
54	  const info = JSON.parse(stdout);
55
56	  const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
57	  const fmt         = info.format || {};
58
59	  let fps = null;
60	  if (videoStream?.r_frame_rate) {
61	    const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
62	    if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
63	  }
64
65	  const hasAudio = (info.streams || []).some(s => s.codec_type === 'audio');
66
67	  return {
68	    fps,
69	    codec:          videoStream?.codec_name  || null,
70	    resolution:     videoStream ? `${videoStream.width}x${videoStream.height}` : null,
71	    durationMs:     fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
72	    fileSizeBytes:  fmt.size     ? parseInt(fmt.size, 10)                       : null,
73	    hasAudio,
74	  };
75	};
76
77	export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
78	  const args = [
79	    '-ss', timeCode,
80	    '-i', inputPath,
81	    '-vframes', '1',
82	    '-q:v', '2',
83	    '-y',
84	    outputPath,
85	  ];
86	  await runFFmpeg(args);
87	};
88
89	export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
90	  const {
91	    videoCodec = 'libx264',
92	    videoPreset = 'fast',
93	    videoBitrate = '10M',
94	    audioCodec = 'aac',
95	    audioBitrate = '192k',
96	    hasAudio = true,
97	  } = options;
98
99	  // libx264 / yuv420p require even width AND height. Captured frames from SDI
100	  // or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125). Force-even
101	  // before pixel-format conversion so the encoder never sees odd dimensions.
102	  const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";
103
104	  // analyzeduration/probesize must be set BEFORE -i. Some ProRes captures
105	  // write unusual timebases (60k tbn) that ffmpeg cannot resolve with the
106	  // default 5MB probe — bump to 100MB so we always read enough of the file.
107	  const args = [
108	    '-analyzeduration', '100M',
109	    '-probesize',       '100M',
110	    '-i', inputPath,
111	    '-vf', vf,
112	    '-c:v', videoCodec,
113	    '-preset', videoPreset,
114	    '-b:v', videoBitrate,
115	  ];
116
117	  if (hasAudio) {
118	    args.push('-c:a', audioCodec, '-b:a', audioBitrate);
119	  } else {
120	    args.push('-an');
121	  }
122
123	  args.push('-movflags', '+faststart', '-y', outputPath);
124
125	  await runFFmpeg(args);
126	};
127
128	// Single-frame poster — used as a fallback "proxy" for still-image assets
129	// so the library can show them without a transcoded video.
130	export const transcodeImage = async (inputPath, outputPath) => {
131	  await runFFmpeg([
132	    '-i', inputPath,
133	    '-vf', "scale='min(1920,iw)':-2",
134	    '-q:v', '3',
135	    '-y', outputPath,
136	  ]);
137	};
138
139	export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
140	  const args = [
141	    '-i', inputPath,
142	    '-ss', inPoint,
143	    '-to', outPoint,
144	    '-c', 'copy',
145	    '-y',
146	    outputPath,
147	  ];
148	  await runFFmpeg(args);
149	};
150
151	export const concatSegments = async (segmentListFile, outputPath) => {
152	  const args = [
153	    '-f', 'concat',
154	    '-safe', '0',
155	    '-i', segmentListFile,
156	    '-c', 'copy',
157	    '-y',
158	    outputPath,
159	  ];
160	  await runFFmpeg(args);
161	};
162

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;
  }

  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,
  };
};

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);
};


1	{
2	  "name": "wild-dragon-mam-api",
3	  "version": "0.1.0",
4	  "description": "Media Asset Management API for Wild Dragon",
5	  "main": "src/index.js",
6	  "scripts": {
7	    "start": "node src/index.js",
8	    "dev": "node --watch src/index.js"
9	  },
10	  "dependencies": {
11	    "express": "^4.18.2",
12	    "pg": "^8.11.3",
13	    "connect-pg-simple": "^9.0.1",
14	    "express-session": "^1.17.3",
15	    "cors": "^2.8.5",
16	    "bcrypt": "^5.1.1",
17	    "@aws-sdk/client-s3": "^3.500.0",
18	    "@aws-sdk/s3-request-presigner": "^3.500.0",
19	    "@aws-sdk/lib-storage": "^3.500.0",
20	    "bullmq": "^5.5.0",
21	    "multer": "^1.4.5-lts.1",
22	    "uuid": "^9.0.1",
23	    "dotenv": "^16.4.5"
24	  },
25	  "engines": {
26	    "node": ">=22.0.0"
27	  }
28	}
29


Found 1 file
/tmp/wd/wild-dragon/services/mam-api/src/routes/upload.js


Investigate S3 settings load path - why blank?

Audit existing Settings tabs (remove AMPP, redesign GPU)

Research SDK install pipeline (existing patch_decklink.py)

Design + implement SDK deployment UI

Refactor Settings: drop AMPP, GPU as global proxy settings

Wire S3 settings env-var fallback

Commit, push, redeploy, smoke test


1	import express from 'express';
2	import multer from 'multer';
3	import { Queue } from 'bullmq';
4	import { v4 as uuidv4 } from 'uuid';
5	import pool from '../db/pool.js';
6	import { s3Client, uploadStream, deleteObject, getS3Bucket } from '../s3/client.js';
7	import {
8	  CreateMultipartUploadCommand,
9	  UploadPartCommand,
10	  CompleteMultipartUploadCommand,
11	  AbortMultipartUploadCommand,
12	} from '@aws-sdk/client-s3';
13	import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
14
15	const router = express.Router();
16
17	const memoryStorage = multer.memoryStorage();
18	const upload = multer({ storage: memoryStorage });
19
20	const parseRedisUrl = (url) => {
21	  const parsed = new URL(url);
22	  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
23	};
24
25	// Only proxy queue needed here — proxy worker dispatches thumbnail once done
26	const proxyQueue = new Queue('proxy', {
27	  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
28	});
29
30	// ---------------------------------------------------------------
31	// AMPP Sync Helpers
32	// ---------------------------------------------------------------
33
34	async function resolveBinPath(binId) {
35	  const segments = [];
36	  let currentId = binId;
37	  while (currentId) {
38	    const result = await pool.query(
39	      'SELECT id, name, parent_id FROM bins WHERE id = $1',
40	      [currentId]

import express from 'express';
import multer from 'multer';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { s3Client, uploadStream, deleteObject, getS3Bucket } from '../s3/client.js';
import {
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';

const router = express.Router();

const memoryStorage = multer.memoryStorage();
const upload = multer({ storage: memoryStorage });

const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};

// Only proxy queue needed here — proxy worker dispatches thumbnail once done
const proxyQueue = new Queue('proxy', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

// ---------------------------------------------------------------
// AMPP Sync Helpers
// ---------------------------------------------------------------

async function resolveBinPath(binId) {
  const segments = [];
  let currentId = binId;
  while (currentId) {
    const result = await pool.query(
      'SELECT id, name, parent_id FROM bins WHERE id = $1',
      [currentId]


Bash

grep -n "use.*router\|router.*=.*require\|import.*Router" /tmp/wd/wild-dragon/services/mam-api/src/index.js | head -30

Find route mounting in index.js


13:import authRouter from './routes/auth.js';
14:import assetsRouter from './routes/assets.js';
15:import projectsRouter from './routes/projects.js';
16:import binsRouter from './routes/bins.js';
17:import jobsRouter from './routes/jobs.js';
18:import captureRouter from './routes/capture.js';
19:import uploadRouter from './routes/upload.js';
20:import recordersRouter from './routes/recorders.js';
21:import settingsRouter from './routes/settings.js';
22:import amppRouter from './routes/ampp.js';
23:import usersRouter    from './routes/users.js';
24:import groupsRouter   from './routes/groups.js';
25:import tokensRouter   from './routes/tokens.js';
26:import sequencesRouter from './routes/sequences.js';
27:import systemRouter  from './routes/system.js';
28:import clusterRouter from './routes/cluster.js';


Ok - another push here.  The GUI needs the ability to deploy the Blackmagic SDK , Deltacast SDK, or AJA SDK ( I want to support those 3 capture cards for now). Can we wire that in?  Also, the settings…


Debug ingest, monitors, buttons and prototype file editing


grep -n "app.use.*'/api" /tmp/wd/wild-dragon/services/mam-api/src/index.js

Find API route mounting


61:app.use('/api/v1/auth',      authRouter);
62:app.use('/api/v1/assets',    assetsRouter);
63:app.use('/api/v1/projects',  projectsRouter);
64:app.use('/api/v1/bins',      binsRouter);
65:app.use('/api/v1/jobs',      jobsRouter);
66:app.use('/api/v1/capture',   captureRouter);
67:app.use('/api/v1/upload',    uploadRouter);
68:app.use('/api/v1/recorders', recordersRouter);
69:app.use('/api/v1/settings',  settingsRouter);
70:app.use('/api/v1/ampp',      amppRouter);
71:app.use('/api/v1/users',     usersRouter);
72:app.use('/api/v1/groups',    groupsRouter);
73:app.use('/api/v1/tokens',    tokensRouter);
74:app.use('/api/v1/sequences', sequencesRouter);
75:app.use('/api/v1/system',    systemRouter);
76:app.use('/api/v1/cluster',   clusterRouter);


ls -la /mnt/NVME/MAM/wild-dragon/services/capture/sdk/ 2>&1 | head -15; echo ---; ls /mnt/NVME/MAM/wild-dragon/services/capture/lib/ 2>&1


$ ls -la /mnt/NVME/MAM/wild-dragon/services/capture/sdk/ 2>&1 | head -15; echo ---; ls /mnt/NVME/MAM/wild-dragon/services/capture/lib/ 2>&1

ls: cannot access '/mnt/NVME/MAM/wild-dragon/services/capture/sdk/': No such file or directory
---
ls: cannot access '/mnt/NVME/MAM/wild-dragon/services/capture/lib/': No such file or directory

[Exit code: 2]


cat /tmp/wd/wild-dragon/services/worker/src/db/client.js 2>&1 | head -30

Check worker DB client


import pg from 'pg';

const { Pool } = pg;

let pool;

export const getPool = () => {
  if (!pool) {
    pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
  }
  return pool;
};

export const query = async (text, params) => {
  const client = await getPool().connect();
  try {
    return await client.query(text, params);
  } finally {
    client.release();
  }
};

export const closePool = async () => {
  if (pool) {
    await pool.end();
    pool = null;
  }
};


grep -A 15 "mam-api:" /tmp/wd/wild-dragon/docker-compose.yml | head -25

Inspect mam-api docker-compose entry


  mam-api:
    build: ./services/mam-api
    depends_on:
      db:
        condition: service_healthy
      queue:
        condition: service_started
    ports:
      - "${PORT_MAM_API:-7432}:3000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /mnt/NVME/MAM/wild-dragon-growing:/growing
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
--
      MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
    volumes:
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
    networks:
      - wild-dragon


Plan is clear. Starting with the highest-impact fixes in order.

### 1. S3 settings env-var fallback


Edit

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.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')`
    );
    // Env defaults — surface what's actually wired so the UI doesn't show
    // "not configured" when the container was started with S3_* env vars.
    const envEndpoint  = process.env.S3_ENDPOINT   || '';
    const envBucket    = process.env.S3_BUCKET     || getS3Bucket();
    const envAccessKey = process.env.S3_ACCESS_KEY || '';
    const envSecretKey = process.env.S3_SECRET_KEY || '';
    const envRegion    = process.env.S3_REGION     || 'us-east-1';

    const out = {
      s3_endpoint:   envEndpoint,
      s3_bucket:     envBucket,
      s3_access_key: envAccessKey,
      s3_secret_key_exists: !!envSecretKey,
      s3_region:     envRegion,
      source:        envEndpoint ? 'env' : 'unset',
    };
    for (const { key, value } of result.rows) {
      if (key === 's3_secret_key') {
        if (value) { out.s3_secret_key_exists = true; out.source = 'db'; }
      } else if (value) {
        out[key] = value;
        out.source = 'db';
      }
    }
    res.json(out);
  } catch (err) {
    next(err);
  }
});


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)

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.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')`
    );
    // Env defaults — surface what's actually wired so the UI doesn't show
    // "not configured" when the container was started with S3_* env vars.
    const envEndpoint  = process.env.S3_ENDPOINT   || '';
    const envBucket    = process.env.S3_BUCKET     || getS3Bucket();
    const envAccessKey = process.env.S3_ACCESS_KEY || '';
    const envSecretKey = process.env.S3_SECRET_KEY || '';
    const envRegion    = process.env.S3_REGION     || 'us-east-1';

    const out = {
      s3_endpoint:   envEndpoint,
      s3_bucket:     envBucket,
      s3_access_key: envAccessKey,
      s3_secret_key_exists: !!envSecretKey,
      s3_region:     envRegion,
      source:        envEndpoint ? 'env' : 'unset',
    };
    for (const { key, value } of result.rows) {
      if (key === 's3_secret_key') {
        if (value) { out.s3_secret_key_exists = true; out.source = 'db'; }
      } else if (value) {
        out[key] = value;
        out.source = 'db';
      }
    }
    res.json(out);
  } catch (err) {
    next(err);
  }
});

+    // Env defaults — surface what's actually wired so the UI doesn't show

+    // "not configured" when the container was started with S3_* env vars.

+    const envEndpoint  = process.env.S3_ENDPOINT   || '';

+    const envBucket    = process.env.S3_BUCKET     || getS3Bucket();

+    const envAccessKey = process.env.S3_ACCESS_KEY || '';

+    const envSecretKey = process.env.S3_SECRET_KEY || '';

+    const envRegion    = process.env.S3_REGION     || 'us-east-1';

+      s3_secret_key_exists: !!envSecretKey,

+      source:        envEndpoint ? 'env' : 'unset',

-        out.s3_secret_key_exists = !!value;

+        if (value) { out.s3_secret_key_exists = true; out.source = 'db'; }


TodoWrite

Investigate S3 settings load path - why blank?

Wire S3 settings env-var fallback

Refactor Settings: drop AMPP, rename GPU to Proxy encoding

Wire proxy.js to read GPU settings from DB

Add SDK upload backend (multer + extract)

Add SDK settings card in UI

Commit, push, redeploy, smoke test


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

Design + implement SDK deployment UI

Wire S3 settings env-var fallback

Commit, push, redeploy, smoke test

Wire S3 settings env-var fallback

Refactor Settings: drop AMPP, rename GPU to Proxy encoding

Wire proxy.js to read GPU settings from DB

Add SDK upload backend (multer + extract)

Add SDK settings card in UI

Commit, push, redeploy, smoke test


### 2. Settings UI: drop AMPP, rename GPU to "Proxy encoding", add SDK tab


Edit

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 Settings() {
  const [section, setSection] = React.useState('storage');

  const SECTIONS = [
    { id: 'storage',  label: 'S3 / Object storage', icon: 'hdd' },
    { id: 'proxy',    label: 'Proxy encoding',      icon: 'gpu' },
    { id: 'growing',  label: 'Growing files (SMB)', icon: 'hdd' },
    { id: 'sdk',      label: 'Capture SDKs',        icon: 'video' },
    { id: 'sdi',      label: 'SDI capture',         icon: 'video' },
  ];

  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 === 'proxy'   && <GpuSettingsCard />}
            {section === 'growing' && <GrowingSettingsCard />}
            {section === 'sdk'     && <SdkSettingsCard />}
            {section === 'sdi'     && <SdiSettingsCard />}

  );
}


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 [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 Settings() {
  const [section, setSection] = React.useState('storage');

  const SECTIONS = [
    { id: 'storage',  label: 'S3 / Object storage', icon: 'hdd' },
    { id: 'proxy',    label: 'Proxy encoding',      icon: 'gpu' },
    { id: 'growing',  label: 'Growing files (SMB)', icon: 'hdd' },
    { id: 'sdk',      label: 'Capture SDKs',        icon: 'video' },
    { id: 'sdi',      label: 'SDI capture',         icon: 'video' },
  ];

  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 === 'proxy'   && <GpuSettingsCard />}
            {section === 'growing' && <GrowingSettingsCard />}
            {section === 'sdk'     && <SdkSettingsCard />}
            {section === 'sdi'     && <SdiSettingsCard />}

  );
}

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

+    { id: 'proxy',    label: 'Proxy encoding',      icon: 'gpu' },

+    { id: 'sdk',      label: 'Capture SDKs',        icon: 'video' },

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

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

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

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

+            {section === 'sdk'     && <SdkSettingsCard />}

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


Updating the GPU card to "Proxy encoding" with global wording and CPU fallback knobs:


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 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 — new settings apply to the next proxy job.' }); })
      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };

  if (!cfg) return <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file">
Loading…
</SettingsCard>;

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

  return (
    <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"
      tag={gpuEnabled ? GPU mode : CPU mode}>

        These settings drive the proxy worker for every ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.

      <SField label="Hardware acceleration">

          <input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
          Use GPU encoders (NVENC / VAAPI) when available — falls back to CPU on missing hardware

      </SField>

        <SField label={gpuEnabled ? 'GPU codec' : 'CPU codec'}>
          <select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
            {gpuEnabled ? (<>
              <option value="h264_nvenc">h264_nvenc (NVIDIA)</option>
              <option value="hevc_nvenc">hevc_nvenc (NVIDIA HEVC)</option>
              <option value="h264_vaapi">h264_vaapi (Intel/AMD)</option>
              <option value="hevc_vaapi">hevc_vaapi (Intel/AMD HEVC)</option>
            </>) : (<>
              <option value="libx264">libx264 (H.264, recommended)</option>
              <option value="libx265">libx265 (HEVC, slower)</option>
            </>)}
          </select>
        </SField>
        <SField label="Preset">
          <select className="field-input" value={cfg.gpu_preset || (gpuEnabled ? 'p4' : 'fast')} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
            {gpuEnabled
              ? ['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)
              : ['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower'].map(p => <option key={p} value={p}>{p}</option>)}
          </select>
        </SField>

        <SField label="Target bitrate (Mbps)">
          <input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="10" />
        </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 — constant bitrate</option>
            <option value="vbr">VBR — variable bitrate</option>
            <option value="cqp">CQP / CRF — constant quality</option>
          </select>
        </SField>

        <SField label="Audio codec">
          <select className="field-input" value={cfg.gpu_audio_codec || 'aac'} onChange={e => set('gpu_audio_codec', e.target.value)} style={{ appearance: 'auto' }}>
            <option value="aac">aac</option>
            <option value="opus">opus</option>
            <option value="mp3">mp3</option>
          </select>
        </SField>
        <SField label="Audio bitrate (kbps)">
          <input className="field-input mono" type="number" value={cfg.gpu_audio_bitrate_kbps || ''} onChange={e => set('gpu_audio_bitrate_kbps', e.target.value)} placeholder="192" />
        </SField>

      <SettingsMsg msg={msg} />

        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</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 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 — new settings apply to the next proxy job.' }); })
      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
  };

  if (!cfg) return <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file">
Loading…
</SettingsCard>;

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

  return (
    <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"
      tag={gpuEnabled ? GPU mode : CPU mode}>

        These settings drive the proxy worker for every ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.

      <SField label="Hardware acceleration">

          <input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
          Use GPU encoders (NVENC / VAAPI) when available — falls back to CPU on missing hardware

      </SField>

        <SField label={gpuEnabled ? 'GPU codec' : 'CPU codec'}>
          <select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
            {gpuEnabled ? (<>
              <option value="h264_nvenc">h264_nvenc (NVIDIA)</option>
              <option value="hevc_nvenc">hevc_nvenc (NVIDIA HEVC)</option>
              <option value="h264_vaapi">h264_vaapi (Intel/AMD)</option>
              <option value="hevc_vaapi">hevc_vaapi (Intel/AMD HEVC)</option>
            </>) : (<>
              <option value="libx264">libx264 (H.264, recommended)</option>
              <option value="libx265">libx265 (HEVC, slower)</option>
            </>)}
          </select>
        </SField>
        <SField label="Preset">
          <select className="field-input" value={cfg.gpu_preset || (gpuEnabled ? 'p4' : 'fast')} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
            {gpuEnabled
              ? ['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)
              : ['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower'].map(p => <option key={p} value={p}>{p}</option>)}
          </select>
        </SField>

        <SField label="Target bitrate (Mbps)">
          <input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="10" />
        </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 — constant bitrate</option>
            <option value="vbr">VBR — variable bitrate</option>
            <option value="cqp">CQP / CRF — constant quality</option>
          </select>
        </SField>

        <SField label="Audio codec">
          <select className="field-input" value={cfg.gpu_audio_codec || 'aac'} onChange={e => set('gpu_audio_codec', e.target.value)} style={{ appearance: 'auto' }}>
            <option value="aac">aac</option>
            <option value="opus">opus</option>
            <option value="mp3">mp3</option>
          </select>
        </SField>
        <SField label="Audio bitrate (kbps)">
          <input className="field-input mono" type="number" value={cfg.gpu_audio_bitrate_kbps || ''} onChange={e => set('gpu_audio_bitrate_kbps', e.target.value)} placeholder="192" />
        </SField>

      <SettingsMsg msg={msg} />

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

    </SettingsCard>
  );
}

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

+      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved — new settings apply to the next proxy job.' }); })

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

+  if (!cfg) return <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file">
Loading…
</SettingsCard>;

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

+  const gpuEnabled = 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">

+    <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"

+      tag={gpuEnabled ? GPU mode : CPU mode}>

+      <div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 4 }}>

+        These settings drive the proxy worker for every ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.

+      <SField label="Hardware acceleration">

<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

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

+          Use GPU encoders (NVENC / VAAPI) when available — falls back to CPU on missing hardware

+        <SField label={gpuEnabled ? 'GPU codec' : 'CPU codec'}>

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

+              <option value="h264_nvenc">h264_nvenc (NVIDIA)</option>

+              <option value="hevc_nvenc">hevc_nvenc (NVIDIA HEVC)</option>

+              <option value="h264_vaapi">h264_vaapi (Intel/AMD)</option>

+              <option value="hevc_vaapi">hevc_vaapi (Intel/AMD HEVC)</option>

+              <option value="libx264">libx264 (H.264, recommended)</option>

+              <option value="libx265">libx265 (HEVC, slower)</option>

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

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

+              : ['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower'].map(p => <option key={p} value={p}>{p}</option>)}

-        <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 label="Target bitrate (Mbps)">

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

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

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

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

+            <option value="cbr">CBR — constant bitrate</option>

+            <option value="vbr">VBR — variable bitrate</option>

+            <option value="cqp">CQP / CRF — constant quality</option>

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

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

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

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

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

+        <SField label="Audio bitrate (kbps)">

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

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


Now adding the new SDK card. Let me drop it in before `function AmppSettingsCard()`:


// ────────────────────────────────────────────────────────────────────────────
// Capture SDK deployment — Blackmagic / AJA / Deltacast
// ────────────────────────────────────────────────────────────────────────────
const SDK_VENDORS = [
  {
    id: 'blackmagic',
    name: 'Blackmagic DeckLink',
    sub: 'DeckLink SDK 16.x — required for SDI capture via DeckLink cards',
    expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',
    docs: 'https://www.blackmagicdesign.com/developer/product/capture',
    buildHint: 'docker compose build --no-cache capture',
    status: 'wired',
  },
  {
    id: 'aja',
    name: 'AJA NTV2',
    sub: 'NTV2 SDK — for Kona / Io / U-Tap / T-Tap cards',
    expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',
    docs: 'https://sdksupport.aja.com/',
    buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
    status: 'staging-only',
  },
  {
    id: 'deltacast',
    name: 'Deltacast VideoMaster',
    sub: 'VideoMasterHD SDK — for FLEX / DELTA-h4k2 / etc.',
    expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',
    docs: 'https://www.deltacast.tv/products/sdk',
    buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
    status: 'staging-only',
  },
];

function SdkSettingsCard() {
  const [statuses, setStatuses] = React.useState(null);
  const [msg, setMsg] = React.useState(null);

  const load = React.useCallback(() => {
    window.ZAMPP_API.fetch('/sdk').then(setStatuses).catch(() => setStatuses({}));
  }, []);

  React.useEffect(() => { load(); }, [load]);

  return (
    <SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
      tag={{SDK_VENDORS.length} vendors}>

        Each SDK archive should be a .zip or .tar.gz containing the vendor's Linux SDK contents. After uploading, rebuild the capture container on the host with a DeckLink/AJA/Deltacast card. The SDK files are staged under /sdk/<vendor>/ inside mam-api.

      <SettingsMsg msg={msg} />

        {SDK_VENDORS.map(v => (
          <SdkVendorRow
            key={v.id}
            vendor={v}
            status={(statuses && statuses[v.id]) || null}
            onDone={(text, ok = true) => { setMsg({ ok, text }); load(); }}
          />
        ))}

    </SettingsCard>
  );
}

function SdkVendorRow({ vendor, status, onDone }) {
  const fileRef = React.useRef(null);
  const [uploading, setUploading] = React.useState(false);
  const [progress, setProgress] = React.useState(0);

  const deployed = status && status.file_count > 0;
  const lastUpload = status?.uploaded_at
    ? new Date(status.uploaded_at).toLocaleString()
    : null;

  const handleFile = async (file) => {
    if (!file) return;
    setUploading(true); setProgress(0);
    const fd = new FormData();
    fd.append('archive', file);

    // Use XHR so we can report progress to the user — fetch's stream API is fiddly.
    await new Promise((resolve) => {
      const xhr = new XMLHttpRequest();
      xhr.open('POST', '/api/v1/sdk/' + vendor.id);
      xhr.withCredentials = true;
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
      };
      xhr.onload = () => {
        setUploading(false); setProgress(0);
        if (xhr.status >= 200 && xhr.status < 300) {
          onDone(vendor.name + ': SDK staged.', true);
        } else {
          let txt = xhr.responseText;
          try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
          onDone(vendor.name + ': upload failed — ' + txt, false);
        }
        resolve();
      };
      xhr.onerror = () => {
        setUploading(false); setProgress(0);
        onDone(vendor.name + ': network error', false);
        resolve();
      };
      xhr.send(fd);
    });
  };

  const clear = () => {
    if (!confirm('Remove staged ' + vendor.name + ' SDK files?')) return;
    window.ZAMPP_API.fetch('/sdk/' + vendor.id, { method: 'DELETE' })
      .then(() => onDone(vendor.name + ': cleared.', true))
      .catch(e => onDone(vendor.name + ': ' + e.message, false));
  };

  return (

        {vendor.name}
        {deployed
          ? deployed · {status.file_count} files
          : not deployed}
        {vendor.status === 'staging-only' && build pipeline pending}

        {deployed && <button className="btn ghost sm" onClick={clear}>Remove</button>}
        <button className="btn primary sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
          {uploading ? `Uploading ${progress}%` : (deployed ? 'Replace' : 'Upload SDK')}
        </button>
        <input ref={fileRef} type="file" accept=".zip,.tar.gz,.tgz,.tar"
               style={{ display: 'none' }}
               onChange={e => handleFile(e.target.files?.[0])} />

        {vendor.sub}

        expects: {vendor.expect}
        {lastUpload && <>
uploaded: {lastUpload}</>}
        {deployed && <>
on host: rebuild with → {vendor.buildHint}</>}

  );
}

function AmppSettingsCard() {


// ────────────────────────────────────────────────────────────────────────────
// Capture SDK deployment — Blackmagic / AJA / Deltacast
// ────────────────────────────────────────────────────────────────────────────
const SDK_VENDORS = [
  {
    id: 'blackmagic',
    name: 'Blackmagic DeckLink',
    sub: 'DeckLink SDK 16.x — required for SDI capture via DeckLink cards',
    expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',
    docs: 'https://www.blackmagicdesign.com/developer/product/capture',
    buildHint: 'docker compose build --no-cache capture',
    status: 'wired',
  },
  {
    id: 'aja',
    name: 'AJA NTV2',
    sub: 'NTV2 SDK — for Kona / Io / U-Tap / T-Tap cards',
    expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',
    docs: 'https://sdksupport.aja.com/',
    buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
    status: 'staging-only',
  },
  {
    id: 'deltacast',
    name: 'Deltacast VideoMaster',
    sub: 'VideoMasterHD SDK — for FLEX / DELTA-h4k2 / etc.',
    expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',
    docs: 'https://www.deltacast.tv/products/sdk',
    buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
    status: 'staging-only',
  },
];

function SdkSettingsCard() {
  const [statuses, setStatuses] = React.useState(null);
  const [msg, setMsg] = React.useState(null);

  const load = React.useCallback(() => {
    window.ZAMPP_API.fetch('/sdk').then(setStatuses).catch(() => setStatuses({}));
  }, []);

  React.useEffect(() => { load(); }, [load]);

  return (
    <SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
      tag={{SDK_VENDORS.length} vendors}>

        Each SDK archive should be a .zip or .tar.gz containing the vendor's Linux SDK contents. After uploading, rebuild the capture container on the host with a DeckLink/AJA/Deltacast card. The SDK files are staged under /sdk/<vendor>/ inside mam-api.

      <SettingsMsg msg={msg} />

        {SDK_VENDORS.map(v => (
          <SdkVendorRow
            key={v.id}
            vendor={v}
            status={(statuses && statuses[v.id]) || null}
            onDone={(text, ok = true) => { setMsg({ ok, text }); load(); }}
          />
        ))}

    </SettingsCard>
  );
}

function SdkVendorRow({ vendor, status, onDone }) {
  const fileRef = React.useRef(null);
  const [uploading, setUploading] = React.useState(false);
  const [progress, setProgress] = React.useState(0);

  const deployed = status && status.file_count > 0;
  const lastUpload = status?.uploaded_at
    ? new Date(status.uploaded_at).toLocaleString()
    : null;

  const handleFile = async (file) => {
    if (!file) return;
    setUploading(true); setProgress(0);
    const fd = new FormData();
    fd.append('archive', file);

    // Use XHR so we can report progress to the user — fetch's stream API is fiddly.
    await new Promise((resolve) => {
      const xhr = new XMLHttpRequest();
      xhr.open('POST', '/api/v1/sdk/' + vendor.id);
      xhr.withCredentials = true;
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
      };
      xhr.onload = () => {
        setUploading(false); setProgress(0);
        if (xhr.status >= 200 && xhr.status < 300) {
          onDone(vendor.name + ': SDK staged.', true);
        } else {
          let txt = xhr.responseText;
          try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
          onDone(vendor.name + ': upload failed — ' + txt, false);
        }
        resolve();
      };
      xhr.onerror = () => {
        setUploading(false); setProgress(0);
        onDone(vendor.name + ': network error', false);
        resolve();
      };
      xhr.send(fd);
    });
  };

  const clear = () => {
    if (!confirm('Remove staged ' + vendor.name + ' SDK files?')) return;
    window.ZAMPP_API.fetch('/sdk/' + vendor.id, { method: 'DELETE' })
      .then(() => onDone(vendor.name + ': cleared.', true))
      .catch(e => onDone(vendor.name + ': ' + e.message, false));
  };

  return (

        {vendor.name}
        {deployed
          ? deployed · {status.file_count} files
          : not deployed}
        {vendor.status === 'staging-only' && build pipeline pending}

        {deployed && <button className="btn ghost sm" onClick={clear}>Remove</button>}
        <button className="btn primary sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
          {uploading ? `Uploading ${progress}%` : (deployed ? 'Replace' : 'Upload SDK')}
        </button>
        <input ref={fileRef} type="file" accept=".zip,.tar.gz,.tgz,.tar"
               style={{ display: 'none' }}
               onChange={e => handleFile(e.target.files?.[0])} />

        {vendor.sub}

        expects: {vendor.expect}
        {lastUpload && <>
uploaded: {lastUpload}</>}
        {deployed && <>
on host: rebuild with → {vendor.buildHint}</>}

  );
}

function AmppSettingsCard() {

+// Capture SDK deployment — Blackmagic / AJA / Deltacast

+    sub: 'DeckLink SDK 16.x — required for SDI capture via DeckLink cards',

+    expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',

+    docs: 'https://www.blackmagicdesign.com/developer/product/capture',

+    buildHint: 'docker compose build --no-cache capture',

+    sub: 'NTV2 SDK — for Kona / Io / U-Tap / T-Tap cards',

+    expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',

+    docs: 'https://sdksupport.aja.com/',

+    buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',

+    sub: 'VideoMasterHD SDK — for FLEX / DELTA-h4k2 / etc.',

+    expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',

+    docs: 'https://www.deltacast.tv/products/sdk',

+  const [statuses, setStatuses] = React.useState(null);

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

+  const load = React.useCallback(() => {

+    window.ZAMPP_API.fetch('/sdk').then(setStatuses).catch(() => setStatuses({}));

+  React.useEffect(() => { load(); }, [load]);

+    <SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"

+      tag={{SDK_VENDORS.length} vendors}>

+      <div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 4 }}>

+        Each SDK archive should be a .zip or .tar.gz containing the vendor's Linux SDK contents. After uploading, rebuild the capture container on the host with a DeckLink/AJA/Deltacast card. The SDK files are staged under /sdk/<vendor>/ inside mam-api.

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

+            status={(statuses && statuses[v.id]) || null}

+            onDone={(text, ok = true) => { setMsg({ ok, text }); load(); }}

+function SdkVendorRow({ vendor, status, onDone }) {

+  const [uploading, setUploading] = React.useState(false);

+  const [progress, setProgress] = React.useState(0);

+  const deployed = status && status.file_count > 0;

+  const lastUpload = status?.uploaded_at

+    ? new Date(status.uploaded_at).toLocaleString()

+    setUploading(true); setProgress(0);

+    // Use XHR so we can report progress to the user — fetch's stream API is fiddly.

+      const xhr = new XMLHttpRequest();

+      xhr.open('POST', '/api/v1/sdk/' + vendor.id);

+        if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));

+        setUploading(false); setProgress(0);

+        if (xhr.status >= 200 && xhr.status < 300) {

+          onDone(vendor.name + ': SDK staged.', true);

+          try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}

+          onDone(vendor.name + ': upload failed — ' + txt, false);

+        onDone(vendor.name + ': network error', false);

+    if (!confirm('Remove staged ' + vendor.name + ' SDK files?')) return;

+    window.ZAMPP_API.fetch('/sdk/' + vendor.id, { method: 'DELETE' })

+      .then(() => onDone(vendor.name + ': cleared.', true))

+      .catch(e => onDone(vendor.name + ': ' + e.message, false));

+    <div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: 12, background: 'var(--bg-2)' }}>

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

+          ? deployed · {status.file_count} files

+        {vendor.status === 'staging-only' && build pipeline pending}

+        {deployed && <button className="btn ghost sm" onClick={clear}>Remove</button>}

+        <button className="btn primary sm" onClick={() => fileRef.current?.click()} disabled={uploading}>

+          {uploading ? `Uploading ${progress}%` : (deployed ? 'Replace' : 'Upload SDK')}

+        <input ref={fileRef} type="file" accept=".zip,.tar.gz,.tgz,.tar"

+               style={{ display: 'none' }}

+               onChange={e => handleFile(e.target.files?.[0])} />

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

+        {lastUpload && <>
uploaded: {lastUpload}</>}

+        {deployed && <>
on host: rebuild with → {vendor.buildHint}</>}

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

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


and protected as commentary. If you came here looking for actual API tokens, that page no longer exists — service
            credentials are managed through the cluster's own JWT issuer (see Settings → AMPP Integration).

            and protected as commentary. If you came here looking for actual API tokens, that page no longer exists — service
            credentials are managed through the cluster's own JWT issuer.


and protected as commentary. If you came here looking for actual API tokens, that page no longer exists — service
            credentials are managed through the cluster's own JWT issuer (see Settings → AMPP Integration).

            and protected as commentary. If you came here looking for actual API tokens, that page no longer exists — service
            credentials are managed through the cluster's own JWT issuer.

Disclaimer: No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform

is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical

-            credentials are managed through the cluster's own JWT issuer (see Settings → AMPP Integration).

+            credentials are managed through the cluster's own JWT issuer.


### 3. Backend: SDK upload routes


Write

// Capture SDK deployment — Blackmagic / AJA / Deltacast.
//
// Vendor SDKs are licensed and not redistributable, so they can't ship in
// the repo. This route lets the operator upload an SDK archive through the
// Settings UI; we extract it under /sdk/<vendor>/ (bind-mounted into mam-api)
// so the capture image build can pick the files up.
//
// Today the Dockerfile only wires Blackmagic into FFmpeg via patch_decklink.py;
// AJA and Deltacast files are staged for the next image revision but don't yet
// produce a working FFmpeg build — see the issue tracker.

import express from 'express';
import multer from 'multer';
import { promises as fs, createWriteStream } from 'fs';
import { spawn } from 'child_process';
import path from 'path';
import { requireAuth } from '../middleware/auth.js';

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

const SDK_ROOT = process.env.SDK_ROOT || '/sdk';

const VENDORS = {
  blackmagic: {
    name: 'Blackmagic DeckLink',
    // Header that must be present once the archive is extracted
    sentinel: 'DeckLinkAPI.h',
  },
  aja: {
    name: 'AJA NTV2',
    sentinel: 'ntv2card.h',
  },
  deltacast: {
    name: 'Deltacast VideoMaster',
    sentinel: 'VideoMasterHD_Core.h',
  },
};

const upload = multer({
  storage: multer.memoryStorage(),
  // 512 MB ceiling — Blackmagic's full SDK is ~150 MB, plenty of headroom.
  limits: { fileSize: 512 * 1024 * 1024 },
});

async function statusFor(vendor) {
  const dir = path.join(SDK_ROOT, vendor);
  try {
    const entries = await listFilesRecursive(dir);
    if (entries.length === 0) return { file_count: 0, uploaded_at: null };
    const stat = await fs.stat(dir);
    const sentinel = VENDORS[vendor].sentinel;
    const hasSentinel = entries.some(p => p.endsWith('/' + sentinel) || p === sentinel);
    return {
      file_count: entries.length,
      uploaded_at: stat.mtime.toISOString(),
      sentinel_present: hasSentinel,
    };
  } catch {
    return { file_count: 0, uploaded_at: null };
  }
}

async function listFilesRecursive(dir, base = '') {
  let out = [];
  let entries;
  try { entries = await fs.readdir(dir, { withFileTypes: true }); }
  catch { return out; }
  for (const e of entries) {
    const full = path.join(dir, e.name);
    const rel  = base ? `${base}/${e.name}` : e.name;
    if (e.isDirectory()) {
      out = out.concat(await listFilesRecursive(full, rel));
    } else if (e.isFile()) {
      out.push(rel);
    }
  }
  return out;
}

router.get('/', async (req, res, next) => {
  try {
    const out = {};
    for (const vendor of Object.keys(VENDORS)) {
      out[vendor] = await statusFor(vendor);
    }
    res.json(out);
  } catch (err) { next(err); }
});

router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
  try {
    const vendor = req.params.vendor;
    if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
    if (!req.file)        return res.status(400).json({ error: 'No archive uploaded (field "archive")' });

    const dir = path.join(SDK_ROOT, vendor);
    // Wipe any previous staging so partial uploads don't leave stale headers.
    await fs.rm(dir, { recursive: true, force: true });
    await fs.mkdir(dir, { recursive: true });

    const originalName = req.file.originalname || 'sdk.bin';
    const archivePath = path.join(dir, originalName);
    await fs.writeFile(archivePath, req.file.buffer);

    // Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz;
    // unzip handles .zip. The capture container will be built separately on
    // the host with a DeckLink/AJA/Deltacast card; this route just stages.
    const lower = originalName.toLowerCase();
    let cmd, args;
    if (lower.endsWith('.zip')) {
      cmd = 'unzip'; args = ['-q', '-o', archivePath, '-d', dir];
    } else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
      cmd = 'tar'; args = ['-xf', archivePath, '-C', dir];
    } else {
      return res.status(400).json({ error: 'Unsupported archive format — use .zip, .tar.gz, .tgz, or .tar' });
    }

    await new Promise((resolve, reject) => {
      const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
      let stderr = '';
      child.stderr.on('data', d => { stderr += d.toString(); });
      child.on('error', reject);
      child.on('exit', code => {
        if (code === 0) resolve();
        else reject(new Error(`${cmd} exited ${code}: ${stderr.slice(0, 500)}`));
      });
    });

    // Best-effort: remove the archive after a successful extract so we only
    // keep the unpacked headers/.so files on disk.
    await fs.unlink(archivePath).catch(() => {});

    const status = await statusFor(vendor);
    res.json({ message: VENDORS[vendor].name + ' SDK staged.', status });
  } catch (err) {
    console.error('[sdk] upload failed:', err);
    res.status(500).json({ error: err.message });
  }
});

router.delete('/:vendor', async (req, res, next) => {
  try {
    const vendor = req.params.vendor;
    if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
    const dir = path.join(SDK_ROOT, vendor);
    await fs.rm(dir, { recursive: true, force: true });
    res.json({ message: VENDORS[vendor].name + ' SDK cleared.' });
  } catch (err) { next(err); }
});

export default router;


File created successfully at: /tmp/wd/wild-dragon/services/mam-api/src/routes/sdk.js (file state is current in your context — no need to Read it back)

// Capture SDK deployment — Blackmagic / AJA / Deltacast.
//
// Vendor SDKs are licensed and not redistributable, so they can't ship in
// the repo. This route lets the operator upload an SDK archive through the
// Settings UI; we extract it under /sdk/<vendor>/ (bind-mounted into mam-api)
// so the capture image build can pick the files up.
//
// Today the Dockerfile only wires Blackmagic into FFmpeg via patch_decklink.py;
// AJA and Deltacast files are staged for the next image revision but don't yet
// produce a working FFmpeg build — see the issue tracker.

import express from 'express';
import multer from 'multer';
import { promises as fs, createWriteStream } from 'fs';
import { spawn } from 'child_process';
import path from 'path';
import { requireAuth } from '../middleware/auth.js';

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

const SDK_ROOT = process.env.SDK_ROOT || '/sdk';

const VENDORS = {
  blackmagic: {
    name: 'Blackmagic DeckLink',
    // Header that must be present once the archive is extracted
    sentinel: 'DeckLinkAPI.h',
  },
  aja: {
    name: 'AJA NTV2',
    sentinel: 'ntv2card.h',
  },
  deltacast: {
    name: 'Deltacast VideoMaster',
    sentinel: 'VideoMasterHD_Core.h',
  },
};

const upload = multer({
  storage: multer.memoryStorage(),
  // 512 MB ceiling — Blackmagic's full SDK is ~150 MB, plenty of headroom.
  limits: { fileSize: 512 * 1024 * 1024 },
});

async function statusFor(vendor) {
  const dir = path.join(SDK_ROOT, vendor);
  try {
    const entries = await listFilesRecursive(dir);
    if (entries.length === 0) return { file_count: 0, uploaded_at: null };
    const stat = await fs.stat(dir);
    const sentinel = VENDORS[vendor].sentinel;
    const hasSentinel = entries.some(p => p.endsWith('/' + sentinel) || p === sentinel);
    return {
      file_count: entries.length,
      uploaded_at: stat.mtime.toISOString(),
      sentinel_present: hasSentinel,
    };
  } catch {
    return { file_count: 0, uploaded_at: null };
  }
}

async function listFilesRecursive(dir, base = '') {
  let out = [];
  let entries;
  try { entries = await fs.readdir(dir, { withFileTypes: true }); }
  catch { return out; }
  for (const e of entries) {
    const full = path.join(dir, e.name);
    const rel  = base ? `${base}/${e.name}` : e.name;
    if (e.isDirectory()) {
      out = out.concat(await listFilesRecursive(full, rel));
    } else if (e.isFile()) {
      out.push(rel);
    }
  }
  return out;
}

router.get('/', async (req, res, next) => {
  try {
    const out = {};
    for (const vendor of Object.keys(VENDORS)) {
      out[vendor] = await statusFor(vendor);
    }
    res.json(out);
  } catch (err) { next(err); }
});

router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
  try {
    const vendor = req.params.vendor;
    if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
    if (!req.file)        return res.status(400).json({ error: 'No archive uploaded (field "archive")' });

    const dir = path.join(SDK_ROOT, vendor);
    // Wipe any previous staging so partial uploads don't leave stale headers.
    await fs.rm(dir, { recursive: true, force: true });
    await fs.mkdir(dir, { recursive: true });

    const originalName = req.file.originalname || 'sdk.bin';
    const archivePath = path.join(dir, originalName);
    await fs.writeFile(archivePath, req.file.buffer);

    // Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz;
    // unzip handles .zip. The capture container will be built separately on
    // the host with a DeckLink/AJA/Deltacast card; this route just stages.
    const lower = originalName.toLowerCase();
    let cmd, args;
    if (lower.endsWith('.zip')) {
      cmd = 'unzip'; args = ['-q', '-o', archivePath, '-d', dir];
    } else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
      cmd = 'tar'; args = ['-xf', archivePath, '-C', dir];
    } else {
      return res.status(400).json({ error: 'Unsupported archive format — use .zip, .tar.gz, .tgz, or .tar' });
    }

    await new Promise((resolve, reject) => {
      const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
      let stderr = '';
      child.stderr.on('data', d => { stderr += d.toString(); });
      child.on('error', reject);
      child.on('exit', code => {
        if (code === 0) resolve();
        else reject(new Error(`${cmd} exited ${code}: ${stderr.slice(0, 500)}`));
      });
    });

    // Best-effort: remove the archive after a successful extract so we only
    // keep the unpacked headers/.so files on disk.
    await fs.unlink(archivePath).catch(() => {});

    const status = await statusFor(vendor);
    res.json({ message: VENDORS[vendor].name + ' SDK staged.', status });
  } catch (err) {
    console.error('[sdk] upload failed:', err);
    res.status(500).json({ error: err.message });
  }
});

router.delete('/:vendor', async (req, res, next) => {
  try {
    const vendor = req.params.vendor;
    if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
    const dir = path.join(SDK_ROOT, vendor);
    await fs.rm(dir, { recursive: true, force: true });
    res.json({ message: VENDORS[vendor].name + ' SDK cleared.' });
  } catch (err) { next(err); }
});

export default router;


### 4. Mount the SDK route + bind-mount


28	import clusterRouter from './routes/cluster.js';
29
30	const app  = express();
31	const PORT = process.env.PORT || 3000;
32
33	// ── Middleware ────────────────────────────────────────────────────────────────
34	app.use(cors({ origin: true, credentials: true }));
35	app.use(express.json({ limit: '50mb' }));
36
37	const PgSession = ConnectPgSimple(session);

import clusterRouter from './routes/cluster.js';

const app  = express();
const PORT = process.env.PORT || 3000;

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '50mb' }));

const PgSession = ConnectPgSimple(session);


Investigate S3 settings load path - why blank?

Wire S3 settings env-var fallback

Add SDK settings card in UI

Commit, push, redeploy, smoke test


Edit

import clusterRouter from './routes/cluster.js';

import clusterRouter from './routes/cluster.js';
import sdkRouter     from './routes/sdk.js';


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

import clusterRouter from './routes/cluster.js';
import sdkRouter     from './routes/sdk.js';

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import ConnectPgSimple from 'connect-pg-simple';
import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { loadS3ConfigFromDb } from './s3/client.js';

// Routes
import authRouter from './routes/auth.js';
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js';
import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js';
import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js';
import usersRouter    from './routes/users.js';
import groupsRouter   from './routes/groups.js';
import tokensRouter   from './routes/tokens.js';
import sequencesRouter from './routes/sequences.js';
import systemRouter  from './routes/system.js';
import clusterRouter from './routes/cluster.js';

const app  = express();
const PORT = process.env.PORT || 3000;

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '50mb' }));

const PgSession = ConnectPgSimple(session);

app.use(
  session({
    store: new PgSession({
      pool,
      tableName: 'sessions',
      pruneSessionInterval: 3600,
    }),
    secret:            process.env.SESSION_SECRET || 'change-me-in-production',
    resave:            false,
    saveUninitialized: false,
    cookie: {
      secure:   process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge:   1000 * 60 * 60 * 24,
    },
  })
);

// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth',      authRouter);
app.use('/api/v1/assets',    assetsRouter);
app.use('/api/v1/projects',  projectsRouter);
app.use('/api/v1/bins',      binsRouter);
app.use('/api/v1/jobs',      jobsRouter);
app.use('/api/v1/capture',   captureRouter);
app.use('/api/v1/upload',    uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings',  settingsRouter);
app.use('/api/v1/ampp',      amppRouter);
app.use('/api/v1/users',     usersRouter);
app.use('/api/v1/groups',    groupsRouter);
app.use('/api/v1/tokens',    tokensRouter);
app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system',    systemRouter);
app.use('/api/v1/cluster',   clusterRouter);

// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);

// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
  const dir = join(__dirnameMig, 'db', 'migrations');
  let files = [];
  try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
  for (const f of files) {
    const sql = readFileSync(join(dir, f), 'utf8');
    try {
      await pool.query(sql);
      console.log('[migration] applied ' + f);
    } catch (err) {
      console.error('[migration] failed ' + f, err.message);
    }
  }
}
await runMigrations();

// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb();

// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() {
  // Prefer an explicit override — useful when running inside Docker where
  // os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
  if (process.env.NODE_IP) return process.env.NODE_IP;

  const ifaces = os.networkInterfaces();
  for (const name of Object.keys(ifaces)) {
    for (const iface of (ifaces[name] || [])) {
      if (iface.family === 'IPv4' && !iface.internal) return iface.address;
    }
  }
  return '127.0.0.1';
}

// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() {
  return new Promise(resolve => {
    exec(
      'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
      { timeout: 5000 },
      (err, stdout) => {
        if (err || !stdout.trim()) return resolve([]);
        const gpus = stdout.trim().split('\n').map(line => {
          const parts = line.split(',').map(s => s.trim());
          return {
            index:     parseInt(parts[0], 10),
            name:      parts[1] || 'Unknown GPU',
            memory_mb: parseInt(parts[2], 10) || 0,
          };
        }).filter(g => !isNaN(g.index));
        resolve(gpus);
      }
    );
  });
}

async function selfHeartbeat() {
  const load  = os.loadavg()[0];
  const total = os.totalmem();
  const used  = total - os.freemem();
  const gpus  = await detectGpus();

  const capabilities = { gpus, blackmagic: [] };

  pool.query(
    `INSERT INTO cluster_nodes
       (hostname, ip_address, role, version, api_url,
        cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
     VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
     ON CONFLICT (hostname) DO UPDATE SET
       ip_address   = EXCLUDED.ip_address,
       cpu_usage    = EXCLUDED.cpu_usage,
       mem_used_mb  = EXCLUDED.mem_used_mb,
       mem_total_mb = EXCLUDED.mem_total_mb,
       capabilities = EXCLUDED.capabilities,
       last_seen    = NOW()`,
    [
      process.env.NODE_HOSTNAME || os.hostname(),
      getLocalIp(),
      process.env.npm_package_version || null,
      `http://${getLocalIp()}:${PORT}`,
      parseFloat(load.toFixed(2)),
      Math.round(used  / 1024 / 1024),
      Math.round(total / 1024 / 1024),
      JSON.stringify(capabilities),
    ]
  ).catch(err => console.error('[cluster] heartbeat failed:', err.message));
}

setInterval(selfHeartbeat, 30_000);
selfHeartbeat();

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});

+import sdkRouter     from './routes/sdk.js';


app.use('/api/v1/cluster',   clusterRouter);

app.use('/api/v1/cluster',   clusterRouter);
app.use('/api/v1/sdk',       sdkRouter);


app.use('/api/v1/cluster',   clusterRouter);
app.use('/api/v1/sdk',       sdkRouter);

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import ConnectPgSimple from 'connect-pg-simple';
import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { loadS3ConfigFromDb } from './s3/client.js';

// Routes
import authRouter from './routes/auth.js';
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js';
import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js';
import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js';
import usersRouter    from './routes/users.js';
import groupsRouter   from './routes/groups.js';
import tokensRouter   from './routes/tokens.js';
import sequencesRouter from './routes/sequences.js';
import systemRouter  from './routes/system.js';
import clusterRouter from './routes/cluster.js';
import sdkRouter     from './routes/sdk.js';

const app  = express();
const PORT = process.env.PORT || 3000;

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '50mb' }));

const PgSession = ConnectPgSimple(session);

app.use(
  session({
    store: new PgSession({
      pool,
      tableName: 'sessions',
      pruneSessionInterval: 3600,
    }),
    secret:            process.env.SESSION_SECRET || 'change-me-in-production',
    resave:            false,
    saveUninitialized: false,
    cookie: {
      secure:   process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge:   1000 * 60 * 60 * 24,
    },
  })
);

// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth',      authRouter);
app.use('/api/v1/assets',    assetsRouter);
app.use('/api/v1/projects',  projectsRouter);
app.use('/api/v1/bins',      binsRouter);
app.use('/api/v1/jobs',      jobsRouter);
app.use('/api/v1/capture',   captureRouter);
app.use('/api/v1/upload',    uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings',  settingsRouter);
app.use('/api/v1/ampp',      amppRouter);
app.use('/api/v1/users',     usersRouter);
app.use('/api/v1/groups',    groupsRouter);
app.use('/api/v1/tokens',    tokensRouter);
app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system',    systemRouter);
app.use('/api/v1/cluster',   clusterRouter);

// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);

// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
  const dir = join(__dirnameMig, 'db', 'migrations');
  let files = [];
  try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
  for (const f of files) {
    const sql = readFileSync(join(dir, f), 'utf8');
    try {
      await pool.query(sql);
      console.log('[migration] applied ' + f);
    } catch (err) {
      console.error('[migration] failed ' + f, err.message);
    }
  }
}
await runMigrations();

// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb();

// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() {
  // Prefer an explicit override — useful when running inside Docker where
  // os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
  if (process.env.NODE_IP) return process.env.NODE_IP;

  const ifaces = os.networkInterfaces();
  for (const name of Object.keys(ifaces)) {
    for (const iface of (ifaces[name] || [])) {
      if (iface.family === 'IPv4' && !iface.internal) return iface.address;
    }
  }
  return '127.0.0.1';
}

// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() {
  return new Promise(resolve => {
    exec(
      'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
      { timeout: 5000 },
      (err, stdout) => {
        if (err || !stdout.trim()) return resolve([]);
        const gpus = stdout.trim().split('\n').map(line => {
          const parts = line.split(',').map(s => s.trim());
          return {
            index:     parseInt(parts[0], 10),
            name:      parts[1] || 'Unknown GPU',
            memory_mb: parseInt(parts[2], 10) || 0,
          };
        }).filter(g => !isNaN(g.index));
        resolve(gpus);
      }
    );
  });
}

async function selfHeartbeat() {
  const load  = os.loadavg()[0];
  const total = os.totalmem();
  const used  = total - os.freemem();
  const gpus  = await detectGpus();

  const capabilities = { gpus, blackmagic: [] };

  pool.query(
    `INSERT INTO cluster_nodes
       (hostname, ip_address, role, version, api_url,
        cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
     VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
     ON CONFLICT (hostname) DO UPDATE SET
       ip_address   = EXCLUDED.ip_address,
       cpu_usage    = EXCLUDED.cpu_usage,
       mem_used_mb  = EXCLUDED.mem_used_mb,
       mem_total_mb = EXCLUDED.mem_total_mb,
       capabilities = EXCLUDED.capabilities,
       last_seen    = NOW()`,
    [
      process.env.NODE_HOSTNAME || os.hostname(),
      getLocalIp(),
      process.env.npm_package_version || null,
      `http://${getLocalIp()}:${PORT}`,
      parseFloat(load.toFixed(2)),
      Math.round(used  / 1024 / 1024),
      Math.round(total / 1024 / 1024),
      JSON.stringify(capabilities),
    ]
  ).catch(err => console.error('[cluster] heartbeat failed:', err.message));
}

setInterval(selfHeartbeat, 30_000);
selfHeartbeat();

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});

+app.use('/api/v1/sdk',       sdkRouter);


      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /mnt/NVME/MAM/wild-dragon-growing:/growing
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd

      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /mnt/NVME/MAM/wild-dragon-growing:/growing
      - /mnt/NVME/MAM/sdk:/sdk
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


1	services:
2	  db:
3	    image: postgres:16
4	    environment:
5	      POSTGRES_DB: ${POSTGRES_DB}
6	      POSTGRES_USER: ${POSTGRES_USER}
7	      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
8	    ports:
9	      - "${PORT_DB:-5432}:5432"
10	    volumes:
11	      - postgres_data:/var/lib/postgresql/data
12	      - ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
13	    healthcheck:
14	      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
15	      interval: 5s
16	      timeout: 5s
17	      retries: 5
18	    networks:
19	      - wild-dragon
20
21	  queue:
22	    image: redis:7-alpine
23	    ports:
24	      - "${PORT_REDIS:-6379}:6379"
25	    volumes:
26	      - redis_data:/data
27	    networks:
28	      - wild-dragon
29
30	  mam-api:
31	    build: ./services/mam-api
32	    depends_on:
33	      db:
34	        condition: service_healthy
35	      queue:
36	        condition: service_started
37	    ports:
38	      - "${PORT_MAM_API:-7432}:3000"
39	    volumes:
40	      - /var/run/docker.sock:/var/run/docker.sock
41	      - /mnt/NVME/MAM/wild-dragon-live:/live
42	      - /mnt/NVME/MAM/wild-dragon-growing:/growing
43	      - /dev/shm:/dev/shm
44	      - /run/dbus:/run/dbus
45	      - /run/systemd:/run/systemd
46	      - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
47	    environment:
48	      DATABASE_URL: ${DATABASE_URL}
49	      REDIS_URL: ${REDIS_URL}
50	      S3_ENDPOINT: ${S3_ENDPOINT}
51	      S3_BUCKET: ${S3_BUCKET}
52	      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
53	      S3_SECRET_KEY: ${S3_SECRET_KEY}
54	      S3_REGION: ${S3_REGION:-us-east-1}
55	      SESSION_SECRET: ${SESSION_SECRET}
56	      AUTH_ENABLED: ${AUTH_ENABLED:-false}
57	      DOCKER_NETWORK: wild-dragon_wild-dragon
58	      NODE_IP: ${NODE_IP}
59	      NODE_HOSTNAME: ${NODE_HOSTNAME:-}
60	    deploy:
61	      resources:
62	        reservations:
63	          devices:
64	            - driver: nvidia
65	              count: all
66	              capabilities: [gpu]
67	    networks:
68	      - wild-dragon
69
70	  capture:
71	    build: ./services/capture
72	    depends_on:
73	      - mam-api
74	    ports:
75	      - "${PORT_CAPTURE:-7433}:3001"
76	      - "${PORT_RTMP:-1935}:1935"         # RTMP ingest (listener mode)
77	      - "${PORT_SRT:-9000}:9000/udp"      # SRT ingest (listener mode)
78	    privileged: true
79	    environment:
80	      S3_ENDPOINT: ${S3_ENDPOINT}
81	      S3_BUCKET: ${S3_BUCKET}
82	      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
83	      S3_SECRET_KEY: ${S3_SECRET_KEY}
84	      S3_REGION: ${S3_REGION:-us-east-1}
85	      MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
86	    volumes:
87	      - /mnt/NVME/MAM/wild-dragon-live:/live
88	      - /dev/shm:/dev/shm
89	      - /run/dbus:/run/dbus
90	      - /run/systemd:/run/systemd
91	    networks:
92	      - wild-dragon
93
94	  worker:
95	    build: ./services/worker
96	    depends_on:
97	      - queue
98	      - db
99	    environment:
100	      REDIS_URL: ${REDIS_URL}
101	      DATABASE_URL: ${DATABASE_URL}
102	      S3_ENDPOINT: ${S3_ENDPOINT}
103	      S3_BUCKET: ${S3_BUCKET}
104	      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
105	      S3_SECRET_KEY: ${S3_SECRET_KEY}
106	      S3_REGION: ${S3_REGION:-us-east-1}
107	      GROWING_PATH: /growing
108	    volumes:
109	      - /mnt/NVME/MAM/wild-dragon-growing:/growing
110	    networks:
111	      - wild-dragon
112
113	  web-ui:
114	    build: ./services/web-ui
115	    ports:
116	      - "${PORT_WEB_UI:-7434}:80"
117	    volumes:
118	      - /mnt/NVME/MAM/wild-dragon-live:/live
119	      - /dev/shm:/dev/shm
120	      - /run/dbus:/run/dbus
121	      - /run/systemd:/run/systemd
122	    networks:
123	      - wild-dragon
124
125	  editor:
126	    build: ./services/editor
127	    depends_on:
128	      - mam-api
129	    ports:
130	      - "${PORT_EDITOR:-7435}:80"
131	    networks:
132	      - wild-dragon
133
134	volumes:
135	  postgres_data:
136	  redis_data:
137
138	networks:
139	  wild-dragon:
140	    driver: bridge
141

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "${PORT_DB:-5432}:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - wild-dragon

  queue:
    image: redis:7-alpine
    ports:
      - "${PORT_REDIS:-6379}:6379"
    volumes:
      - redis_data:/data
    networks:
      - wild-dragon

  mam-api:
    build: ./services/mam-api
    depends_on:
      db:
        condition: service_healthy
      queue:
        condition: service_started
    ports:
      - "${PORT_MAM_API:-7432}:3000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /mnt/NVME/MAM/wild-dragon-growing:/growing
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
      - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
    environment:
      DATABASE_URL: ${DATABASE_URL}
      REDIS_URL: ${REDIS_URL}
      S3_ENDPOINT: ${S3_ENDPOINT}
      S3_BUCKET: ${S3_BUCKET}
      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
      S3_SECRET_KEY: ${S3_SECRET_KEY}
      S3_REGION: ${S3_REGION:-us-east-1}
      SESSION_SECRET: ${SESSION_SECRET}
      AUTH_ENABLED: ${AUTH_ENABLED:-false}
      DOCKER_NETWORK: wild-dragon_wild-dragon
      NODE_IP: ${NODE_IP}
      NODE_HOSTNAME: ${NODE_HOSTNAME:-}
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    networks:
      - wild-dragon

  capture:
    build: ./services/capture
    depends_on:
      - mam-api
    ports:
      - "${PORT_CAPTURE:-7433}:3001"
      - "${PORT_RTMP:-1935}:1935"         # RTMP ingest (listener mode)
      - "${PORT_SRT:-9000}:9000/udp"      # SRT ingest (listener mode)
    privileged: true
    environment:
      S3_ENDPOINT: ${S3_ENDPOINT}
      S3_BUCKET: ${S3_BUCKET}
      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
      S3_SECRET_KEY: ${S3_SECRET_KEY}
      S3_REGION: ${S3_REGION:-us-east-1}
      MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
    volumes:
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
    networks:
      - wild-dragon

  worker:
    build: ./services/worker
    depends_on:
      - queue
      - db
    environment:
      REDIS_URL: ${REDIS_URL}
      DATABASE_URL: ${DATABASE_URL}
      S3_ENDPOINT: ${S3_ENDPOINT}
      S3_BUCKET: ${S3_BUCKET}
      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
      S3_SECRET_KEY: ${S3_SECRET_KEY}
      S3_REGION: ${S3_REGION:-us-east-1}
      GROWING_PATH: /growing
    volumes:
      - /mnt/NVME/MAM/wild-dragon-growing:/growing
    networks:
      - wild-dragon

  web-ui:
    build: ./services/web-ui
    ports:
      - "${PORT_WEB_UI:-7434}:80"
    volumes:
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
    networks:
      - wild-dragon

  editor:
    build: ./services/editor
    depends_on:
      - mam-api
    ports:
      - "${PORT_EDITOR:-7435}:80"
    networks:
      - wild-dragon

volumes:
  postgres_data:
  redis_data:

networks:
  wild-dragon:
    driver: bridge


      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /mnt/NVME/MAM/wild-dragon-growing:/growing
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
      - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro

      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /mnt/NVME/MAM/wild-dragon-growing:/growing
      - /mnt/NVME/MAM/sdk:/sdk
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
      - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro