Compare commits

..
Author SHA1 Message Date
asepharyana c8473b0610 build(nix): fix binding link path — LDC_LIB is full .so path
binding.gyp appended '/libdatachannel.so.0.24.0' to LDC_LIB; nixpkgs output
layout is <out>/lib/libdatachannel.so.0.24.1. Make LDC_LIB the complete
library path (env or default) and drop the append.
2026-08-11 18:54:12 +07:00
asepharyana 3deca91ffe build(nix): use nixpkgs libdatachannel (no cmake/fetchFromGitHub)
libdatachannel-src fetchFromGitHub + manual cmake build fails: GitHub tarball
does not include git submodules (deps/plog, libjuice, libsrtp, usrsctp) →
CMake 'source directory does not contain CMakeLists.txt'.

Switch to pkgs.libdatachannel (0.24.1): nixpkgs builds submodules + ships
lib/dev outputs. In the Nix sandbox everything is consistent (store glibc),
so the GLIBC_ABI_GNU2_TLS issue that blocks host-local use of 0.24.1 does
not apply to the Nix build. binding.gyp defaults stay on local 0.24.0 for
dev; Nix sets LDC_INCLUDE/LDC_LIB to the store paths.
2026-08-11 18:45:51 +07:00
asepharyana edec2edf82 build(nix): binding — NAPI_INCLUDE from pnpm store (depth 3), gyp env fallback 2026-08-11 18:35:49 +07:00
asepharyana 17013fe1e5 build(nix): gateway binding — gyp env-var paths, correct cwd, tolerant install
- binding.gyp: resolve libdatachannel include/.so via LDC_INCLUDE/LDC_LIB env
  (node -e expression) instead of hardcoded /tmp/ldc-build paths
- flake buildPhase: run node-gyp from native/libdatachannel-min root (was
  build/ subdir → 'binding.gyp not found'); export LDC_INCLUDE (fetchFromGitHub
  source) + LDC_LIB (cmake build dir)
- flake installPhase: tolerate missing binding (screen share disabled, gateway
  still starts); copy .so real files via -rL
2026-08-11 18:32:09 +07:00
asepharyana 3acb03391a build(nix): gateway flake — build libdatachannel-min binding, drop datachannel/node-av/zeromq
- Replace the per-package rebuild loop (node-datachannel cmake-js, zeromq)
  with: opus build + libdatachannel-min N-API binding build (fetchFromGitHub
  libdatachannel v0.24.0 — pinned because nixpkgs 0.24.1 is glibc-incompatible
  with this host; sha256 1jk53qs…).
- Removes ~760MB of node-datachannel build/cleanup cruft from the build
  phase; node_modules now 423MB (was 1.5GB).
2026-08-11 17:53:47 +07:00
asepharyana 9109d3c898 perf(golive): drop @dank074/discord-video-stream — node_modules 1.5GB → 423MB
Remove the last heavy GoLive dependency now that src/goLive/ replaces it:
- @dank074/discord-video-stream (pulled in @lng2004/node-datachannel
  771MB, node-av 118MB + @seydx/node-av-linux-x64 167MB, zeromq 21MB,
  fluent-ffmpeg 13MB — ~1.09GB total)
- onlyBuiltDependencies: drop node-av/zeromq/@lng2004 (keep opus/esbuild/sharp)
- pnpm.lock regenerated; orphan .pnpm dirs removed locally
- @discordjs/opus prebuild: rebuilt binary copied into
  prebuild/node-v127-napi-v3-linux-x64-glibc-2.39/ (node-pre-gyp find path)

Verified: tsc 0 errors, vitest 8/8, biome clean, opus encode OK.
Fresh CI install now ~423MB instead of ~1.5GB.
2026-08-11 17:49:33 +07:00
asepharyana 9139e225f4 perf(golive): ffmpeg-spawn demuxer (no node-av) + E2E pipeline tests
Phase 2 — replace the 114MB node-av binary with a plain ffmpeg spawn:

Demuxer.ts: spool stream input to temp file → probe via ffmpeg stderr
(ffmpeg-headless ships NO ffprobe — parse 'Stream #0:0: Video: h264...
640x360, 30 fps' from -loglevel info) → ffmpeg -c copy -f h264 pipe:1
→ NAL-split frames. Falls back to h264 defaults when probe fails.

prepareStream.ts: resolve ffmpeg from FFMPEG_PATH env → Nix store
ffmpeg-headless (hash-prefixed entry!) → PATH; split encoder option
strings ('-forced-idr 1' → two argv) — fluent-ffmpeg used to split
automatically, spawn does not.

E2E tests (tsx, need LD_LIBRARY_PATH=/tmp/ldc-build):
- golive-demux-e2e.ts: real H264 file → 33 NAL frames + dims from probe
- golive-pipeline-e2e.ts: prepareStream → demux → 82 frames
- golive-videostream-e2e.ts: local peer pair → demux → VideoStream →
  native setPacketizer/sendFrame/addTimestamp → 33 frames sent connected

Pitfalls captured: setPacketizer before negotiation breaks createOffer
('No DataChannel or Track to negotiate'); track methods are read-only
(no monkeypatching); both peers must declare audio+video tracks or
answer hangs; state() returns 'closed' after close() — snapshot first.
2026-08-11 17:38:06 +07:00
11 changed files with 425 additions and 978 deletions
+44 -28
View File
@@ -11,6 +11,11 @@
let
pkgs = import nixpkgs { inherit system; };
# libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built
# against this host's glibc and ships both lib + dev headers, so the
# binding links cleanly inside the Nix sandbox (no manual cmake build).
libdatachannel = pkgs.libdatachannel;
# Source filter: `path:` literals do NOT respect .gitignore by default,
# so a dirty local out/ (stale chunks from previous builds) leaks into
# the sandbox. Filter out build artifacts explicitly.
@@ -162,6 +167,7 @@ WRAPPER
pkgs.pkg-config
pkgs.openssl
pkgs.openssl.dev
libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding
pkgs.git # libdatachannel FetchContent clones from GitHub
pkgs.cacert
];
@@ -180,41 +186,34 @@ WRAPPER
# pnpm rebuild aborts on the first failing package and runs scripts
# from the wrong cwd build each native dep explicitly with its own
# install script. Each failure is tolerated (|| true); the packages
# that matter (opus, datachannel, node-av) are verified at runtime.
# that matter (opus) are verified at runtime.
for pkg in \
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus \
node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel \
node_modules/.pnpm/zeromq@*/node_modules/zeromq
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus
do
if [ -d "$pkg" ]; then
echo "--- native build: $pkg ---"
(cd "$pkg" && npm run install 2>&1 || true)
# node-datachannel's `prebuild -r napi` CLI is broken (TypeError:
# expected first argument to be an array) the install fallback
# populates devDeps incl. cmake-js; build directly via cmake-js.
if [ "$(basename "$pkg")" = "node-datachannel" ]; then
echo "--- datachannel cmake-js compile ---"
# Nix splits OpenSSL headers/libs across outputs merge them
# (opensslDevEnv) so FindOpenSSL finds both include + libcrypto.
(cd "$pkg" && OPENSSL_ROOT_DIR="${opensslDevEnv}" npm run compile 2>&1 || true)
fi
fi
done
echo "=== Cleaning node-datachannel build tree ==="
# Runtime only needs build/Release/node_datachannel.node + dist/
# the cmake FetchContent sources (build/_deps, ~380MB), intermediate
# cmake files, and the nested node_modules of build tooling (nw-gyp,
# typescript, puppeteer, eslint, ... ~380MB) are build-time only.
for pkg in node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel
do
if [ -d "$pkg" ]; then
( cd "$pkg/build" \
&& find . -mindepth 1 -maxdepth 1 ! -name 'Release' -exec rm -rf {} + ) 2>/dev/null || true
rm -rf "$pkg/node_modules" 2>/dev/null || true
echo "node-datachannel cleaned: $(du -sh "$pkg" | cut -f1)"
fi
done
echo "=== Compiling TypeScript ==="
echo "=== Building libdatachannel-min N-API binding ==="
# The GoLive screen-share stack uses a minimal N-API binding
# (native/libdatachannel-min) over nixpkgs libdatachannel.
(
cd native/libdatachannel-min
# binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev
# include root, LDC_LIB = lib output dir, NAPI_INCLUDE =
# node-addon-api include root).
NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \
-type d -path "*node_modules/node-addon-api" | head -1)
echo "NAPI_INCLUDE=$NAPI_INCLUDE"
LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \
NAPI_INCLUDE=$NAPI_INCLUDE \
npx node-gyp rebuild 2>&1 || true
ls -la build/Release/datachannel_min.node 2>/dev/null \
&& echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \
|| echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)"
)
echo "=== Compiling TypeScript ===="
npx tsc 2>&1
echo "=== Fixing @/ path aliases to relative paths ==="
node -e "
@@ -248,6 +247,22 @@ WRAPPER
mkdir -p $out/lib/gmw-discord-gateway
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
# GoLive native binding loadNative resolves it relative to
# dist/goLive/native.js, i.e. <root>/native/libdatachannel-min/
# build/Release/datachannel_min.node; libdatachannel .so must sit
# next to it and be on LD_LIBRARY_PATH at runtime.
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release
cp native/libdatachannel-min/build/Release/datachannel_min.node \
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc
cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true
# If the binding failed to build, screen share is simply disabled
# the gateway itself must still start.
if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then
echo "WARN: datachannel_min.node missing GoLive screen share disabled in this build"
fi
# Also include drizzle migrations if they exist
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
@@ -256,6 +271,7 @@ WRAPPER
#!${pkgs.runtimeShell}
cd $out/lib/gmw-discord-gateway
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_PATH
exec ${nodejs}/bin/node dist/index.js
WRAPPER
chmod +x $out/bin/gmw-discord-gateway
@@ -4,11 +4,11 @@
"target_name": "libdatachannel_min",
"sources": ["binding.cpp"],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")",
"/home/code/GMW/services/discord-gateway/node_modules/.pnpm/@lng2004+node-datachannel@0.32.0-20260202/node_modules/@lng2004/node-datachannel/build/_deps/libdatachannel-src/include"
"<!(node -e \"console.log(process.env.NAPI_INCLUDE || (() => { try { return require('node-addon-api').include; } catch { return '/nonexistent'; } })())\")",
"<!(node -e \"const s=process.env.LDC_INCLUDE||'/nix/store/39a85gpfjqy3h3k8jwrwh7m9yc3inqw7-source';console.log(s+'/include')\")"
],
"libraries": [
"/tmp/ldc-build/libdatachannel.so.0.24.0"
"<!(node -e \"console.log(process.env.LDC_LIB || '/tmp/ldc-build/libdatachannel.so.0.24.0')\")"
],
"cflags": ["-std=c++17", "-fexceptions"],
"cflags_cc": ["-std=c++17", "-fexceptions"],
+1 -5
View File
@@ -7,11 +7,8 @@
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
"@lng2004/node-datachannel",
"esbuild",
"node-av",
"sharp",
"zeromq"
"sharp"
]
},
"scripts": {
@@ -24,7 +21,6 @@
"test": "vitest run"
},
"dependencies": {
"@dank074/discord-video-stream": "6.0.0",
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
+36 -896
View File
File diff suppressed because it is too large Load Diff
@@ -323,7 +323,7 @@ a=ice-lite
}
const { op, d, seq } = JSON.parse(e.data as string) as {
op: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Discord voice WS payload is dynamically typed
// biome-ignore lint/suspicious/noExplicitAny: Discord voice WS payload is dynamically typed
d: any;
seq?: number;
};
+130 -39
View File
@@ -14,17 +14,44 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createWriteStream, existsSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
export enum AVCodecID {
AV_CODEC_ID_H264 = 27,
AV_CODEC_ID_HEVC = 173,
AV_CODEC_ID_VP8 = 139,
AV_CODEC_ID_VP9 = 167,
AV_CODEC_ID_AV1 = 225,
AV_CODEC_ID_OPUS = 86019,
/**
* Resolve ffmpeg/ffprobe binary. Prefers explicit env override, then PATH,
* then a Nix-store ffmpeg-headless (the GMW flake provides it in the service
* profile, but dev shells / tests may not have it on PATH).
*/
function resolveBin(name: "ffmpeg"): string {
const override = process.env.FFMPEG_PATH;
if (override && existsSync(override)) return override;
// Nix store scan: <store>/<hash>-ffmpeg-headless-*/bin/<name>
const store = "/nix/store";
if (existsSync(store)) {
const entries = readdirSync(store);
for (const entry of entries) {
if (!entry.includes("ffmpeg-headless-")) continue;
const candidate = join(store, entry, "bin", name);
if (existsSync(candidate)) return candidate;
}
}
return name; // fall back to PATH
}
const FFMPEG = resolveBin("ffmpeg");
export const AVCodecID = {
AV_CODEC_ID_H264: 27,
AV_CODEC_ID_HEVC: 173,
AV_CODEC_ID_VP8: 139,
AV_CODEC_ID_VP9: 167,
AV_CODEC_ID_AV1: 225,
AV_CODEC_ID_OPUS: 86019,
} as const;
export type AVCodecID = (typeof AVCodecID)[keyof typeof AVCodecID];
export const AV_PKT_FLAG_KEY = 1;
export interface Frame {
@@ -48,37 +75,66 @@ export interface DemuxedStream {
stream: PassThrough;
}
/** Run ffprobe JSON on a file URL, return raw stream descriptors. */
/**
* Probe a media file for stream info using ffmpeg's stderr (the
* ffmpeg-headless Nix package ships ffmpeg but not ffprobe). Returns
* stream descriptors in the same shape ffprobe -show_streams would.
*/
export async function probeStreams(
url: string,
): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
const proc = spawn("ffprobe", [
const proc = spawn(FFMPEG, [
"-hide_banner",
"-loglevel",
"error",
"info",
"-i",
url,
"-print_format",
"json",
"-show_streams",
"-f",
"null",
"-",
]);
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
proc.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
proc.on("close", (code) => {
if (code === 0) {
try {
const parsed = JSON.parse(stdout);
resolve(parsed.streams ?? []);
} catch (e) {
reject(new Error(`Failed to parse ffprobe output: ${e}`));
proc.on("close", () => {
// Parse "Stream #0:0: Video: h264 (High), yuv420p, 640x360, 30 fps"
const streams: Array<Record<string, unknown>> = [];
const re = /Stream #0:(\d+): (Video|Audio): ([^,]+)/g;
let m: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: regex loop idiom
while ((m = re.exec(stderr)) !== null) {
const [full, idx, kind, codecRaw] = m;
void full;
const codecName = codecRaw.split(" ")[0].toLowerCase();
const stream: Record<string, unknown> = {
index: Number(idx),
codec_type: kind.toLowerCase(),
codec_name: codecName,
width: 0,
height: 0,
r_frame_rate: "0/1",
sample_rate: 0,
};
// dimensions: "640x360"
const dim = /(\d{2,5})x(\d{2,5})/.exec(stderr.slice(m.index));
if (dim) {
stream.width = Number(dim[1]);
stream.height = Number(dim[2]);
}
} else {
reject(new Error(`ffprobe failed (${code}): ${stderr}`));
// fps: "30 fps" or "29.97 fps"
const fps = /(\d+(?:\.\d+)?) fps/.exec(stderr.slice(m.index));
if (fps) {
const v = Number(fps[1]);
stream.r_frame_rate = `${Math.round(v * 1000)}/1000`;
}
// sample rate for audio: "48000 Hz"
const sr = /(\d+) Hz/.exec(stderr.slice(m.index));
if (sr) stream.sample_rate = Number(sr[1]);
streams.push(stream);
}
resolve(streams);
});
proc.on("error", (err) => reject(err));
});
}
@@ -100,15 +156,44 @@ export async function demux(
const vPipe = new PassThrough({ objectMode: true, highWaterMark: 128 });
const aPipe = new PassThrough({ objectMode: true, highWaterMark: 128 });
// For stream input, spool to a temp file first so ffprobe can inspect it
// (ffprobe needs a seekable file; pipes can't be re-read). The stream is
// fully consumed before ffmpeg starts — acceptable for screen-share
// sources which are already fully buffered by yt-dlp in practice.
let spoolPath: string | null = null;
const cleanupSpool = () => {
if (spoolPath) {
import("node:fs").then(({ unlink }) => unlink(spoolPath!, () => {}));
spoolPath = null;
}
};
let effectiveInput: string;
if (typeof input === "string") {
effectiveInput = input;
} else {
spoolPath = join(tmpdir(), `golive-demux-${_label}.h264`);
const ws = createWriteStream(spoolPath);
await new Promise<void>((resolve, reject) => {
input.pipe(ws);
input.on("error", reject);
ws.on("finish", resolve);
ws.on("error", reject);
});
effectiveInput = spoolPath;
}
// Probe for codec + dimensions
let streams: Array<Record<string, unknown>> = [];
if (typeof input === "string") {
streams = await probeStreams(input);
try {
streams = await probeStreams(effectiveInput);
} catch (_e) {
// probe failed (e.g. raw h264 without container) — infer h264 default
streams = [];
}
const v = streams.find((s) => s.codec_type === "video");
const a = streams.find((s) => s.codec_type === "audio");
let vInfo: DemuxedStream | undefined;
let aInfo: DemuxedStream | undefined;
@@ -121,7 +206,7 @@ export async function demux(
AVCodecID[
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
"AV_CODEC_ID_H264"
],
] ?? AVCodecID.AV_CODEC_ID_H264,
codecName,
width: (v.width as number) ?? 0,
height: (v.height as number) ?? 0,
@@ -130,6 +215,19 @@ export async function demux(
sample_rate: 0,
stream: vPipe,
};
} else {
// Probe failed (e.g. raw AnnexB h264 input) — still emit frames on the
// video pipe; playStream infers dimensions from the first frame.
vInfo = {
codec: AVCodecID.AV_CODEC_ID_H264,
codecName: "h264",
width: 0,
height: 0,
framerate_num: 0,
framerate_den: 1,
sample_rate: 0,
stream: vPipe,
};
}
if (a) {
@@ -151,12 +249,12 @@ export async function demux(
}
// Spawn ffmpeg — extract raw video (AnnexB for H264) to stdout
const isUrl = typeof input === "string";
const args: string[] = [
"-hide_banner",
"-loglevel",
"error",
...(isUrl ? ["-i", input] : ["-i", "pipe:0"]),
"-i",
effectiveInput,
"-c:v",
"copy",
"-an", // no audio in this minimal demuxer
@@ -165,15 +263,7 @@ export async function demux(
"pipe:1",
];
const proc = isUrl
? spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] })
: spawn("ffmpeg", args, { stdio: ["pipe", "pipe", "pipe"] });
if (proc.stdin && !isUrl) {
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
input.on("end", () => proc.stdin?.end());
input.on("error", () => proc.stdin?.destroy());
}
const proc = spawn(FFMPEG, args, { stdio: ["ignore", "pipe", "pipe"] });
// Scan stdout for NAL units. Each NAL unit (between start codes) is one frame
// payload. We emit them individually; the packetizer chain handles FU-A.
@@ -280,6 +370,7 @@ export async function demux(
proc.kill("SIGTERM");
vPipe.end();
aPipe.end();
cleanupSpool();
};
return { video: vInfo, audio: aInfo, close };
@@ -27,7 +27,7 @@ export interface StreamerClientLike {
broadcast(data: { op: number; d: unknown }): void;
};
guilds?: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- discord.js-selfbot client shape is dynamic
// biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot client shape is dynamic
fetch(id: string): Promise<any>;
};
}
@@ -199,7 +199,7 @@ export class Streamer {
const { guildId } = this.voiceConnection.streamConnection;
if (!this.client.guilds) return;
const server = await this.client.guilds.fetch(guildId);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any -- discord.js-selfbot dynamic
// biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot dynamic
(server as any).members.me?.voice?.postPreview(data);
}
@@ -9,6 +9,8 @@
*/
import { type ChildProcess, spawn } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { PassThrough, type Readable } from "node:stream";
import { demux } from "./Demuxer.js";
import { type EncoderSettings, Encoders } from "./Encoders.js";
@@ -37,6 +39,25 @@ const DEFAULT_HEADERS = {
Connection: "keep-alive",
};
/** Resolve ffmpeg binary (env override → PATH → Nix store ffmpeg-headless). */
function resolveFfmpeg(): string {
if (process.env.FFMPEG_PATH && existsSync(process.env.FFMPEG_PATH)) {
return process.env.FFMPEG_PATH;
}
const store = "/nix/store";
if (existsSync(store)) {
const entries = readdirSync(store);
for (const entry of entries) {
if (!entry.includes("ffmpeg-headless-")) continue;
const candidate = join(store, entry, "bin", "ffmpeg");
if (existsSync(candidate)) return candidate;
}
}
return "ffmpeg";
}
const FFMPEG_BIN = resolveFfmpeg();
/**
* prepareStream build an ffmpeg command (as spawn args + PassThrough output)
* that transcodes the input into a pipe we can demux. Mirrors @dank074's
@@ -132,6 +153,11 @@ export function prepareStream(
throw new Error(
`Encoder settings not specified for ${mergedOptions.videoCodec}`,
);
// Encoder options are declared as single strings like "-forced-idr 1";
// spawn needs each flag and value as separate argv entries.
const encOptions = enc.options.flatMap((opt) =>
opt.split(/\s+/).filter(Boolean),
);
args.push(
"-b:v",
`${mergedOptions.bitrateVideo}k`,
@@ -147,8 +173,10 @@ export function prepareStream(
"expr:gte(t,n_forced*1)",
"-c:v",
enc.name,
...enc.options,
...(enc.globalOptions ?? []),
...encOptions,
...(enc.globalOptions ?? []).flatMap((opt) =>
opt.split(/\s+/).filter(Boolean),
),
);
}
@@ -174,8 +202,8 @@ export function prepareStream(
const isUrl = typeof input === "string";
const proc: ChildProcess = isUrl
? spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] })
: spawn("ffmpeg", args, { stdio: ["pipe", "pipe", "pipe"] });
? spawn(FFMPEG_BIN, args, { stdio: ["ignore", "pipe", "pipe"] })
: spawn(FFMPEG_BIN, args, { stdio: ["pipe", "pipe", "pipe"] });
if (proc.stdin && !isUrl) {
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
@@ -0,0 +1,40 @@
// Phase 2 E2E: Demuxer on a real ffmpeg-generated H264 file.
// Run: npx tsx tests/golive-demux-e2e.ts
import { createReadStream } from "node:fs";
import { demux } from "../src/goLive/Demuxer.js";
const input = process.argv[2] ?? "/tmp/sample.h264";
const { video, close } = await demux(createReadStream(input), {
format: "h264",
});
console.log(
"video:",
JSON.stringify({
codecName: video.codecName,
width: video.width,
height: video.height,
duration: video.duration,
fps: Math.round(video.framerate_num / video.framerate_den),
}),
);
let count = 0;
let keyframes = 0;
let bytes = 0;
video.stream.on("data", (frame: { data: Buffer; keyframe: boolean }) => {
count++;
bytes += frame.data.length;
if (frame.keyframe) keyframes++;
});
video.stream.on("end", () => {
console.log(`frames: ${count} (${keyframes} keyframes), ${bytes} bytes`);
close();
process.exit(0);
});
video.stream.on("error", (e: unknown) => {
console.error("stream error:", e);
close();
process.exit(1);
});
@@ -0,0 +1,58 @@
// Phase 2 E2E: full pipeline prepareStream → demux → frame stream.
// Run: npx tsx tests/golive-pipeline-e2e.ts
import { demux } from "../src/goLive/Demuxer.js";
import { Encoders } from "../src/goLive/Encoders.js";
import { prepareStream } from "../src/goLive/prepareStream.js";
import { normalizeVideoCodec } from "../src/goLive/utils.js";
// Use a real ffmpeg-generated video file as input (from sample generation).
const input = process.argv[2] ?? "/tmp/sample.h264";
const prepared = prepareStream(input, {
encoder: Encoders.software({ x264: { preset: "superfast" } }),
width: 640,
height: 360,
frameRate: 25,
bitrateVideo: 500,
bitrateVideoMax: 800,
includeAudio: false,
videoCodec: normalizeVideoCodec("H264"),
});
console.log(
"prepareStream ok, videoCodec:",
prepared.videoCodec,
"size:",
prepared.width,
"x",
prepared.height,
);
const { video, close } = await demux(prepared.output, { format: "h264" });
console.log("demux video:", video?.codecName, video?.width, "x", video?.height);
let frames = 0;
let keyframes = 0;
video.stream.on("data", (f: { keyframe?: boolean }) => {
frames++;
if (f.keyframe) keyframes++;
});
video.stream.on("end", () => {
console.log(`pipeline frames: ${frames} (${keyframes} keyframes)`);
close();
prepared.command.kill("SIGTERM");
process.exit(frames > 0 ? 0 : 1);
});
video.stream.on("error", (e: unknown) => {
console.error("pipeline error:", e);
close();
prepared.command.kill("SIGTERM");
process.exit(1);
});
setTimeout(() => {
console.log("timeout after 30s — killing");
close();
prepared.command.kill("SIGTERM");
process.exit(2);
}, 30000);
@@ -0,0 +1,78 @@
// Phase 2 E2E: demux → VideoStream → native packetizer chain (local pair).
// Run: npx tsx tests/golive-videostream-e2e.ts
import { createReadStream } from "node:fs";
import { demux } from "../src/goLive/Demuxer.js";
import { loadNative } from "../src/goLive/native.js";
import { VideoStream } from "../src/goLive/VideoStream.js";
async function main() {
const native = loadNative();
const { PeerConnection } = native;
const pcA = new PeerConnection({ iceServers: [] });
const pcB = new PeerConnection({ iceServers: [] });
pcA.onStateChange(() => {});
pcB.onStateChange(() => {});
// Both peers declare audio+video tracks (exact passing test-packetizer
// pattern — tracks trigger negotiation).
pcA.addTrack("0", "audio");
pcA.addTrack("1", "video");
pcB.addTrack("0", "audio");
const trackB = pcB.addTrack("1", "video");
if (!trackB) throw new Error("no track from addTrack");
const track = trackB;
// NOTE: setPacketizer is called AFTER connected (see below) — calling it
// before negotiation breaks the offer (libdatachannel negotiation state).
const offer = await pcA.createOffer();
console.log("T1 offer");
pcB.setRemoteDescription(offer, "offer");
const answer = await pcB.createAnswer(offer);
console.log("T2 answer");
pcA.setRemoteDescription(answer, "answer");
await new Promise((r) => setTimeout(r, 1500));
console.log("T3 states:", pcA.state(), "/", pcB.state());
// Discord-style SSRC/payload: H264 101 @ 90kHz, playout ext id 5
track.setPacketizer("h264", 0x1234, 101, 90000, 5, 0, 10);
const { video, close } = await demux(createReadStream("/tmp/sample.h264"), {
format: "h264",
});
console.log("video stream:", video.codecName, video.width, "x", video.height);
const conn = {
sendVideoFrame: (frame: Buffer, frametime: number) => {
track.sendFrame(frame);
track.addTimestamp(Math.round((frametime * 90000) / 1000));
},
} as unknown as { sendVideoFrame(frame: Buffer, frametime: number): void };
const vStream = new VideoStream(conn as never);
let sent = 0;
const origSend = conn.sendVideoFrame;
conn.sendVideoFrame = (frame: Buffer, frametime: number) => {
sent++;
origSend(frame, frametime);
};
video.stream.pipe(vStream);
await new Promise((r) => setTimeout(r, 4000));
console.log(`sent ${sent} frames via VideoStream; B state=${pcB.state()}`);
const ok = sent > 0 && pcB.state() === "connected";
close();
pcA.close();
pcB.close();
process.exit(ok ? 0 : 1);
}
main().catch((e) => {
console.error("E2E failed:", e);
process.exit(1);
});