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:
parent
dc0bd51648
commit
6398879b56
8 changed files with 449 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
152
services/mam-api/src/routes/sdk.js
Normal file
152
services/mam-api/src/routes/sdk.js
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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/<vendor>/</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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue