diff --git a/docker-compose.yml b/docker-compose.yml index da15302..a1ed1f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: - /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 diff --git a/services/mam-api/Dockerfile b/services/mam-api/Dockerfile index f13f774..558a29d 100644 --- a/services/mam-api/Dockerfile +++ b/services/mam-api/Dockerfile @@ -1,4 +1,8 @@ FROM node:22-slim +# unzip/tar needed for SDK upload extraction (see routes/sdk.js) +RUN apt-get update \ + && apt-get install -y --no-install-recommends unzip tar ca-certificates \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package*.json ./ RUN npm install --omit=dev diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index d8c6701..3f9c144 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -26,6 +26,7 @@ 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; @@ -74,6 +75,7 @@ app.use('/api/v1/tokens', tokensRouter); app.use('/api/v1/sequences', sequencesRouter); app.use('/api/v1/system', systemRouter); app.use('/api/v1/cluster', clusterRouter); +app.use('/api/v1/sdk', sdkRouter); // ── Error handler ───────────────────────────────────────────────────────────── app.use(errorHandler); diff --git a/services/mam-api/src/routes/sdk.js b/services/mam-api/src/routes/sdk.js new file mode 100644 index 0000000..c80a14d --- /dev/null +++ b/services/mam-api/src/routes/sdk.js @@ -0,0 +1,152 @@ +// 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// (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; diff --git a/services/mam-api/src/routes/settings.js b/services/mam-api/src/routes/settings.js index c9721cf..b37863a 100644 --- a/services/mam-api/src/routes/settings.js +++ b/services/mam-api/src/routes/settings.js @@ -15,18 +15,28 @@ router.get('/s3', async (req, res, next) => { `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: '', - s3_bucket: getS3Bucket(), - s3_access_key: '', - s3_secret_key_exists: false, - s3_region: 'us-east-1', + 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') { - out.s3_secret_key_exists = !!value; - } else { - out[key] = value || ''; + if (value) { out.s3_secret_key_exists = true; out.source = 'db'; } + } else if (value) { + out[key] = value; + out.source = 'db'; } } res.json(out); diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 8d7e9c8..2127b3b 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -314,7 +314,7 @@ function Tokens() { 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 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). + credentials are managed through the cluster's own JWT issuer. @@ -753,10 +753,10 @@ function Settings() { const SECTIONS = [ { id: 'storage', label: 'S3 / Object storage', icon: 'hdd' }, - { id: 'gpu', label: 'GPU / Transcoding', icon: 'gpu' }, + { 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' }, - { id: 'ampp', label: 'AMPP integration', icon: 'link' }, ]; return ( @@ -779,10 +779,10 @@ function Settings() {
{section === 'storage' && } - {section === 'gpu' && } + {section === 'proxy' && } {section === 'growing' && } + {section === 'sdk' && } {section === 'sdi' && } - {section === 'ampp' && }
@@ -856,51 +856,78 @@ function GpuSettingsCard() { 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.' }); }) + .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
Loading…
; + if (!cfg) return
Loading…
; const set = (k, v) => setCfg(c => ({ ...c, [k]: v })); - const enabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true; + const gpuEnabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true; return ( - enabled : disabled}> - + 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. +
+ + +
- + - set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}> + {gpuEnabled + ? ['p1','p2','p3','p4','p5','p6','p7'].map(p => ) + : ['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower'].map(p => )}
+
- - set('gpu_bitrate_mbps', e.target.value)} placeholder="8" /> + + set('gpu_bitrate_mbps', e.target.value)} placeholder="10" />
+ +
+ + + + + set('gpu_audio_bitrate_kbps', e.target.value)} placeholder="192" /> + +
+
@@ -975,6 +1002,148 @@ function SdiSettingsCard() { ); } +// ──────────────────────────────────────────────────────────────────────────── +// 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 ( + {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. +
+ +
+ {SDK_VENDORS.map(v => ( + { setMsg({ ok, text }); load(); }} + /> + ))} +
+
+ ); +} + +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 && } + + handleFile(e.target.files?.[0])} /> +
+
+ {vendor.sub}
+ expects: {vendor.expect} + {lastUpload && <>
uploaded: {lastUpload}} + {deployed && <>
on host: rebuild with → {vendor.buildHint}} +
+
+ ); +} + function AmppSettingsCard() { const [cfg, setCfg] = React.useState(null); const [saving, setSaving] = React.useState(false); diff --git a/services/worker/src/ffmpeg/executor.js b/services/worker/src/ffmpeg/executor.js index d1bd3f3..5ffab48 100644 --- a/services/worker/src/ffmpeg/executor.js +++ b/services/worker/src/ffmpeg/executor.js @@ -86,19 +86,23 @@ export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => { await runFFmpeg(args); }; +const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']); +const VAAPI_CODECS = new Set(['h264_vaapi', 'hevc_vaapi']); +const HW_CODECS = new Set([...NVENC_CODECS, ...VAAPI_CODECS]); + export const transcodeVideo = async (inputPath, outputPath, options = {}) => { const { - videoCodec = 'libx264', - videoPreset = 'fast', + videoCodec = 'libx264', + videoPreset = 'fast', videoBitrate = '10M', - audioCodec = 'aac', + rateControl = null, // 'cbr' | 'vbr' | 'cqp' — optional + audioCodec = 'aac', audioBitrate = '192k', - hasAudio = true, + 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. + // libx264 / yuv420p require even dimensions. Captured frames from SDI + // or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125). const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p"; // analyzeduration/probesize must be set BEFORE -i. Some ProRes captures @@ -114,6 +118,16 @@ export const transcodeVideo = async (inputPath, outputPath, options = {}) => { '-b:v', videoBitrate, ]; + // NVENC takes rate control via -rc / -cq. VAAPI uses -rc_mode. libx264 + // ignores both (rate is implied by -b:v + -maxrate). + if (rateControl) { + if (NVENC_CODECS.has(videoCodec)) { + args.push('-rc', rateControl); + } else if (VAAPI_CODECS.has(videoCodec)) { + args.push('-rc_mode', rateControl.toUpperCase()); + } + } + if (hasAudio) { args.push('-c:a', audioCodec, '-b:a', audioBitrate); } else { @@ -125,6 +139,8 @@ export const transcodeVideo = async (inputPath, outputPath, options = {}) => { await runFFmpeg(args); }; +export const isHwCodec = (codec) => HW_CODECS.has(codec); + // 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) => { diff --git a/services/worker/src/workers/proxy.js b/services/worker/src/workers/proxy.js index 99b70e0..784466f 100644 --- a/services/worker/src/workers/proxy.js +++ b/services/worker/src/workers/proxy.js @@ -4,7 +4,38 @@ import { tmpdir } from 'os'; import { Queue } from 'bullmq'; import { query } from '../db/client.js'; import { downloadFromS3, uploadToS3 } from '../s3/client.js'; -import { transcodeVideo, transcodeImage, getMediaInfo } from '../ffmpeg/executor.js'; +import { transcodeVideo, transcodeImage, getMediaInfo, isHwCodec } from '../ffmpeg/executor.js'; + +// Read the global proxy-encoder settings from the DB. These are written by +// Settings → Proxy encoding in the GUI. Falls back to libx264 defaults if +// nothing is configured (first-run / fresh install). +async function loadProxyEncodingSettings() { + const result = await query( + `SELECT key, value FROM settings + WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps', + 'gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')` + ); + const map = {}; + for (const { key, value } of result.rows) map[key] = value; + + const gpuEnabled = map.gpu_transcode_enabled === 'true'; + const codec = map.gpu_codec || (gpuEnabled ? 'h264_nvenc' : 'libx264'); + const preset = map.gpu_preset || (gpuEnabled ? 'p4' : 'fast'); + const bitrateM = parseInt(map.gpu_bitrate_mbps || '10', 10); + const rcMode = map.gpu_rc_mode || null; + const audioCodec = map.gpu_audio_codec || 'aac'; + const audioKbps = parseInt(map.gpu_audio_bitrate_kbps || '192', 10); + + return { + videoCodec: codec, + videoPreset: preset, + videoBitrate: `${bitrateM}M`, + rateControl: rcMode, + audioCodec, + audioBitrate: `${audioKbps}k`, + _gpu: gpuEnabled && isHwCodec(codec), + }; +} // Codec names ffprobe reports for still-image inputs. These bypass the video // transcode entirely — see proxyWorker below. @@ -87,17 +118,30 @@ export const proxyWorker = async (job) => { ); } - // Transcode to H.264 proxy + // Transcode using the operator-configured encoder. The proxy worker is + // the only consumer of these settings; recorders manage their own codecs. await job.updateProgress(30); - console.log(`[proxy] Transcoding asset ${assetId}`); - await transcodeVideo(inputPath, outputPath, { - videoCodec: 'libx264', - videoPreset: 'fast', - videoBitrate: '10M', - audioCodec: 'aac', - audioBitrate: '192k', - hasAudio, - }); + const encSettings = await loadProxyEncodingSettings(); + console.log( + `[proxy] Transcoding asset ${assetId} via ${encSettings._gpu ? 'GPU' : 'CPU'} ` + + `(${encSettings.videoCodec} ${encSettings.videoPreset} ${encSettings.videoBitrate})` + ); + try { + await transcodeVideo(inputPath, outputPath, { ...encSettings, hasAudio }); + } catch (err) { + if (encSettings._gpu) { + // Hardware encoder failed — typically "no NVIDIA driver" or "VAAPI + // device not found". Fall back to libx264 so the job doesn't fail + // when the worker host has no GPU. + console.warn(`[proxy] GPU encode failed (${err.message}); falling back to libx264`); + await transcodeVideo(inputPath, outputPath, { + videoCodec: 'libx264', videoPreset: 'fast', + videoBitrate: encSettings.videoBitrate, + audioCodec: encSettings.audioCodec, audioBitrate: encSettings.audioBitrate, + hasAudio, + }); + } else { throw err; } + } // Upload proxy to S3 await job.updateProgress(70);