Compare commits
No commits in common. "a8656fc1a82881c0f32b51c4f7a1cb84591a2834" and "486e3c27e4c429d4eac27d40a94d969298c66949" have entirely different histories.
a8656fc1a8
...
486e3c27e4
9 changed files with 25 additions and 537 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -23,12 +23,3 @@ services/editor/node_modules
|
||||||
services/editor/**/node_modules
|
services/editor/**/node_modules
|
||||||
services/editor/**/dist
|
services/editor/**/dist
|
||||||
services/editor/.pnpm-store
|
services/editor/.pnpm-store
|
||||||
|
|
||||||
# Blackmagic DeckLink SDK + runtime libs (operator-supplied; see services/capture/build-with-decklink.sh)
|
|
||||||
services/capture/sdk/
|
|
||||||
services/capture/lib/
|
|
||||||
|
|
||||||
# Editor backups
|
|
||||||
*.bak
|
|
||||||
*.bak2
|
|
||||||
.env.bak.*
|
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,6 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
- /dev/shm:/dev/shm
|
|
||||||
- /run/dbus:/run/dbus
|
|
||||||
- /run/systemd:/run/systemd
|
|
||||||
- /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
- /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
|
@ -80,9 +77,6 @@ services:
|
||||||
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
|
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
- /dev/shm:/dev/shm
|
|
||||||
- /run/dbus:/run/dbus
|
|
||||||
- /run/systemd:/run/systemd
|
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
|
|
@ -108,9 +102,6 @@ services:
|
||||||
- "${PORT_WEB_UI:-7434}:80"
|
- "${PORT_WEB_UI:-7434}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
- /dev/shm:/dev/shm
|
|
||||||
- /run/dbus:/run/dbus
|
|
||||||
- /run/systemd:/run/systemd
|
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,8 @@
|
||||||
# ── Stage 1: Build FFmpeg with DeckLink support ─────────────────────────────
|
|
||||||
FROM debian:bookworm AS ffmpeg-builder
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
build-essential nasm yasm pkg-config git ca-certificates python3 \
|
|
||||||
libssl-dev libx264-dev libx265-dev libvpx-dev libopus-dev \
|
|
||||||
libmp3lame-dev libsrt-openssl-dev \
|
|
||||||
libzmq3-dev zlib1g-dev libstdc++-12-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy in BMD DeckLink SDK headers and patch script
|
|
||||||
COPY sdk/ /decklink-sdk/
|
|
||||||
COPY patch_decklink.py /patch_decklink.py
|
|
||||||
|
|
||||||
# Pull FFmpeg 7.1 source
|
|
||||||
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
|
|
||||||
|
|
||||||
# Patch FFmpeg DeckLink code for SDK 16.x API changes
|
|
||||||
RUN python3 /patch_decklink.py
|
|
||||||
|
|
||||||
WORKDIR /ffmpeg
|
|
||||||
RUN ./configure \
|
|
||||||
--prefix=/usr/local \
|
|
||||||
--enable-gpl \
|
|
||||||
--enable-nonfree \
|
|
||||||
--enable-libx264 \
|
|
||||||
--enable-libx265 \
|
|
||||||
--enable-libvpx \
|
|
||||||
--enable-libopus \
|
|
||||||
--enable-libmp3lame \
|
|
||||||
--enable-libsrt \
|
|
||||||
--enable-libzmq \
|
|
||||||
--enable-decklink \
|
|
||||||
--extra-cflags="-I/decklink-sdk" \
|
|
||||||
--disable-doc \
|
|
||||||
--disable-debug \
|
|
||||||
--disable-ffplay \
|
|
||||||
&& make -j$(nproc) \
|
|
||||||
&& make install
|
|
||||||
|
|
||||||
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
|
||||||
FROM node:20-bookworm
|
FROM node:20-bookworm
|
||||||
|
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
# Runtime deps for compiled ffmpeg libs
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
|
|
||||||
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy compiled ffmpeg/ffprobe
|
|
||||||
COPY --from=ffmpeg-builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
|
||||||
COPY --from=ffmpeg-builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
|
||||||
COPY --from=ffmpeg-builder /usr/local/lib/ /usr/local/lib/
|
|
||||||
|
|
||||||
# DeckLink runtime .so
|
|
||||||
COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
|
|
||||||
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
|
|
||||||
RUN ldconfig
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
echo "=== Checking prerequisites ==="
|
|
||||||
|
|
||||||
if [ ! -f sdk/DeckLinkAPI.h ]; then
|
|
||||||
echo "ERROR: sdk/DeckLinkAPI.h not found."
|
|
||||||
echo ""
|
|
||||||
echo "Please download the Blackmagic DeckLink SDK 16.x from:"
|
|
||||||
echo " https://www.blackmagicdesign.com/developer/product/capture"
|
|
||||||
echo ""
|
|
||||||
echo "Then extract the Linux/include/ folder contents into:"
|
|
||||||
echo " $(pwd)/sdk/"
|
|
||||||
echo ""
|
|
||||||
echo "Required files: DeckLinkAPI.h DeckLinkAPIVersion.h DeckLinkAPIDispatch.cpp"
|
|
||||||
echo " LinuxCOM.h DeckLinkAPIModes.h DeckLinkAPITypes.h"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "SDK headers found:"
|
|
||||||
ls sdk/*.h sdk/*.cpp 2>/dev/null
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Building capture container with DeckLink FFmpeg ==="
|
|
||||||
docker compose -f ../../docker-compose.yml build capture
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Verifying DeckLink support in built image ==="
|
|
||||||
docker run --rm wild-dragon-capture ffmpeg -f decklink -list_devices true -i dummy 2>&1 | head -20
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import re
|
|
||||||
|
|
||||||
dec_path = '/ffmpeg/libavdevice/decklink_dec.cpp'
|
|
||||||
|
|
||||||
with open(dec_path) as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Fix 1: IDeckLinkMemoryAllocator -> IDeckLinkMemoryAllocator_v14_2_1
|
|
||||||
# SDK 16 removed the unversioned alias
|
|
||||||
for old, new in [
|
|
||||||
(': public IDeckLinkMemoryAllocator\n', ': public IDeckLinkMemoryAllocator_v14_2_1\n'),
|
|
||||||
('IDeckLinkMemoryAllocator *', 'IDeckLinkMemoryAllocator_v14_2_1 *'),
|
|
||||||
('IDeckLinkMemoryAllocator*', 'IDeckLinkMemoryAllocator_v14_2_1*'),
|
|
||||||
]:
|
|
||||||
content = content.replace(old, new)
|
|
||||||
print('Fix 1: IDeckLinkMemoryAllocator renamed')
|
|
||||||
|
|
||||||
# Fix 2: SetVideoInputFrameMemoryAllocator removed from IDeckLinkInput in SDK 16
|
|
||||||
content = re.sub(
|
|
||||||
r'ret = \(ctx->dli->SetVideoInputFrameMemoryAllocator\(allocator\)[^;]*;',
|
|
||||||
'ret = 0; /* SDK16: SetVideoInputFrameMemoryAllocator removed */',
|
|
||||||
content
|
|
||||||
)
|
|
||||||
print('Fix 2: SetVideoInputFrameMemoryAllocator patched')
|
|
||||||
|
|
||||||
# Fix 3: IDeckLinkVideoFrame::GetBytes removed in SDK 16 - moved to IDeckLinkVideoBuffer
|
|
||||||
# Replace: videoFrame->GetBytes(&frameBytes);
|
|
||||||
# With: { IDeckLinkVideoBuffer *vbuf = nullptr; videoFrame->QueryInterface(IID_IDeckLinkVideoBuffer, (void**)&vbuf); if (vbuf) { vbuf->GetBytes(&frameBytes); vbuf->Release(); } }
|
|
||||||
getbytes_replacement = (
|
|
||||||
'{ IDeckLinkVideoBuffer *_vbuf = nullptr; '
|
|
||||||
'videoFrame->QueryInterface(IID_IDeckLinkVideoBuffer, (void**)&_vbuf); '
|
|
||||||
'if (_vbuf) { _vbuf->GetBytes(&frameBytes); _vbuf->Release(); } }'
|
|
||||||
)
|
|
||||||
content = content.replace(
|
|
||||||
'videoFrame->GetBytes(&frameBytes);',
|
|
||||||
getbytes_replacement + ';'
|
|
||||||
)
|
|
||||||
print('Fix 3: videoFrame->GetBytes replaced with QueryInterface(IDeckLinkVideoBuffer)')
|
|
||||||
|
|
||||||
# Fix 4: Add include for versioned allocator header
|
|
||||||
if 'DeckLinkAPIMemoryAllocator_v14_2_1.h' not in content:
|
|
||||||
content = content.replace(
|
|
||||||
'#include "decklink_common.h"',
|
|
||||||
'#include "decklink_common.h"\n#include "DeckLinkAPIMemoryAllocator_v14_2_1.h"'
|
|
||||||
)
|
|
||||||
print('Fix 4: DeckLinkAPIMemoryAllocator_v14_2_1.h include added')
|
|
||||||
|
|
||||||
with open(dec_path, 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
print('All patches applied successfully')
|
|
||||||
|
|
@ -8,17 +8,17 @@ const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
||||||
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
|
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
|
||||||
// / pix_fmt are layered on top from the per-recorder configuration.
|
// / pix_fmt are layered on top from the per-recorder configuration.
|
||||||
const VIDEO_CODECS = {
|
const VIDEO_CODECS = {
|
||||||
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false },
|
||||||
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false },
|
||||||
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false },
|
||||||
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false },
|
||||||
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' },
|
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true },
|
||||||
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
|
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false },
|
||||||
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
|
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false },
|
||||||
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true },
|
||||||
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true },
|
||||||
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true },
|
||||||
hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUDIO_CODECS = {
|
const AUDIO_CODECS = {
|
||||||
|
|
@ -56,7 +56,6 @@ function buildEncodeArgs({
|
||||||
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
|
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
|
||||||
|
|
||||||
args.push(...v.args);
|
args.push(...v.args);
|
||||||
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
|
|
||||||
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
|
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
|
||||||
if (framerate && framerate !== 'native') args.push('-r', framerate);
|
if (framerate && framerate !== 'native') args.push('-r', framerate);
|
||||||
|
|
||||||
|
|
@ -64,7 +63,7 @@ function buildEncodeArgs({
|
||||||
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
|
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
|
||||||
if (audioChannels) args.push('-ac', String(audioChannels));
|
if (audioChannels) args.push('-ac', String(audioChannels));
|
||||||
|
|
||||||
if (fmt === 'mov' || fmt === 'mp4') {
|
if (isNetwork && (fmt === 'mov' || fmt === 'mp4')) {
|
||||||
args.push('-movflags', '+frag_keyframe+empty_moov');
|
args.push('-movflags', '+frag_keyframe+empty_moov');
|
||||||
}
|
}
|
||||||
args.push('-f', fmt);
|
args.push('-f', fmt);
|
||||||
|
|
@ -91,7 +90,7 @@ class CaptureManager {
|
||||||
* Returns { inputArgs, isNetwork }
|
* Returns { inputArgs, isNetwork }
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
_buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
||||||
if (sourceType === 'srt') {
|
if (sourceType === 'srt') {
|
||||||
let url;
|
let url;
|
||||||
if (listen) {
|
if (listen) {
|
||||||
|
|
@ -119,28 +118,8 @@ class CaptureManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: SDI via DeckLink
|
// Default: SDI via DeckLink
|
||||||
// device may be an integer index (0-based) or a full device name string.
|
|
||||||
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo (2)').
|
|
||||||
// Map integer index -> name using ffmpeg -sources decklink at runtime.
|
|
||||||
let deckLinkName = String(device);
|
|
||||||
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
|
|
||||||
const idx = parseInt(device, 10);
|
|
||||||
try {
|
|
||||||
const { execSync } = await import('child_process');
|
|
||||||
const out = execSync('ffmpeg -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
|
||||||
const names = [];
|
|
||||||
for (const line of out.split('\n')) {
|
|
||||||
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
|
||||||
if (m) names.push(m[1]);
|
|
||||||
}
|
|
||||||
if (names[idx]) deckLinkName = names[idx];
|
|
||||||
else deckLinkName = `DeckLink Duo (${idx + 1})`;
|
|
||||||
} catch (_) {
|
|
||||||
deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
|
inputArgs: ['-f', 'decklink', '-i', String(device)],
|
||||||
isNetwork: false,
|
isNetwork: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +178,7 @@ class CaptureManager {
|
||||||
|
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
const { inputArgs, isNetwork } = await this._buildInputArgs({
|
const { inputArgs, isNetwork } = this._buildInputArgs({
|
||||||
sourceType, device, sourceUrl, listen, listenPort, streamKey,
|
sourceType, device, sourceUrl, listen, listenPort, streamKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -213,11 +192,8 @@ class CaptureManager {
|
||||||
|
|
||||||
console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
|
console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
|
||||||
|
|
||||||
const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1'] : [];
|
|
||||||
|
|
||||||
const hiresProcess = spawn('ffmpeg', [
|
const hiresProcess = spawn('ffmpeg', [
|
||||||
...inputArgs,
|
...inputArgs,
|
||||||
...sdiFilterArgs,
|
|
||||||
...hiresCodecArgs,
|
...hiresCodecArgs,
|
||||||
'pipe:1',
|
'pipe:1',
|
||||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
@ -289,7 +265,6 @@ class CaptureManager {
|
||||||
|
|
||||||
const proxyProcess = spawn('ffmpeg', [
|
const proxyProcess = spawn('ffmpeg', [
|
||||||
...inputArgs,
|
...inputArgs,
|
||||||
...sdiFilterArgs,
|
|
||||||
...proxyCodecArgs,
|
...proxyCodecArgs,
|
||||||
'-movflags', '+frag_keyframe+empty_moov',
|
'-movflags', '+frag_keyframe+empty_moov',
|
||||||
'pipe:1',
|
'pipe:1',
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ router.get('/devices', (req, res) => {
|
||||||
let output = '';
|
let output = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
output = execSync('ffmpeg -sources decklink 2>&1', {
|
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -101,7 +101,7 @@ router.get('/devices', (req, res) => {
|
||||||
let deviceIndex = 0;
|
let deviceIndex = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||||
if (match) {
|
if (match) {
|
||||||
devices.push({
|
devices.push({
|
||||||
index: deviceIndex,
|
index: deviceIndex,
|
||||||
|
|
@ -137,10 +137,10 @@ router.post('/probe', async (req, res) => {
|
||||||
|
|
||||||
if (source_type === 'sdi') {
|
if (source_type === 'sdi') {
|
||||||
try {
|
try {
|
||||||
const raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||||
const devices = [];
|
const devices = [];
|
||||||
for (const line of raw.split('\n')) {
|
for (const line of raw.split('\n')) {
|
||||||
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||||
if (m) devices.push(m[1]);
|
if (m) devices.push(m[1]);
|
||||||
}
|
}
|
||||||
return res.json({ ok: true, source_type, devices });
|
return res.json({ ok: true, source_type, devices });
|
||||||
|
|
|
||||||
|
|
@ -1,330 +0,0 @@
|
||||||
import express from 'express';
|
|
||||||
import { execSync, spawn } from 'child_process';
|
|
||||||
import captureManager from '../capture-manager.js';
|
|
||||||
|
|
||||||
import dgram from 'dgram';
|
|
||||||
import net from 'net';
|
|
||||||
|
|
||||||
function parseUrl(u) {
|
|
||||||
try {
|
|
||||||
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
|
|
||||||
if (!m) return null;
|
|
||||||
return { host: m[1], port: parseInt(m[2] || '0', 10) };
|
|
||||||
} catch (_) { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkReachable(host, port, sourceType) {
|
|
||||||
if (!port) return { ok: true };
|
|
||||||
if (sourceType === 'srt') return await udpSendProbe(host, port);
|
|
||||||
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function udpSendProbe(host, port) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = dgram.createSocket('udp4');
|
|
||||||
let done = false;
|
|
||||||
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
|
|
||||||
sock.on('error', (err) => {
|
|
||||||
const msg = String(err && err.message || err);
|
|
||||||
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
|
|
||||||
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
|
|
||||||
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
|
|
||||||
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
|
|
||||||
} else {
|
|
||||||
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
|
|
||||||
setTimeout(() => finish({ ok: true }), 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function tcpConnectProbe(host, port) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = new net.Socket();
|
|
||||||
let done = false;
|
|
||||||
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
|
|
||||||
sock.setTimeout(2500);
|
|
||||||
sock.once('connect', () => finish({ ok: true }));
|
|
||||||
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
|
|
||||||
sock.once('error', (err) => {
|
|
||||||
const msg = String(err && err.message || err);
|
|
||||||
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
|
|
||||||
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
|
|
||||||
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
|
|
||||||
});
|
|
||||||
sock.connect(port, host);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function classifyProbeError(raw, sourceType) {
|
|
||||||
const r = (raw || '').toLowerCase();
|
|
||||||
if (sourceType === 'srt') {
|
|
||||||
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
|
|
||||||
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sourceType === 'rtmp') {
|
|
||||||
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
|
|
||||||
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /devices
|
|
||||||
* List available DeckLink devices
|
|
||||||
*/
|
|
||||||
router.get('/devices', (req, res) => {
|
|
||||||
try {
|
|
||||||
const devices = [];
|
|
||||||
let output = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// ffmpeg returns non-zero, but stderr is still captured
|
|
||||||
output = error.stderr ? error.stderr.toString() : error.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse ffmpeg output for DeckLink device names
|
|
||||||
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
|
||||||
const lines = output.split('\n');
|
|
||||||
let deviceIndex = 0;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
|
|
||||||
if (match) {
|
|
||||||
devices.push({
|
|
||||||
index: deviceIndex,
|
|
||||||
name: match[1],
|
|
||||||
});
|
|
||||||
deviceIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ devices });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error listing devices:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to list devices' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /status
|
|
||||||
* Get current capture status
|
|
||||||
*/
|
|
||||||
router.get('/status', (req, res) => {
|
|
||||||
try {
|
|
||||||
const status = captureManager.getStatus();
|
|
||||||
res.json(status);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting status:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to get status' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
router.post('/probe', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
|
|
||||||
|
|
||||||
if (source_type === 'sdi') {
|
|
||||||
try {
|
|
||||||
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
|
||||||
const devices = [];
|
|
||||||
for (const line of raw.split('\n')) {
|
|
||||||
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
|
|
||||||
if (m) devices.push(m[1]);
|
|
||||||
}
|
|
||||||
return res.json({ ok: true, source_type, devices });
|
|
||||||
} catch (err) {
|
|
||||||
const out = (err.stderr || err.stdout || err.toString()).toString();
|
|
||||||
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listen) {
|
|
||||||
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
|
|
||||||
|
|
||||||
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
|
|
||||||
// an actionable error instead of the opaque libsrt "Input/output error".
|
|
||||||
const parsed = parseUrl(source_url);
|
|
||||||
if (!parsed) {
|
|
||||||
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
|
|
||||||
}
|
|
||||||
const reach = await checkReachable(parsed.host, parsed.port, source_type);
|
|
||||||
if (!reach.ok) {
|
|
||||||
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = source_url;
|
|
||||||
if (source_type === 'srt' && !/mode=/.test(url)) {
|
|
||||||
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
|
|
||||||
const ff = spawn('ffprobe', args);
|
|
||||||
let stdout = '', stderr = '';
|
|
||||||
ff.stdout.on('data', (c) => { stdout += c; });
|
|
||||||
ff.stderr.on('data', (c) => { stderr += c; });
|
|
||||||
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
|
|
||||||
ff.on('close', (code) => {
|
|
||||||
clearTimeout(killer);
|
|
||||||
if (code !== 0) {
|
|
||||||
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
|
|
||||||
const friendly = classifyProbeError(rawErr, source_type);
|
|
||||||
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(stdout);
|
|
||||||
const streams = (parsed.streams || []).map(s => ({
|
|
||||||
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
|
|
||||||
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
|
|
||||||
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
|
|
||||||
sample_rate: s.sample_rate, channels: s.channels,
|
|
||||||
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
|
|
||||||
}));
|
|
||||||
return res.json({ ok: true, source_type, source_url,
|
|
||||||
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
|
|
||||||
streams });
|
|
||||||
} catch (err) {
|
|
||||||
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Probe error:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /start
|
|
||||||
* Start a new capture session
|
|
||||||
*
|
|
||||||
* Body (SDI):
|
|
||||||
* { project_id, clip_name, device, bin_id?, source_type? }
|
|
||||||
*
|
|
||||||
* Body (SRT/RTMP caller):
|
|
||||||
* { project_id, clip_name, source_type, source_url, bin_id? }
|
|
||||||
*
|
|
||||||
* Body (SRT/RTMP listener):
|
|
||||||
* { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? }
|
|
||||||
*/
|
|
||||||
router.post('/start', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
project_id,
|
|
||||||
bin_id,
|
|
||||||
clip_name,
|
|
||||||
device,
|
|
||||||
source_type = 'sdi',
|
|
||||||
source_url,
|
|
||||||
listen = false,
|
|
||||||
listen_port,
|
|
||||||
stream_key,
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
if (!project_id || !clip_name) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Missing required fields: project_id, clip_name',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source-specific validation
|
|
||||||
if (source_type === 'sdi') {
|
|
||||||
if (device === undefined || device === null) {
|
|
||||||
return res.status(400).json({ error: 'SDI source requires: device' });
|
|
||||||
}
|
|
||||||
} else if (source_type === 'srt' || source_type === 'rtmp') {
|
|
||||||
if (!listen && !source_url) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: `${source_type.toUpperCase()} caller mode requires: source_url`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await captureManager.start({
|
|
||||||
projectId: project_id,
|
|
||||||
binId: bin_id || null,
|
|
||||||
clipName: clip_name,
|
|
||||||
device,
|
|
||||||
sourceType: source_type,
|
|
||||||
sourceUrl: source_url,
|
|
||||||
listen,
|
|
||||||
listenPort: listen_port,
|
|
||||||
streamKey: stream_key,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(session);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error starting capture:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /stop
|
|
||||||
* Stop the current capture session
|
|
||||||
* Body: { session_id }
|
|
||||||
*/
|
|
||||||
router.post('/stop', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { session_id } = req.body;
|
|
||||||
|
|
||||||
if (!session_id) {
|
|
||||||
return res.status(400).json({ error: 'Missing required field: session_id' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const completedSession = await captureManager.stop(session_id);
|
|
||||||
|
|
||||||
// Register asset with mam-api.
|
|
||||||
// If proxyKey is null (SRT/RTMP source), set needsProxy=true so the
|
|
||||||
// worker generates a proxy from the hires file asynchronously.
|
|
||||||
try {
|
|
||||||
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
projectId: completedSession.projectId,
|
|
||||||
binId: completedSession.binId,
|
|
||||||
clipName: completedSession.clipName,
|
|
||||||
sourceType: completedSession.sourceType,
|
|
||||||
hiresKey: completedSession.hiresKey,
|
|
||||||
proxyKey: completedSession.proxyKey,
|
|
||||||
needsProxy: completedSession.proxyKey === null,
|
|
||||||
duration: completedSession.duration,
|
|
||||||
capturedAt: completedSession.startedAt,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!mamResponse.ok) {
|
|
||||||
console.warn(
|
|
||||||
`MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (mamError) {
|
|
||||||
console.warn('Failed to register asset with MAM API:', mamError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(completedSession);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error stopping capture:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -386,9 +386,9 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Source type</label>
|
<label class="form-label">Source type</label>
|
||||||
<div class="source-type-row">
|
<div class="source-type-row">
|
||||||
<button class="source-type-btn" data-type="srt" onclick="setSourceType('srt')">SRT</button>
|
<button class="source-type-btn active" data-type="srt" onclick="setSourceType('srt')">SRT</button>
|
||||||
<button class="source-type-btn" data-type="rtmp" onclick="setSourceType('rtmp')">RTMP</button>
|
<button class="source-type-btn" data-type="rtmp" onclick="setSourceType('rtmp')">RTMP</button>
|
||||||
<button class="source-type-btn active" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>
|
<button class="source-type-btn" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -662,7 +662,7 @@
|
||||||
const PROXY_DEFAULT_CONTAINER = 'mp4';
|
const PROXY_DEFAULT_CONTAINER = 'mp4';
|
||||||
|
|
||||||
const pState = {
|
const pState = {
|
||||||
recorders: [], timers: {}, sourceType: 'sdi', mode: 'caller',
|
recorders: [], timers: {}, sourceType: 'srt', mode: 'caller',
|
||||||
projects: [], signals: {}, editingId: null,
|
projects: [], signals: {}, editingId: null,
|
||||||
// SDI picker state
|
// SDI picker state
|
||||||
bmdDevices: [], // flat list from /cluster/devices/blackmagic
|
bmdDevices: [], // flat list from /cluster/devices/blackmagic
|
||||||
|
|
@ -1068,10 +1068,10 @@
|
||||||
const pr = document.getElementById('probeResult');
|
const pr = document.getElementById('probeResult');
|
||||||
if (pr) pr.remove();
|
if (pr) pr.remove();
|
||||||
|
|
||||||
pState.sourceType = 'sdi';
|
pState.sourceType = 'srt';
|
||||||
pState.selectedNodeId = null;
|
pState.selectedNodeId = null;
|
||||||
pState.selectedDeviceIndex = null;
|
pState.selectedDeviceIndex = null;
|
||||||
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'sdi'));
|
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'srt'));
|
||||||
// Make sure tabs are on Video
|
// Make sure tabs are on Video
|
||||||
document.querySelectorAll('.codec-tabs').forEach(tabs => {
|
document.querySelectorAll('.codec-tabs').forEach(tabs => {
|
||||||
tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'video'));
|
tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'video'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue