feat: SDK deployment UI, proxy encoding global settings, S3 env fallback

- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
  with explicit 'applied to every ingested file' wording, expose
  CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
  SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
  BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
  staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
  Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
  builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
  hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
  UI reflects what's actually wired (fixes 'not configured' false alarm)
This commit is contained in:
claude 2026-05-23 02:58:32 +00:00
parent dc0bd51648
commit 6398879b56
8 changed files with 449 additions and 51 deletions

View file

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

View file

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

View file

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

View file

@ -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/<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;

View file

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

View file

@ -314,7 +314,7 @@ function Tokens() {
<strong>Disclaimer:</strong> 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 <span style={{ color: "var(--accent-text)" }}>Settings AMPP Integration</span>).
credentials are managed through the cluster's own JWT issuer.
</div>
</div>
</div>
@ -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() {
</nav>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{section === 'storage' && <S3SettingsCard />}
{section === 'gpu' && <GpuSettingsCard />}
{section === 'proxy' && <GpuSettingsCard />}
{section === 'growing' && <GrowingSettingsCard />}
{section === 'sdk' && <SdkSettingsCard />}
{section === 'sdi' && <SdiSettingsCard />}
{section === 'ampp' && <AmppSettingsCard />}
</div>
</div>
</div>
@ -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 <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration"><div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div></SettingsCard>;
if (!cfg) return <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"><div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div></SettingsCard>;
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 (
<SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration for proxy generation"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<SField label="Enable hardware encoding">
<SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"
tag={gpuEnabled ? <span className="badge success">GPU mode</span> : <span className="badge neutral">CPU mode</span>}>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 4 }}>
These settings drive the proxy worker for <strong style={{ color: 'var(--text-2)' }}>every</strong> ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.
</div>
<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))} />
<span style={{ color: 'var(--text-2)' }}>Route proxy jobs through GPU encoders when available</span>
<input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available falls back to CPU on missing hardware</span>
</label>
</SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Codec">
<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' }}>
<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>
{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 || '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 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>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<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" />
</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>
<option value="cbr">CBR constant bitrate</option>
<option value="vbr">VBR variable bitrate</option>
<option value="cqp">CQP / CRF constant quality</option>
</select>
</SField>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<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>
</div>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
@ -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 (
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
tag={<span className="badge neutral">{SDK_VENDORS.length} vendors</span>}>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 4 }}>
Each SDK archive should be a <strong style={{ color: 'var(--text-2)' }}>.zip</strong> or <strong style={{ color: 'var(--text-2)' }}>.tar.gz</strong> 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 <code className="mono" style={{ fontSize: 11.5 }}>/sdk/&lt;vendor&gt;/</code> inside mam-api.
</div>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{SDK_VENDORS.map(v => (
<SdkVendorRow
key={v.id}
vendor={v}
status={(statuses && statuses[v.id]) || null}
onDone={(text, ok = true) => { setMsg({ ok, text }); load(); }}
/>
))}
</div>
</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 (
<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 }}>
<strong style={{ fontSize: 13 }}>{vendor.name}</strong>
{deployed
? <span className="badge success">deployed · {status.file_count} files</span>
: <span className="badge neutral">not deployed</span>}
{vendor.status === 'staging-only' && <span className="badge warning" title={vendor.buildHint}>build pipeline pending</span>}
<div style={{ flex: 1 }} />
{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])} />
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', lineHeight: 1.55 }}>
{vendor.sub}<br />
<span className="mono" style={{ fontSize: 11 }}>expects: {vendor.expect}</span>
{lastUpload && <><br /><span style={{ color: 'var(--text-3)' }}>uploaded: {lastUpload}</span></>}
{deployed && <><br /><span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>on host: rebuild with {vendor.buildHint}</span></>}
</div>
</div>
);
}
function AmppSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);

View file

@ -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',
videoBitrate = '10M',
rateControl = null, // 'cbr' | 'vbr' | 'cqp' — optional
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.
// 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) => {

View file

@ -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}`);
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: '10M',
audioCodec: 'aac',
audioBitrate: '192k',
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);