refactor(gateway): remove screen-share / GoLive feature entirely
Drop the Discord Go Live (screen share) stack across the discord-gateway: - delete src/goLive/ (19 modules: Streamer, Demuxer, encoders, WebRTC wrapper, native loader, etc.) - delete native/libdatachannel-min/ N-API binding + flake native build + LD_LIBRARY_PATH wiring - delete screenShareController.ts and screen-share tests (goLive-port, golive-*, demuxerNut, screenShareInput) - mediaSource.ts: remove Invidious helpers + downloadScreenInput (YouTube full-file download) - mediaTypes.ts: drop ScreenShare* types, narrow MediaMode to 'music' and DiscordPlayerOwner to non-screen - media.handler.ts: remove screen branch, screenController/screenPlayback, voice-disconnect/reconnect accessor - commandHandler.ts: stop passing getVoiceStatus / setVoiceController into MediaHandler - media handler now only handles music; music queue/playback/status untouched Verification: tsc --noEmit clean, biome clean on touched files, no lingering goLive/screenShare refs in BE/FE/gateway.
This commit is contained in:
@@ -11,11 +11,6 @@
|
|||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
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,
|
# Source filter: `path:` literals do NOT respect .gitignore by default,
|
||||||
# so a dirty local out/ (stale chunks from previous builds) leaks into
|
# so a dirty local out/ (stale chunks from previous builds) leaks into
|
||||||
# the sandbox. Filter out build artifacts explicitly.
|
# the sandbox. Filter out build artifacts explicitly.
|
||||||
@@ -73,7 +68,7 @@
|
|||||||
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
|
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
|
||||||
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
|
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
|
||||||
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
|
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
|
||||||
# are only reachable through it), silently breaking voice/screenshare.
|
# are only reachable through it), silently breaking voice.
|
||||||
# Instead we keep the full install's symlink layout and only prune
|
# Instead we keep the full install's symlink layout and only prune
|
||||||
# orphaned package dirs + broken symlinks.
|
# orphaned package dirs + broken symlinks.
|
||||||
# Must run AFTER tsc (typescript is a devDep) and after native builds.
|
# Must run AFTER tsc (typescript is a devDep) and after native builds.
|
||||||
@@ -167,8 +162,7 @@ WRAPPER
|
|||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
pkgs.openssl.dev
|
pkgs.openssl.dev
|
||||||
libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding
|
pkgs.git # for any FetchContent-based deps during native builds
|
||||||
pkgs.git # libdatachannel FetchContent clones from GitHub
|
|
||||||
pkgs.cacert
|
pkgs.cacert
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -195,24 +189,6 @@ WRAPPER
|
|||||||
(cd "$pkg" && npm run install 2>&1 || true)
|
(cd "$pkg" && npm run install 2>&1 || true)
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
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 ===="
|
echo "=== Compiling TypeScript ===="
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||||
@@ -247,22 +223,6 @@ WRAPPER
|
|||||||
mkdir -p $out/lib/gmw-discord-gateway
|
mkdir -p $out/lib/gmw-discord-gateway
|
||||||
cp -r dist node_modules package.json tsconfig.json $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
|
# Also include drizzle migrations if they exist
|
||||||
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
|
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
|
||||||
|
|
||||||
@@ -271,7 +231,6 @@ WRAPPER
|
|||||||
#!${pkgs.runtimeShell}
|
#!${pkgs.runtimeShell}
|
||||||
cd $out/lib/gmw-discord-gateway
|
cd $out/lib/gmw-discord-gateway
|
||||||
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
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
|
exec ${nodejs}/bin/node dist/index.js
|
||||||
WRAPPER
|
WRAPPER
|
||||||
chmod +x $out/bin/gmw-discord-gateway
|
chmod +x $out/bin/gmw-discord-gateway
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
build/
|
|
||||||
package-lock.json
|
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
// libdatachannel-min — minimal N-API binding to libdatachannel.
|
|
||||||
// Exposes ONLY what GMW GoLive needs:
|
|
||||||
// PeerConnection (offer/answer, ICE, SDP), DataChannel (signaling),
|
|
||||||
// Track send (added in media phase).
|
|
||||||
// Built against libdatachannel 0.24.0 (built from source in /tmp/ldc-build).
|
|
||||||
|
|
||||||
#include <napi.h>
|
|
||||||
#include <rtc/rtc.hpp>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
#include <variant>
|
|
||||||
|
|
||||||
using namespace Napi;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
std::string stateToString(rtc::PeerConnection::State s) {
|
|
||||||
switch (s) {
|
|
||||||
case rtc::PeerConnection::State::New: return "new";
|
|
||||||
case rtc::PeerConnection::State::Connecting: return "connecting";
|
|
||||||
case rtc::PeerConnection::State::Connected: return "connected";
|
|
||||||
case rtc::PeerConnection::State::Disconnected: return "disconnected";
|
|
||||||
case rtc::PeerConnection::State::Failed: return "failed";
|
|
||||||
case rtc::PeerConnection::State::Closed: return "closed";
|
|
||||||
default: return "unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string binaryToString(const rtc::binary& data) {
|
|
||||||
// rtc::binary is std::vector<std::byte> in libdatachannel >= 0.21
|
|
||||||
std::string msg(data.size(), '\0');
|
|
||||||
for (size_t i = 0; i < data.size(); i++) {
|
|
||||||
msg[i] = static_cast<char>(data[i]);
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Holds a Napi::Promise::Deferred so it can be moved into TSFN lambdas
|
|
||||||
// without invalid copies (node-addon-api 8.x Deferred is not movable).
|
|
||||||
struct DeferredHolder {
|
|
||||||
Promise::Deferred deferred;
|
|
||||||
explicit DeferredHolder(Promise::Deferred d) : deferred(d) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
class DataChannelWrap : public Napi::ObjectWrap<DataChannelWrap> {
|
|
||||||
public:
|
|
||||||
static Function Init(Napi::Env env) {
|
|
||||||
Function func = DefineClass(env, "DataChannel", {
|
|
||||||
InstanceMethod("send", &DataChannelWrap::Send),
|
|
||||||
InstanceMethod("isOpen", &DataChannelWrap::IsOpen),
|
|
||||||
InstanceMethod("close", &DataChannelWrap::Close),
|
|
||||||
InstanceMethod("onMessage", &DataChannelWrap::OnMessage),
|
|
||||||
InstanceMethod("onOpen", &DataChannelWrap::OnOpen),
|
|
||||||
});
|
|
||||||
dcConstructor = Napi::Persistent(func);
|
|
||||||
return func;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a JS wrapper (calls the JS constructor, returns instance).
|
|
||||||
static Object NewInstance(Napi::Env env) {
|
|
||||||
return dcConstructor.New({});
|
|
||||||
}
|
|
||||||
|
|
||||||
DataChannelWrap(const Napi::CallbackInfo& info)
|
|
||||||
: Napi::ObjectWrap<DataChannelWrap>(info) {}
|
|
||||||
|
|
||||||
void Init(std::shared_ptr<rtc::DataChannel> dc) {
|
|
||||||
dc_ = dc;
|
|
||||||
dc_->onMessage([this](rtc::message_variant data) {
|
|
||||||
std::string msg;
|
|
||||||
if (std::holds_alternative<rtc::binary>(data)) {
|
|
||||||
msg = binaryToString(std::get<rtc::binary>(data));
|
|
||||||
} else {
|
|
||||||
msg = std::get<std::string>(data);
|
|
||||||
}
|
|
||||||
if (msgCb_) {
|
|
||||||
msgCb_->BlockingCall([msg](Napi::Env env, Function cb) {
|
|
||||||
cb.Call({String::New(env, msg)});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
dc_->onOpen([this]() {
|
|
||||||
if (openCb_) {
|
|
||||||
openCb_->BlockingCall([](Napi::Env env, Function cb) {
|
|
||||||
cb.Call({});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
static FunctionReference dcConstructor;
|
|
||||||
std::shared_ptr<rtc::DataChannel> dc_;
|
|
||||||
std::shared_ptr<ThreadSafeFunction> msgCb_;
|
|
||||||
std::shared_ptr<ThreadSafeFunction> openCb_;
|
|
||||||
|
|
||||||
void Send(const Napi::CallbackInfo& info) {
|
|
||||||
std::string msg = info[0].As<String>().Utf8Value();
|
|
||||||
if (dc_) dc_->send(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
Napi::Value IsOpen(const Napi::CallbackInfo& info) {
|
|
||||||
bool open = dc_ && dc_->isOpen();
|
|
||||||
return Boolean::New(info.Env(), open);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Close(const Napi::CallbackInfo& info) {
|
|
||||||
if (dc_) dc_->close();
|
|
||||||
}
|
|
||||||
|
|
||||||
void OnMessage(const Napi::CallbackInfo& info) {
|
|
||||||
Function cb = info[0].As<Function>();
|
|
||||||
msgCb_ = std::make_shared<ThreadSafeFunction>(
|
|
||||||
ThreadSafeFunction::New(info.Env(), cb, "dc-message", 0, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
void OnOpen(const Napi::CallbackInfo& info) {
|
|
||||||
Function cb = info[0].As<Function>();
|
|
||||||
openCb_ = std::make_shared<ThreadSafeFunction>(
|
|
||||||
ThreadSafeFunction::New(info.Env(), cb, "dc-open", 0, 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class TrackWrap : public Napi::ObjectWrap<TrackWrap> {
|
|
||||||
public:
|
|
||||||
static Function Init(Napi::Env env) {
|
|
||||||
Function func = DefineClass(env, "Track", {
|
|
||||||
InstanceMethod("send", &TrackWrap::Send),
|
|
||||||
InstanceMethod("isOpen", &TrackWrap::IsOpen),
|
|
||||||
InstanceMethod("close", &TrackWrap::Close),
|
|
||||||
InstanceMethod("setPacketizer", &TrackWrap::SetPacketizer),
|
|
||||||
InstanceMethod("sendFrame", &TrackWrap::SendFrame),
|
|
||||||
InstanceMethod("addTimestamp", &TrackWrap::AddTimestamp),
|
|
||||||
});
|
|
||||||
trackConstructor = Napi::Persistent(func);
|
|
||||||
return func;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Object NewInstance(Napi::Env env) {
|
|
||||||
return trackConstructor.New({});
|
|
||||||
}
|
|
||||||
|
|
||||||
TrackWrap(const Napi::CallbackInfo& info)
|
|
||||||
: Napi::ObjectWrap<TrackWrap>(info) {}
|
|
||||||
|
|
||||||
void Init(std::shared_ptr<rtc::Track> track, Napi::Env env) {
|
|
||||||
track_ = track;
|
|
||||||
(void)env;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
static FunctionReference trackConstructor;
|
|
||||||
std::shared_ptr<rtc::Track> track_;
|
|
||||||
std::shared_ptr<rtc::RtpPacketizationConfig> rtpConfig_;
|
|
||||||
|
|
||||||
void Send(const Napi::CallbackInfo& info) {
|
|
||||||
Buffer<uint8_t> buf = info[0].As<Buffer<uint8_t>>();
|
|
||||||
if (!track_) return;
|
|
||||||
rtc::binary data(buf.Length());
|
|
||||||
for (size_t i = 0; i < buf.Length(); i++) data[i] = (std::byte)buf[i];
|
|
||||||
try {
|
|
||||||
track_->send(data);
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
fprintf(stderr, "[binding] track.send THREW: %s\n", e.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setPacketizer(kind, ssrc, payloadType, clockRate, playoutDelayId,
|
|
||||||
// playoutDelayMin, playoutDelayMax)
|
|
||||||
// kind: "audio" | "h264" | "h265" | "av1"
|
|
||||||
// Builds the media-handler chain (packetizer → RTCP SR → NACK → pacing for
|
|
||||||
// video) exactly like @dank074's WebRtcWrapper does via node-datachannel.
|
|
||||||
void SetPacketizer(const Napi::CallbackInfo& info) {
|
|
||||||
Napi::Env env = info.Env();
|
|
||||||
if (!track_) throw Error::New(env, "track closed");
|
|
||||||
std::string kind = info[0].As<String>().Utf8Value();
|
|
||||||
uint32_t ssrc = info[1].As<Number>().Uint32Value();
|
|
||||||
uint8_t pt = (uint8_t)info[2].As<Number>().Uint32Value();
|
|
||||||
uint32_t clockRate = info[3].As<Number>().Uint32Value();
|
|
||||||
uint8_t playoutDelayId = (uint8_t)info[4].As<Number>().Uint32Value();
|
|
||||||
uint16_t playoutDelayMin = (uint16_t)info[5].As<Number>().Uint32Value();
|
|
||||||
uint16_t playoutDelayMax = (uint16_t)info[6].As<Number>().Uint32Value();
|
|
||||||
try {
|
|
||||||
auto cfg = std::make_shared<rtc::RtpPacketizationConfig>(
|
|
||||||
ssrc, "", pt, clockRate);
|
|
||||||
cfg->playoutDelayId = playoutDelayId;
|
|
||||||
cfg->playoutDelayMin = playoutDelayMin;
|
|
||||||
cfg->playoutDelayMax = playoutDelayMax;
|
|
||||||
std::shared_ptr<rtc::MediaHandler> handler;
|
|
||||||
if (kind == "audio") {
|
|
||||||
handler = std::make_shared<rtc::OpusRtpPacketizer>(cfg);
|
|
||||||
} else if (kind == "h264") {
|
|
||||||
handler = std::make_shared<rtc::H264RtpPacketizer>(
|
|
||||||
rtc::NalUnit::Separator::StartSequence, cfg);
|
|
||||||
} else if (kind == "h265") {
|
|
||||||
handler = std::make_shared<rtc::H265RtpPacketizer>(
|
|
||||||
rtc::NalUnit::Separator::StartSequence, cfg);
|
|
||||||
} else if (kind == "av1") {
|
|
||||||
handler = std::make_shared<rtc::AV1RtpPacketizer>(
|
|
||||||
rtc::AV1RtpPacketizer::Packetization::Obu, cfg);
|
|
||||||
} else {
|
|
||||||
throw std::runtime_error("unknown packetizer kind: " + kind);
|
|
||||||
}
|
|
||||||
handler->addToChain(std::make_shared<rtc::RtcpSrReporter>(cfg));
|
|
||||||
handler->addToChain(std::make_shared<rtc::RtcpNackResponder>());
|
|
||||||
if (kind != "audio") {
|
|
||||||
handler->addToChain(std::make_shared<rtc::PacingHandler>(
|
|
||||||
25.0 * 1000 * 1000, std::chrono::milliseconds(1)));
|
|
||||||
}
|
|
||||||
track_->setMediaHandler(handler);
|
|
||||||
rtpConfig_ = cfg;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
fprintf(stderr, "[binding] setPacketizer THREW: %s\n", e.what());
|
|
||||||
throw Error::New(env, e.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendFrame(buffer) — sends an ENCODED frame (AnnexB H264 / raw opus /
|
|
||||||
// OBU AV1). The media-handler chain packetizes it into RTP.
|
|
||||||
void SendFrame(const Napi::CallbackInfo& info) {
|
|
||||||
Buffer<uint8_t> buf = info[0].As<Buffer<uint8_t>>();
|
|
||||||
if (!track_) return;
|
|
||||||
rtc::binary data(buf.Length());
|
|
||||||
for (size_t i = 0; i < buf.Length(); i++) data[i] = (std::byte)buf[i];
|
|
||||||
try {
|
|
||||||
track_->send(data);
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
fprintf(stderr, "[binding] track.sendFrame THREW: %s\n", e.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addTimestamp(delta) — advances the packetizer RTP timestamp by delta
|
|
||||||
// (clock-rate units). Called by JS after each frame, matching the
|
|
||||||
// node-datachannel contract (WebRtcWrapper does the same increment).
|
|
||||||
void AddTimestamp(const Napi::CallbackInfo& info) {
|
|
||||||
uint32_t delta = info[0].As<Number>().Uint32Value();
|
|
||||||
if (rtpConfig_) rtpConfig_->timestamp += delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
Napi::Value IsOpen(const Napi::CallbackInfo& info) {
|
|
||||||
bool open = track_ && track_->isOpen();
|
|
||||||
return Boolean::New(info.Env(), open);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Close(const Napi::CallbackInfo& info) {
|
|
||||||
if (track_) track_->close();
|
|
||||||
}
|
|
||||||
|
|
||||||
void OnStateChange(const Napi::CallbackInfo& info) {
|
|
||||||
// libdatachannel Track has no state-change callback; kept for API parity.
|
|
||||||
(void)info;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
class PeerConnectionWrap : public Napi::ObjectWrap<PeerConnectionWrap> {
|
|
||||||
public:
|
|
||||||
static Function Init(Napi::Env env) {
|
|
||||||
Function func = DefineClass(env, "PeerConnection", {
|
|
||||||
InstanceMethod("state", &PeerConnectionWrap::State),
|
|
||||||
InstanceMethod("createOffer", &PeerConnectionWrap::CreateOffer),
|
|
||||||
InstanceMethod("createAnswer", &PeerConnectionWrap::CreateAnswer),
|
|
||||||
InstanceMethod("setRemoteDescription",
|
|
||||||
&PeerConnectionWrap::SetRemoteDescription),
|
|
||||||
InstanceMethod("close", &PeerConnectionWrap::Close),
|
|
||||||
InstanceMethod("onStateChange", &PeerConnectionWrap::OnStateChange),
|
|
||||||
InstanceMethod("createDataChannel", &PeerConnectionWrap::CreateDataChannel),
|
|
||||||
InstanceMethod("onDataChannel", &PeerConnectionWrap::OnDataChannel),
|
|
||||||
InstanceMethod("addTrack", &PeerConnectionWrap::AddTrack),
|
|
||||||
});
|
|
||||||
return func;
|
|
||||||
}
|
|
||||||
|
|
||||||
PeerConnectionWrap(const Napi::CallbackInfo& info)
|
|
||||||
: Napi::ObjectWrap<PeerConnectionWrap>(info) {
|
|
||||||
Napi::Env env = info.Env();
|
|
||||||
if (!info[0].IsObject()) {
|
|
||||||
throw TypeError::New(env, "config object required");
|
|
||||||
}
|
|
||||||
Object config = info[0].As<Object>();
|
|
||||||
rtc::Configuration rtcConfig;
|
|
||||||
if (config.Has("iceServers")) {
|
|
||||||
Array servers = config.Get("iceServers").As<Array>();
|
|
||||||
for (uint32_t i = 0; i < servers.Length(); i++) {
|
|
||||||
std::string url = servers.Get(i).As<String>().Utf8Value();
|
|
||||||
rtcConfig.iceServers.emplace_back(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pc_ = std::make_shared<rtc::PeerConnection>(rtcConfig);
|
|
||||||
|
|
||||||
// IMPORTANT: register description/gathering callbacks HERE (constructor),
|
|
||||||
// BEFORE any createDataChannel call. libdatachannel only fires
|
|
||||||
// onLocalDescription for negotiations that start AFTER the callback is
|
|
||||||
// registered — if createDataChannel runs first, the offer callback never
|
|
||||||
// fires (verified in C++ spike: test3 vs test2).
|
|
||||||
pc_->onLocalDescription([this](rtc::Description desc) {
|
|
||||||
latestLocalDesc_ = std::string(desc);
|
|
||||||
fprintf(stderr, "[binding] trickle desc, %zu bytes\n",
|
|
||||||
latestLocalDesc_.size());
|
|
||||||
});
|
|
||||||
pc_->onGatheringStateChange([this](rtc::PeerConnection::GatheringState gs) {
|
|
||||||
fprintf(stderr, "[binding] gathering state: %d\n", (int)gs);
|
|
||||||
if (gs == rtc::PeerConnection::GatheringState::Complete) {
|
|
||||||
// Use the getter — it returns the FULL SDP including candidates after
|
|
||||||
// gathering (trickle callbacks only carry the initial fragment).
|
|
||||||
auto ld = pc_->localDescription();
|
|
||||||
if (ld) {
|
|
||||||
latestLocalDesc_ = std::string(*ld);
|
|
||||||
fprintf(stderr, "[binding] final desc, %zu bytes\n",
|
|
||||||
latestLocalDesc_.size());
|
|
||||||
}
|
|
||||||
resolvePendingLocalDesc_();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::shared_ptr<rtc::PeerConnection> pc_;
|
|
||||||
std::shared_ptr<ThreadSafeFunction> stateCb_;
|
|
||||||
std::shared_ptr<ThreadSafeFunction> dcCb_;
|
|
||||||
std::string latestLocalDesc_;
|
|
||||||
std::shared_ptr<DeferredHolder> pendingDescDeferred_;
|
|
||||||
std::shared_ptr<ThreadSafeFunction> pendingDescTsfn_;
|
|
||||||
|
|
||||||
void resolvePendingLocalDesc_() {
|
|
||||||
if (!pendingDescDeferred_ || !pendingDescTsfn_) return;
|
|
||||||
auto holder = pendingDescDeferred_;
|
|
||||||
auto tsfn = pendingDescTsfn_;
|
|
||||||
pendingDescDeferred_.reset();
|
|
||||||
pendingDescTsfn_.reset();
|
|
||||||
std::string sdp = latestLocalDesc_;
|
|
||||||
tsfn->BlockingCall([sdp, holder](Napi::Env e, Function) {
|
|
||||||
holder->deferred.Resolve(String::New(e, sdp));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Napi::Value State(const Napi::CallbackInfo& info) {
|
|
||||||
return String::New(info.Env(),
|
|
||||||
pc_ ? stateToString(pc_->state()) : "closed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// createOffer() -> Promise<string> — sets local description, waits for
|
|
||||||
// ICE gathering to complete (so candidates are in the SDP), resolves SDP.
|
|
||||||
Napi::Value CreateOffer(const Napi::CallbackInfo& info) {
|
|
||||||
Napi::Env env = info.Env();
|
|
||||||
auto holder = std::make_shared<DeferredHolder>(Promise::Deferred::New(env));
|
|
||||||
if (!pc_) {
|
|
||||||
holder->deferred.Reject(Error::New(env, "peer closed").Value());
|
|
||||||
return holder->deferred.Promise();
|
|
||||||
}
|
|
||||||
// createDataChannel already triggers negotiation in libdatachannel 0.24 —
|
|
||||||
// if gathering already completed, resolve immediately from the cached SDP.
|
|
||||||
if (!latestLocalDesc_.empty()) {
|
|
||||||
auto tsfn = std::make_shared<ThreadSafeFunction>(ThreadSafeFunction::New(
|
|
||||||
env, Function::New(env, [](const CallbackInfo&) {}), "desc", 0, 1));
|
|
||||||
std::string sdp = latestLocalDesc_;
|
|
||||||
tsfn->BlockingCall([sdp, holder](Napi::Env e, Function) {
|
|
||||||
holder->deferred.Resolve(String::New(e, sdp));
|
|
||||||
});
|
|
||||||
return holder->deferred.Promise();
|
|
||||||
}
|
|
||||||
if (pendingDescDeferred_) {
|
|
||||||
pendingDescDeferred_->deferred.Reject(
|
|
||||||
Error::New(env, "previous negotiation still pending").Value());
|
|
||||||
}
|
|
||||||
pendingDescDeferred_ = holder;
|
|
||||||
pendingDescTsfn_ = std::make_shared<ThreadSafeFunction>(
|
|
||||||
ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}),
|
|
||||||
"desc", 0, 1));
|
|
||||||
fprintf(stderr, "[binding] calling setLocalDescription(Offer)\n");
|
|
||||||
try {
|
|
||||||
pc_->setLocalDescription(rtc::Description::Type::Offer);
|
|
||||||
fprintf(stderr, "[binding] setLocalDescription returned OK\n");
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
pendingDescDeferred_.reset();
|
|
||||||
fprintf(stderr, "[binding] setLocalDescription THREW: %s\n", e.what());
|
|
||||||
throw Error::New(env, e.what());
|
|
||||||
}
|
|
||||||
return holder->deferred.Promise();
|
|
||||||
}
|
|
||||||
|
|
||||||
// createAnswer(offerSdp: string) -> Promise<string>
|
|
||||||
Napi::Value CreateAnswer(const Napi::CallbackInfo& info) {
|
|
||||||
Napi::Env env = info.Env();
|
|
||||||
std::string offer = info[0].As<String>().Utf8Value();
|
|
||||||
auto holder = std::make_shared<DeferredHolder>(Promise::Deferred::New(env));
|
|
||||||
if (!pc_) {
|
|
||||||
holder->deferred.Reject(Error::New(env, "peer closed").Value());
|
|
||||||
return holder->deferred.Promise();
|
|
||||||
}
|
|
||||||
if (pendingDescDeferred_) {
|
|
||||||
pendingDescDeferred_->deferred.Reject(
|
|
||||||
Error::New(env, "previous negotiation still pending").Value());
|
|
||||||
}
|
|
||||||
pendingDescDeferred_ = holder;
|
|
||||||
pendingDescTsfn_ = std::make_shared<ThreadSafeFunction>(
|
|
||||||
ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}),
|
|
||||||
"desc", 0, 1));
|
|
||||||
try {
|
|
||||||
pc_->setRemoteDescription(
|
|
||||||
rtc::Description(offer, rtc::Description::Type::Offer));
|
|
||||||
fprintf(stderr, "[binding] answer: setRemoteDescription OK\n");
|
|
||||||
// libdatachannel 0.24 AUTO-GENERATES the answer when a remote offer is
|
|
||||||
// applied (verified in C++ spike test8/9: B desc type=Answer fires
|
|
||||||
// immediately with a=setup:active). Calling setLocalDescription() again
|
|
||||||
// would OVERWRITE it with a role=actpass SDP, which A rejects with
|
|
||||||
// "Illegal role actpass in remote answer description". So we do NOT call
|
|
||||||
// setLocalDescription here — we just wait for gathering complete and
|
|
||||||
// resolve with the auto-generated answer. This also matches @dank074's
|
|
||||||
// Discord voice flow.
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
pendingDescDeferred_.reset();
|
|
||||||
fprintf(stderr, "[binding] answer THREW: %s\n", e.what());
|
|
||||||
holder->deferred.Reject(Error::New(env, e.what()).Value());
|
|
||||||
}
|
|
||||||
return holder->deferred.Promise();
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetRemoteDescription(const Napi::CallbackInfo& info) {
|
|
||||||
std::string sdp = info[0].As<String>().Utf8Value();
|
|
||||||
std::string type = info[1].As<String>().Utf8Value();
|
|
||||||
rtc::Description::Type t = (type == "answer")
|
|
||||||
? rtc::Description::Type::Answer
|
|
||||||
: rtc::Description::Type::Offer;
|
|
||||||
if (pc_) pc_->setRemoteDescription(rtc::Description(sdp, t));
|
|
||||||
}
|
|
||||||
|
|
||||||
void Close(const Napi::CallbackInfo& info) {
|
|
||||||
if (pc_) pc_->close();
|
|
||||||
}
|
|
||||||
|
|
||||||
void OnStateChange(const Napi::CallbackInfo& info) {
|
|
||||||
Function cb = info[0].As<Function>();
|
|
||||||
stateCb_ = std::make_shared<ThreadSafeFunction>(
|
|
||||||
ThreadSafeFunction::New(info.Env(), cb, "pc-state", 0, 1));
|
|
||||||
std::shared_ptr<rtc::PeerConnection> pc = pc_;
|
|
||||||
pc->onStateChange([this](rtc::PeerConnection::State state) {
|
|
||||||
if (stateCb_) {
|
|
||||||
std::string s = stateToString(state);
|
|
||||||
stateCb_->BlockingCall([s](Napi::Env env, Function cb) {
|
|
||||||
cb.Call({String::New(env, s)});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Napi::Value CreateDataChannel(const Napi::CallbackInfo& info) {
|
|
||||||
Napi::Env env = info.Env();
|
|
||||||
std::string label = info[0].As<String>().Utf8Value();
|
|
||||||
fprintf(stderr, "[binding] createDataChannel(%s)\n", label.c_str());
|
|
||||||
auto dc = pc_->createDataChannel(label);
|
|
||||||
Object obj = DataChannelWrap::NewInstance(env);
|
|
||||||
DataChannelWrap::Unwrap(obj)->Init(dc);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
Napi::Value AddTrack(const Napi::CallbackInfo& info) {
|
|
||||||
Napi::Env env = info.Env();
|
|
||||||
std::string mid = info[0].As<String>().Utf8Value();
|
|
||||||
std::string kind = info[1].As<String>().Utf8Value();
|
|
||||||
if (!pc_) throw Error::New(env, "peer closed");
|
|
||||||
fprintf(stderr, "[binding] addTrack(%s, %s) start\n", mid.c_str(), kind.c_str());
|
|
||||||
try {
|
|
||||||
std::shared_ptr<rtc::Track> track;
|
|
||||||
if (kind == "audio") {
|
|
||||||
// Opus payload type 120 (matches @dank074 CodecPayloadType.opus)
|
|
||||||
auto desc = rtc::Description::Audio(mid);
|
|
||||||
desc.addOpusCodec(120);
|
|
||||||
track = pc_->addTrack(desc);
|
|
||||||
} else {
|
|
||||||
// All video codecs with their payload types, matching WebRtcWrapper:
|
|
||||||
// H264 101/102, H265 103/104, VP8 105/106, VP9 107/108, AV1 109/110
|
|
||||||
auto desc = rtc::Description::Video(mid);
|
|
||||||
desc.addH264Codec(101);
|
|
||||||
desc.addRtxCodec(102, 101, 90000);
|
|
||||||
desc.addH265Codec(103);
|
|
||||||
desc.addRtxCodec(104, 103, 90000);
|
|
||||||
desc.addVP8Codec(105);
|
|
||||||
desc.addRtxCodec(106, 105, 90000);
|
|
||||||
desc.addVP9Codec(107);
|
|
||||||
desc.addRtxCodec(108, 107, 90000);
|
|
||||||
desc.addAV1Codec(109);
|
|
||||||
desc.addRtxCodec(110, 109, 90000);
|
|
||||||
track = pc_->addTrack(desc);
|
|
||||||
}
|
|
||||||
Object obj = TrackWrap::NewInstance(env);
|
|
||||||
TrackWrap::Unwrap(obj)->Init(track, env);
|
|
||||||
return obj;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
fprintf(stderr, "[binding] addTrack THREW: %s\n", e.what());
|
|
||||||
throw Error::New(env, e.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void OnDataChannel(const Napi::CallbackInfo& info) {
|
|
||||||
Function cb = info[0].As<Function>();
|
|
||||||
dcCb_ = std::make_shared<ThreadSafeFunction>(
|
|
||||||
ThreadSafeFunction::New(info.Env(), cb, "dc", 0, 1));
|
|
||||||
std::shared_ptr<rtc::PeerConnection> pc = pc_;
|
|
||||||
pc->onDataChannel([this](std::shared_ptr<rtc::DataChannel> dc) {
|
|
||||||
if (dcCb_) {
|
|
||||||
auto dcPtr = dc;
|
|
||||||
dcCb_->BlockingCall([dcPtr](Napi::Env env, Function cb) {
|
|
||||||
Object obj = DataChannelWrap::NewInstance(env);
|
|
||||||
DataChannelWrap::Unwrap(obj)->Init(dcPtr);
|
|
||||||
cb.Call({obj});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Object InitAll(Napi::Env env, Object exports) {
|
|
||||||
exports.Set("PeerConnection", PeerConnectionWrap::Init(env));
|
|
||||||
exports.Set("DataChannel", DataChannelWrap::Init(env));
|
|
||||||
exports.Set("Track", TrackWrap::Init(env));
|
|
||||||
return exports;
|
|
||||||
}
|
|
||||||
|
|
||||||
NODE_API_MODULE(libdatachannel_min, InitAll)
|
|
||||||
|
|
||||||
// Definition for the static constructor references.
|
|
||||||
FunctionReference DataChannelWrap::dcConstructor;
|
|
||||||
FunctionReference TrackWrap::trackConstructor;
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"target_name": "libdatachannel_min",
|
|
||||||
"sources": ["binding.cpp"],
|
|
||||||
"include_dirs": [
|
|
||||||
"<!(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": [
|
|
||||||
"<!(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"],
|
|
||||||
"defines": ["NAPI_CPP_EXCEPTIONS"],
|
|
||||||
"conditions": [
|
|
||||||
["OS=='linux'", { "cflags": ["-fvisibility=hidden"] }]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// libdatachannel-min — JS entry.
|
|
||||||
const native = require("./build/Release/datachannel_min.node");
|
|
||||||
module.exports = native;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "libdatachannel-min",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Minimal N-API binding to libdatachannel — PeerConnection, DataChannel, ICE, SDP (+ media tracks for GoLive)",
|
|
||||||
"main": "index.js",
|
|
||||||
"gypfile": true,
|
|
||||||
"scripts": {
|
|
||||||
"build": "node-gyp rebuild",
|
|
||||||
"test": "node test-handshake.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"node-addon-api": "^8.3.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"node-gyp": "^11.5.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// Phase 0 spike: prove the minimal binding can do a full WebRTC handshake
|
|
||||||
// (offer/answer + ICE + DataChannel) between two local PeerConnections.
|
|
||||||
"use strict";
|
|
||||||
const { PeerConnection } = require("./build/Release/datachannel_min.node");
|
|
||||||
|
|
||||||
function log(...args) {
|
|
||||||
console.log("[spike]", ...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const pcA = new PeerConnection({ iceServers: [] });
|
|
||||||
const pcB = new PeerConnection({ iceServers: [] });
|
|
||||||
|
|
||||||
const stateLog = [];
|
|
||||||
pcA.onStateChange((s) => {
|
|
||||||
stateLog.push(`A:${s}`);
|
|
||||||
log("A state:", s);
|
|
||||||
});
|
|
||||||
pcB.onStateChange((s) => {
|
|
||||||
stateLog.push(`B:${s}`);
|
|
||||||
log("B state:", s);
|
|
||||||
});
|
|
||||||
|
|
||||||
// B waits for incoming DataChannel
|
|
||||||
const received = new Promise((resolve) => {
|
|
||||||
pcB.onDataChannel((dc) => {
|
|
||||||
log("B got incoming DataChannel");
|
|
||||||
dc.onOpen(() => log("B DataChannel open"));
|
|
||||||
dc.onMessage((msg) => {
|
|
||||||
log("B received message:", msg);
|
|
||||||
dc.send("pong from B");
|
|
||||||
resolve(msg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A creates an outgoing DataChannel
|
|
||||||
const dcA = pcA.createDataChannel("test");
|
|
||||||
dcA.onOpen(() => {
|
|
||||||
log("A DataChannel open — sending hello");
|
|
||||||
dcA.send("hello from A");
|
|
||||||
});
|
|
||||||
dcA.onMessage((msg) => {
|
|
||||||
log("A received reply:", msg);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Offer/answer dance
|
|
||||||
log("A createOffer...");
|
|
||||||
const offer = await pcA.createOffer();
|
|
||||||
log("Offer SDP bytes:", offer.length);
|
|
||||||
log("B createAnswer...");
|
|
||||||
const answer = await pcB.createAnswer(offer);
|
|
||||||
log("Answer SDP bytes:", answer.length);
|
|
||||||
const setupMatch = answer.match(/a=setup:(\S+)/);
|
|
||||||
log("Answer setup role:", setupMatch ? setupMatch[1] : "NONE");
|
|
||||||
pcA.setRemoteDescription(answer, "answer");
|
|
||||||
|
|
||||||
// Wait for message roundtrip
|
|
||||||
const msg = await Promise.race([
|
|
||||||
received,
|
|
||||||
new Promise((_, rej) => setTimeout(() => rej(new Error("TIMEOUT waiting for datachannel message")), 15000)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
log("ROUNDTRIP OK — B got:", msg);
|
|
||||||
log("States:", stateLog.join(" | "));
|
|
||||||
|
|
||||||
const aState = pcA.state();
|
|
||||||
const bState = pcB.state();
|
|
||||||
log("Final states — A:", aState, "B:", bState);
|
|
||||||
|
|
||||||
pcA.close();
|
|
||||||
pcB.close();
|
|
||||||
|
|
||||||
if (msg !== "hello from A") throw new Error("wrong message");
|
|
||||||
if (aState !== "connected" && aState !== "disconnected") throw new Error("A not connected: " + aState);
|
|
||||||
log("SPIKE PASSED ✅");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error("SPIKE FAILED:", e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// Verify setPacketizer + sendFrame: two peers connect, audio+video tracks
|
|
||||||
// packetize real encoded frames (opus + AnnexB H264), RTP flows without crash.
|
|
||||||
"use strict";
|
|
||||||
const { PeerConnection } = require("./build/Release/datachannel_min.node");
|
|
||||||
|
|
||||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const pcA = new PeerConnection({ iceServers: [] });
|
|
||||||
const pcB = new PeerConnection({ iceServers: [] });
|
|
||||||
|
|
||||||
const aAudio = pcA.addTrack("0", "audio");
|
|
||||||
const aVideo = pcA.addTrack("1", "video");
|
|
||||||
pcB.addTrack("0", "audio");
|
|
||||||
pcB.addTrack("1", "video");
|
|
||||||
|
|
||||||
let states = { a: "", b: "" };
|
|
||||||
pcA.onStateChange((s) => (states.a = s));
|
|
||||||
pcB.onStateChange((s) => (states.b = s));
|
|
||||||
|
|
||||||
// A: offer (createDataChannel not needed — tracks trigger negotiation)
|
|
||||||
const offer = await pcA.createOffer();
|
|
||||||
pcB.setRemoteDescription(offer, "offer");
|
|
||||||
const answer = await pcB.createAnswer(offer);
|
|
||||||
pcA.setRemoteDescription(answer, "answer");
|
|
||||||
|
|
||||||
// Wait for connected
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
if (states.a === "connected" && states.b === "connected") break;
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
console.log("[pkt] states:", states.a, states.b);
|
|
||||||
if (states.a !== "connected" || states.b !== "connected") {
|
|
||||||
console.log("PKT TEST FAILED: not connected");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup packetizers on A (sender)
|
|
||||||
aAudio.setPacketizer("audio", 1234, 120, 48000, 5, 0, 1);
|
|
||||||
aVideo.setPacketizer("h264", 5678, 101, 90000, 5, 0, 10);
|
|
||||||
|
|
||||||
// Fake opus frame (20ms @48kHz stereo — payload can be any bytes)
|
|
||||||
const opusFrame = Buffer.alloc(160);
|
|
||||||
for (let i = 0; i < 160; i++) opusFrame[i] = i & 0xff;
|
|
||||||
|
|
||||||
// Fake AnnexB H264 frame: SPS + PPS + IDR slice
|
|
||||||
const sps = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x01, 0x40, 0x7e]);
|
|
||||||
const pps = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x3c, 0x80]);
|
|
||||||
const idr = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
|
|
||||||
const h264Frame = Buffer.concat([sps, pps, idr]);
|
|
||||||
|
|
||||||
// Send 10 audio frames (20ms each) + 3 video frames (33ms each)
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
aAudio.sendFrame(opusFrame);
|
|
||||||
aAudio.addTimestamp(960); // 20ms @ 48kHz
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
aVideo.sendFrame(h264Frame);
|
|
||||||
aVideo.addTimestamp(3000); // 33ms @ 90kHz
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
console.log("[pkt] after send: states:", states.a, states.b);
|
|
||||||
console.log("[pkt] audio track open:", aAudio.isOpen(), "| video track open:", aVideo.isOpen());
|
|
||||||
const ok = states.a === "connected" && aAudio.isOpen() && aVideo.isOpen();
|
|
||||||
console.log(ok ? "PKT TEST PASSED" : "PKT TEST FAILED");
|
|
||||||
pcA.close();
|
|
||||||
pcB.close();
|
|
||||||
process.exit(ok ? 0 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error("[pkt] FAILED:", e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
console.error("[pkt] TIMEOUT");
|
|
||||||
process.exit(1);
|
|
||||||
}, 25000);
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// Two-peer RTP capture test: A sends REAL H264 frames through the binding's
|
|
||||||
// packetizer chain to B over localhost. tcpdump (run externally on lo) captures
|
|
||||||
// the RTP; a Python script reassembles AnnexB and ffmpeg decodes it.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// node test-rtp-capture.js <mode> mode = "a" (sender) | "b" (receiver)
|
|
||||||
// Sender writes the negotiated SDP pieces to /tmp/rtp-a.sdp /tmp/rtp-b.sdp
|
|
||||||
// Receiver listens and keeps alive.
|
|
||||||
"use strict";
|
|
||||||
const { PeerConnection } = require("./build/Release/datachannel_min.node");
|
|
||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const mode = process.argv[2] || "a";
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const pc = new PeerConnection({ iceServers: [] });
|
|
||||||
const audio = pc.addTrack("0", "audio");
|
|
||||||
const video = pc.addTrack("1", "video");
|
|
||||||
|
|
||||||
if (mode === "a") {
|
|
||||||
// Sender: generate offer, hand to B via files, get B's answer
|
|
||||||
const offer = await pc.createOffer();
|
|
||||||
fs.writeFileSync("/tmp/rtp-offer.sdp", offer);
|
|
||||||
console.log("[a] offer written", offer.length, "bytes");
|
|
||||||
|
|
||||||
// wait for B to write its answer
|
|
||||||
for (let i = 0; i < 300; i++) {
|
|
||||||
if (fs.existsSync("/tmp/rtp-answer.sdp")) break;
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
const answer = fs.readFileSync("/tmp/rtp-answer.sdp", "utf8");
|
|
||||||
pc.setRemoteDescription(answer, "answer");
|
|
||||||
|
|
||||||
// wait connected
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
if (pc.state() === "connected") break;
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
console.log("[a] state:", pc.state());
|
|
||||||
if (pc.state() !== "connected") {
|
|
||||||
console.log("[a] FAILED not connected");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup packetizer like the gateway does
|
|
||||||
video.setPacketizer("h264", 5678, 101, 90000, 5, 0, 10);
|
|
||||||
|
|
||||||
// Real H264 AnnexB (baseline) — read from file created by ffmpeg
|
|
||||||
const data = fs.readFileSync("/tmp/rtp-input.h264");
|
|
||||||
console.log("[a] input h264 bytes:", data.length);
|
|
||||||
|
|
||||||
// Split into NAL units by start codes, then group into access units
|
|
||||||
// the same way Demuxer does (param sets + one slice per frame).
|
|
||||||
const start3 = Buffer.from([0, 0, 1]);
|
|
||||||
const start4 = Buffer.from([0, 0, 0, 1]);
|
|
||||||
const nals = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < data.length) {
|
|
||||||
let start = -1;
|
|
||||||
let startLen = 0;
|
|
||||||
for (let j = i; j < data.length - 3; j++) {
|
|
||||||
if (data[j] === 0 && data[j + 1] === 0 && data[j + 2] === 1) {
|
|
||||||
start = j;
|
|
||||||
startLen = 3;
|
|
||||||
if (j > 0 && data[j - 1] === 0) {
|
|
||||||
start = j - 1;
|
|
||||||
startLen = 4;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (start === -1) break;
|
|
||||||
if (start > i) {
|
|
||||||
nals.push(data.subarray(i, start));
|
|
||||||
}
|
|
||||||
i = start + startLen;
|
|
||||||
}
|
|
||||||
console.log("[a] NALs:", nals.length);
|
|
||||||
|
|
||||||
// Group: buffer param sets, flush on slice (like Demuxer.flushAccessUnit)
|
|
||||||
let pending = [];
|
|
||||||
let frameCount = 0;
|
|
||||||
const flush = () => {
|
|
||||||
if (pending.length === 0) return;
|
|
||||||
const parts = pending.map((n) => Buffer.concat([start4, n]));
|
|
||||||
const au = Buffer.concat(parts);
|
|
||||||
pending = [];
|
|
||||||
video.sendFrame(au);
|
|
||||||
video.addTimestamp(3000); // 30fps @ 90kHz
|
|
||||||
frameCount++;
|
|
||||||
};
|
|
||||||
for (const n of nals) {
|
|
||||||
const t = n[0] & 0x1f;
|
|
||||||
if (t === 1 || t === 5) {
|
|
||||||
flush(); // previous AU closed by this slice
|
|
||||||
pending.push(n);
|
|
||||||
} else {
|
|
||||||
pending.push(n); // param set / SEI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush();
|
|
||||||
console.log("[a] sent frames:", frameCount);
|
|
||||||
await sleep(3000); // let packets flow
|
|
||||||
console.log("[a] done");
|
|
||||||
pc.close();
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
// Receiver: read offer, answer, keep alive
|
|
||||||
for (let i = 0; i < 300; i++) {
|
|
||||||
if (fs.existsSync("/tmp/rtp-offer.sdp")) break;
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
const offer = fs.readFileSync("/tmp/rtp-offer.sdp", "utf8");
|
|
||||||
pc.setRemoteDescription(offer, "offer");
|
|
||||||
const answer = await pc.createAnswer(offer);
|
|
||||||
fs.writeFileSync("/tmp/rtp-answer.sdp", answer);
|
|
||||||
console.log("[b] answer written");
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
if (pc.state() === "connected") break;
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
console.log("[b] state:", pc.state());
|
|
||||||
await sleep(10000); // hold while sender streams
|
|
||||||
console.log("[b] done");
|
|
||||||
pc.close();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error("FAILED:", e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
console.error("TIMEOUT");
|
|
||||||
process.exit(1);
|
|
||||||
}, 30000);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// Verify addTrack produces SDP with audio+video media sections.
|
|
||||||
"use strict";
|
|
||||||
const { PeerConnection } = require("./build/Release/datachannel_min.node");
|
|
||||||
|
|
||||||
const pc = new PeerConnection({ iceServers: [] });
|
|
||||||
const audioTrack = pc.addTrack("0", "audio");
|
|
||||||
const videoTrack = pc.addTrack("1", "video");
|
|
||||||
|
|
||||||
pc.onStateChange((s) => console.log("[test-track] state:", s));
|
|
||||||
|
|
||||||
pc.createOffer().then((sdp) => {
|
|
||||||
const hasAudio = /^m=audio\s/m.test(sdp);
|
|
||||||
const hasVideo = /^m=video\s/m.test(sdp);
|
|
||||||
const audioPts = sdp.match(/a=rtpmap:(\d+) opus/g) || [];
|
|
||||||
const videoPts = sdp.match(/a=rtpmap:(\d+) H264/g) || [];
|
|
||||||
console.log("[test-track] SDP bytes:", sdp.length);
|
|
||||||
console.log("[test-track] m=audio:", hasAudio, "| m=video:", hasVideo);
|
|
||||||
console.log("[test-track] opus pt:", audioPts, "| H264 pt:", videoPts);
|
|
||||||
console.log("[test-track] audio track send ok:", typeof audioTrack.send === "function");
|
|
||||||
console.log("[test-track] video track send ok:", typeof videoTrack.send === "function");
|
|
||||||
const ok = hasAudio && hasVideo && audioPts.length > 0 && videoPts.length > 0;
|
|
||||||
console.log(ok ? "TRACK TEST PASSED" : "TRACK TEST FAILED");
|
|
||||||
pc.close();
|
|
||||||
process.exit(ok ? 0 : 1);
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error("[test-track] FAILED:", e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
console.error("[test-track] TIMEOUT");
|
|
||||||
process.exit(1);
|
|
||||||
}, 20000);
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
/**
|
|
||||||
* AnnexB bitstream reader/writer (RBSP + emulation prevention) — ported
|
|
||||||
* from @dank074/discord-video-stream AnnexBBitstreamReaderWriter.js.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class AnnexBBitstreamReader {
|
|
||||||
private _buffer: Uint8Array;
|
|
||||||
private _byteOffset = 0;
|
|
||||||
private _bitOffset = 0;
|
|
||||||
|
|
||||||
constructor(buffer: Uint8Array) {
|
|
||||||
this._buffer = buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
readBits(count: number): number {
|
|
||||||
if (count === 0) return 0;
|
|
||||||
let result = 0;
|
|
||||||
while (count > 0) {
|
|
||||||
if (this._byteOffset >= this._buffer.length) {
|
|
||||||
throw new Error("Bad byte offset");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
this._bitOffset === 0 &&
|
|
||||||
this._byteOffset >= 2 &&
|
|
||||||
this._buffer[this._byteOffset - 2] === 0 &&
|
|
||||||
this._buffer[this._byteOffset - 1] === 0 &&
|
|
||||||
this._buffer[this._byteOffset] === 3
|
|
||||||
) {
|
|
||||||
// Skip over emulation prevention
|
|
||||||
this._byteOffset++;
|
|
||||||
}
|
|
||||||
if (this._bitOffset === 0 && count >= 8) {
|
|
||||||
result = (result << 8) | this._buffer[this._byteOffset++];
|
|
||||||
count -= 8;
|
|
||||||
} else {
|
|
||||||
const numBitsToRead = Math.min(count, 8 - this._bitOffset);
|
|
||||||
const mask = (1 << numBitsToRead) - 1;
|
|
||||||
const newBits =
|
|
||||||
(this._buffer[this._byteOffset] >>
|
|
||||||
(8 - this._bitOffset - numBitsToRead)) &
|
|
||||||
mask;
|
|
||||||
result = (result << numBitsToRead) | newBits;
|
|
||||||
count -= numBitsToRead;
|
|
||||||
this._bitOffset += numBitsToRead;
|
|
||||||
if (this._bitOffset === 8) {
|
|
||||||
this._bitOffset = 0;
|
|
||||||
this._byteOffset++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
readUnsigned(bits: number): number {
|
|
||||||
return this.readBits(bits);
|
|
||||||
}
|
|
||||||
|
|
||||||
readSigned(bits: number): number {
|
|
||||||
const unsigned = this.readUnsigned(bits);
|
|
||||||
if (unsigned & (1 << (bits - 1))) return unsigned - (1 << bits);
|
|
||||||
return unsigned;
|
|
||||||
}
|
|
||||||
|
|
||||||
readUnsignedExpGolomb(): number {
|
|
||||||
let leading0 = 0;
|
|
||||||
while (this.readBits(1) === 0) leading0++;
|
|
||||||
return (1 << leading0) + this.readBits(leading0) - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
readSignedExpGolomb(): number {
|
|
||||||
const unsigned = this.readUnsignedExpGolomb();
|
|
||||||
if (unsigned % 2 === 0) return unsigned / -2;
|
|
||||||
return (unsigned + 1) / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AnnexBBitstreamWriter {
|
|
||||||
private _arr: number[] = [];
|
|
||||||
private _pendingByte = 0;
|
|
||||||
private _bitOffset = 0;
|
|
||||||
|
|
||||||
toBuffer(): Buffer {
|
|
||||||
return Buffer.from(this._arr);
|
|
||||||
}
|
|
||||||
|
|
||||||
flush(): void {
|
|
||||||
// Emulation prevention: insert 0x03 before 00 00
|
|
||||||
if (
|
|
||||||
this._pendingByte <= 3 &&
|
|
||||||
this._arr[this._arr.length - 1] === 0 &&
|
|
||||||
this._arr[this._arr.length - 2] === 0
|
|
||||||
) {
|
|
||||||
this._arr.push(3);
|
|
||||||
}
|
|
||||||
this._arr.push(this._pendingByte);
|
|
||||||
this._pendingByte = 0;
|
|
||||||
this._bitOffset = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeBits(bits: number, count: number): void {
|
|
||||||
while (count > 0) {
|
|
||||||
if (this._bitOffset === 0) {
|
|
||||||
if (count >= 8) {
|
|
||||||
this._pendingByte = (bits >> (count - 8)) & 0xff;
|
|
||||||
count -= 8;
|
|
||||||
this.flush();
|
|
||||||
} else {
|
|
||||||
const mask = (1 << count) - 1;
|
|
||||||
this._pendingByte |= (bits & mask) << (8 - count);
|
|
||||||
this._bitOffset = count;
|
|
||||||
count = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const numBitsToWrite = Math.min(8 - this._bitOffset, count);
|
|
||||||
const bitsToWrite =
|
|
||||||
(bits >> (count - numBitsToWrite)) & ((1 << numBitsToWrite) - 1);
|
|
||||||
this._pendingByte |=
|
|
||||||
bitsToWrite << (8 - this._bitOffset - numBitsToWrite);
|
|
||||||
count -= numBitsToWrite;
|
|
||||||
this._bitOffset += numBitsToWrite;
|
|
||||||
if (this._bitOffset === 8) {
|
|
||||||
this._bitOffset = 0;
|
|
||||||
this.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeUnsigned(num: number, count: number): void {
|
|
||||||
if (num < 0) throw new Error("Expected a non-negative number");
|
|
||||||
this.writeBits(num, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeSigned(num: number, count: number): void {
|
|
||||||
if (count <= 0) return;
|
|
||||||
if (count > 32) throw new Error("writeSigned supports up to 32 bits");
|
|
||||||
const mask =
|
|
||||||
count === 32 ? 0xffffffff >>> 0 : (((1 << count) >>> 0) - 1) >>> 0;
|
|
||||||
const unsigned = (num & mask) >>> 0;
|
|
||||||
this.writeBits(unsigned, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeUnsignedExpGolomb(num: number): void {
|
|
||||||
if (num < 0) throw new Error("Expected a non-negative number");
|
|
||||||
num++;
|
|
||||||
const bitCount = 32 - Math.clz32(num >>> 0);
|
|
||||||
this.writeBits(0, bitCount - 1);
|
|
||||||
this.writeBits(num, bitCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeSignedExpGolomb(num: number): void {
|
|
||||||
if (num < 0) this.writeUnsignedExpGolomb(-2 * num);
|
|
||||||
else this.writeUnsignedExpGolomb(2 * num - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* H264/H265 NAL helpers — ported from @dank074/discord-video-stream
|
|
||||||
* AnnexBHelper.js. Only the H264 parts are used by GoLive (H264 encoder),
|
|
||||||
* H265 constants kept for completeness of the port.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export enum H264NalUnitTypes {
|
|
||||||
Unspecified = 0,
|
|
||||||
CodedSliceNonIDR = 1,
|
|
||||||
CodedSlicePartitionA = 2,
|
|
||||||
CodedSlicePartitionB = 3,
|
|
||||||
CodedSlicePartitionC = 4,
|
|
||||||
CodedSliceIdr = 5,
|
|
||||||
SEI = 6,
|
|
||||||
SPS = 7,
|
|
||||||
PPS = 8,
|
|
||||||
AccessUnitDelimiter = 9,
|
|
||||||
EndOfSequence = 10,
|
|
||||||
EndOfStream = 11,
|
|
||||||
FillerData = 12,
|
|
||||||
SEIExtenstion = 13,
|
|
||||||
PrefixNalUnit = 14,
|
|
||||||
SubsetSPS = 15,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum H265NalUnitTypes {
|
|
||||||
TRAIL_N = 0,
|
|
||||||
TRAIL_R = 1,
|
|
||||||
TSA_N = 2,
|
|
||||||
TSA_R = 3,
|
|
||||||
STSA_N = 4,
|
|
||||||
STSA_R = 5,
|
|
||||||
RADL_N = 6,
|
|
||||||
RADL_R = 7,
|
|
||||||
RASL_N = 8,
|
|
||||||
RASL_R = 9,
|
|
||||||
RSV_VCL_N10 = 10,
|
|
||||||
RSV_VCL_R11 = 11,
|
|
||||||
RSV_VCL_N12 = 12,
|
|
||||||
RSV_VCL_R13 = 13,
|
|
||||||
RSV_VCL_N14 = 14,
|
|
||||||
RSV_VCL_R15 = 15,
|
|
||||||
BLA_W_LP = 16,
|
|
||||||
BLA_W_RADL = 17,
|
|
||||||
BLA_N_LP = 18,
|
|
||||||
IDR_W_RADL = 19,
|
|
||||||
IDR_N_LP = 20,
|
|
||||||
CRA_NUT = 21,
|
|
||||||
RSV_IRAP_VCL22 = 22,
|
|
||||||
RSV_IRAP_VCL23 = 23,
|
|
||||||
RSV_VCL24 = 24,
|
|
||||||
RSV_VCL25 = 25,
|
|
||||||
RSV_VCL26 = 26,
|
|
||||||
RSV_VCL27 = 27,
|
|
||||||
RSV_VCL28 = 28,
|
|
||||||
RSV_VCL29 = 29,
|
|
||||||
RSV_VCL30 = 30,
|
|
||||||
RSV_VCL31 = 31,
|
|
||||||
VPS_NUT = 32,
|
|
||||||
SPS_NUT = 33,
|
|
||||||
PPS_NUT = 34,
|
|
||||||
AUD_NUT = 35,
|
|
||||||
EOS_NUT = 36,
|
|
||||||
EOB_NUT = 37,
|
|
||||||
FD_NUT = 38,
|
|
||||||
PREFIX_SEI_NUT = 39,
|
|
||||||
SUFFIX_SEI_NUT = 40,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const H264Helpers = {
|
|
||||||
getUnitType(frame: Uint8Array): number {
|
|
||||||
return frame[0] & 0x1f;
|
|
||||||
},
|
|
||||||
splitHeader(frame: Uint8Array): [Uint8Array, Uint8Array] {
|
|
||||||
return [frame.subarray(0, 1), frame.subarray(1)];
|
|
||||||
},
|
|
||||||
isAUD(unitType: number): boolean {
|
|
||||||
return unitType === H264NalUnitTypes.AccessUnitDelimiter;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const H265Helpers = {
|
|
||||||
getUnitType(frame: Uint8Array): number {
|
|
||||||
return (frame[0] >> 1) & 0x3f;
|
|
||||||
},
|
|
||||||
splitHeader(frame: Uint8Array): [Uint8Array, Uint8Array] {
|
|
||||||
return [frame.subarray(0, 2), frame.subarray(2)];
|
|
||||||
},
|
|
||||||
isAUD(unitType: number): boolean {
|
|
||||||
return unitType === H265NalUnitTypes.AUD_NUT;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startCode3 = Buffer.from([0, 0, 1]);
|
|
||||||
|
|
||||||
/** Split an AnnexB bitstream into NAL units (start codes stripped). */
|
|
||||||
export function splitNalu(buf: Buffer): Buffer[] {
|
|
||||||
let temp: Buffer | null = buf;
|
|
||||||
const nalus: Buffer[] = [];
|
|
||||||
while (temp?.byteLength) {
|
|
||||||
let pos: number = temp.indexOf(startCode3);
|
|
||||||
let length = 3;
|
|
||||||
if (pos > 0 && temp[pos - 1] === 0) {
|
|
||||||
pos--;
|
|
||||||
length++;
|
|
||||||
}
|
|
||||||
const nalu = pos === -1 ? temp : temp.subarray(0, pos);
|
|
||||||
temp = pos === -1 ? null : temp.subarray(pos + length);
|
|
||||||
if (nalu.byteLength) nalus.push(nalu);
|
|
||||||
}
|
|
||||||
return nalus;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* AudioStream — feeds encoded opus frames into the WebRTC connection.
|
|
||||||
* Ported from @dank074/discord-video-stream AudioStream.js.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BaseMediaStream } from "./BaseMediaStream.js";
|
|
||||||
import type { WebRtcConnWrapper } from "./WebRtcWrapper.js";
|
|
||||||
|
|
||||||
export class AudioStream extends BaseMediaStream {
|
|
||||||
_conn: WebRtcConnWrapper;
|
|
||||||
|
|
||||||
constructor(conn: WebRtcConnWrapper, noSleep = false) {
|
|
||||||
super("audio", noSleep);
|
|
||||||
this._conn = conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _sendFrame(frame: Buffer, frametime: number): Promise<void> {
|
|
||||||
this._conn.sendAudioFrame(frame, frametime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
/**
|
|
||||||
* Base media connection for Discord GoLive — ported from
|
|
||||||
* @dank074/discord-video-stream BaseMediaConnection.js.
|
|
||||||
*
|
|
||||||
* Owns the voice WebSocket (identify/select_protocol/heartbeat/resume),
|
|
||||||
* SDP negotiation against Discord's media server, DAVE E2E voice
|
|
||||||
* (via @snazzah/davey), and speaking/video attribute signaling.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import Davey from "@snazzah/davey";
|
|
||||||
import { CodecPayloadType } from "./CodecPayloadType.js";
|
|
||||||
import type { NativePeerConnection } from "./native.js";
|
|
||||||
import { isNativeAvailable } from "./native.js";
|
|
||||||
import { STREAMS_SIMULCAST } from "./utils.js";
|
|
||||||
import { VoiceOpCodes, VoiceOpCodesBinary } from "./VoiceOpCodes.js";
|
|
||||||
import { WebRtcConnWrapper } from "./WebRtcWrapper.js";
|
|
||||||
|
|
||||||
export interface MediaConnectionStatus {
|
|
||||||
hasSession: boolean;
|
|
||||||
hasToken: boolean;
|
|
||||||
started: boolean;
|
|
||||||
resuming: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoAttribute {
|
|
||||||
fps: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StreamerLike {
|
|
||||||
opts: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BaseMediaConnection extends EventEmitter {
|
|
||||||
interval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
guildId: string | null = null;
|
|
||||||
channelId: string;
|
|
||||||
botId: string;
|
|
||||||
ws: WebSocket | null = null;
|
|
||||||
status: MediaConnectionStatus;
|
|
||||||
server: string | null = null; // websocket url
|
|
||||||
token: string | null = null;
|
|
||||||
session_id: string | null = null;
|
|
||||||
protected _webRtcWrapper: WebRtcConnWrapper;
|
|
||||||
_webRtcParams: {
|
|
||||||
address: string;
|
|
||||||
port: number;
|
|
||||||
audioSsrc: number;
|
|
||||||
videoSsrc: number;
|
|
||||||
rtxSsrc: number;
|
|
||||||
supportedEncryptionModes: string[];
|
|
||||||
} | null = null;
|
|
||||||
protected _closed = false;
|
|
||||||
ready: ((conn: WebRtcConnWrapper) => void) | null;
|
|
||||||
protected _streamer: StreamerLike;
|
|
||||||
protected _sequenceNumber = -1;
|
|
||||||
protected _daveSession: Davey.DAVESession | null = null;
|
|
||||||
protected _connectedUsers = new Set<string>();
|
|
||||||
protected _daveProtocolVersion = 0;
|
|
||||||
protected _davePendingTransitions = new Map<number, number>();
|
|
||||||
protected _daveDowngraded = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
streamer: StreamerLike,
|
|
||||||
guildId: string | null,
|
|
||||||
botId: string,
|
|
||||||
channelId: string,
|
|
||||||
callback: ((conn: WebRtcConnWrapper) => void) | null,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
this._streamer = streamer;
|
|
||||||
this.status = {
|
|
||||||
hasSession: false,
|
|
||||||
hasToken: false,
|
|
||||||
started: false,
|
|
||||||
resuming: false,
|
|
||||||
};
|
|
||||||
this.guildId = guildId;
|
|
||||||
this.channelId = channelId;
|
|
||||||
this.botId = botId;
|
|
||||||
this.ready = callback;
|
|
||||||
this._webRtcWrapper = new WebRtcConnWrapper(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
get type(): "guild" | "call" {
|
|
||||||
return this.guildId ? "guild" : "call";
|
|
||||||
}
|
|
||||||
|
|
||||||
get webRtcConn(): WebRtcConnWrapper {
|
|
||||||
return this._webRtcWrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
get webRtcParams(): BaseMediaConnection["_webRtcParams"] {
|
|
||||||
return this._webRtcParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
get streamer(): StreamerLike {
|
|
||||||
return this._streamer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** daveChannelId — overridden in VoiceConnection (channelId) and StreamConnection (serverId - 1n). */
|
|
||||||
get daveChannelId(): string {
|
|
||||||
throw new Error("daveChannelId not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
this._closed = true;
|
|
||||||
this._webRtcWrapper.close();
|
|
||||||
this.ws?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
setSession(session_id: string): void {
|
|
||||||
this.session_id = session_id;
|
|
||||||
this.status.hasSession = true;
|
|
||||||
this.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTokens(server: string, token: string): void {
|
|
||||||
this.token = token;
|
|
||||||
this.server = server;
|
|
||||||
this.status.hasToken = true;
|
|
||||||
this.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
|
||||||
if (this.status.hasSession && this.status.hasToken) {
|
|
||||||
if (this.status.started) return;
|
|
||||||
this.status.started = true;
|
|
||||||
this.ws = new WebSocket(`wss://${this.server}/?v=8`);
|
|
||||||
this.ws.binaryType = "arraybuffer";
|
|
||||||
this.ws.addEventListener("open", () => {
|
|
||||||
if (this.status.resuming) {
|
|
||||||
this.status.resuming = false;
|
|
||||||
this.resume();
|
|
||||||
} else {
|
|
||||||
this.identify();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.ws.addEventListener("error", (err) => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
this.ws.addEventListener("close", (e) => {
|
|
||||||
const wasStarted = this.status.started;
|
|
||||||
this.interval && clearInterval(this.interval);
|
|
||||||
this.status.started = false;
|
|
||||||
const canResume = e.code === 4015 || e.code < 4000;
|
|
||||||
if (canResume && wasStarted) {
|
|
||||||
this.status.resuming = true;
|
|
||||||
this.start();
|
|
||||||
} else {
|
|
||||||
this._closed = true;
|
|
||||||
this._webRtcWrapper?.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.setupEvents();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReady(d: {
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
ssrc: number;
|
|
||||||
streams: { ssrc: number; rtx_ssrc: number }[];
|
|
||||||
modes: string[];
|
|
||||||
}): void {
|
|
||||||
// we hardcoded STREAMS_SIMULCAST, which will always be array of 1
|
|
||||||
const stream = d.streams[0];
|
|
||||||
console.log(
|
|
||||||
`[goLive:${this.constructor.name}] READY ssrc=${d.ssrc} ip=${d.ip} port=${d.port} streams=${JSON.stringify(d.streams)}`,
|
|
||||||
);
|
|
||||||
this._webRtcParams = {
|
|
||||||
address: d.ip,
|
|
||||||
port: d.port,
|
|
||||||
audioSsrc: d.ssrc,
|
|
||||||
videoSsrc: stream.ssrc,
|
|
||||||
rtxSsrc: stream.rtx_ssrc,
|
|
||||||
supportedEncryptionModes: d.modes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleProtocolAck(d: {
|
|
||||||
sdp?: string;
|
|
||||||
dave_protocol_version?: number;
|
|
||||||
}): Promise<void> {
|
|
||||||
if (!("sdp" in d)) throw new Error("Only WebRTC connections are allowed");
|
|
||||||
// DEBUG: dump Discord's real answer SDP — which payload types did it select?
|
|
||||||
console.log(
|
|
||||||
`[goLive:${this.constructor.name}] DISCORD_ANSWER_SDP ${JSON.stringify(d.sdp ?? "").slice(0, 900)}`,
|
|
||||||
);
|
|
||||||
this._daveProtocolVersion = d.dave_protocol_version ?? 0;
|
|
||||||
this.initDave();
|
|
||||||
// Discord's SDP is garbage — generate our own from its pieces
|
|
||||||
let ip = "";
|
|
||||||
let port = "";
|
|
||||||
let iceUsername = "";
|
|
||||||
let icePassword = "";
|
|
||||||
let fingerprint = "";
|
|
||||||
let candidate = "";
|
|
||||||
for (const line of (d.sdp ?? "").split("\n")) {
|
|
||||||
if (line.startsWith("c=")) ip = line;
|
|
||||||
else if (line.startsWith("a=rtcp")) port = line.split(":")[1];
|
|
||||||
else if (line.startsWith("a=ice-ufrag")) iceUsername = line;
|
|
||||||
else if (line.startsWith("a=ice-pwd")) icePassword = line;
|
|
||||||
else if (line.startsWith("a=fingerprint")) fingerprint = line;
|
|
||||||
else if (line.startsWith("a=candidate")) candidate = line;
|
|
||||||
}
|
|
||||||
const audioPayloadType = CodecPayloadType.opus.payload_type;
|
|
||||||
const audioSection = `
|
|
||||||
m=audio ${port} UDP/TLS/RTP/SAVPF ${audioPayloadType}
|
|
||||||
${ip}
|
|
||||||
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
|
|
||||||
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
|
|
||||||
a=setup:passive
|
|
||||||
a=mid:0
|
|
||||||
a=maxptime:60
|
|
||||||
a=inactive
|
|
||||||
${iceUsername}
|
|
||||||
${icePassword}
|
|
||||||
${fingerprint}
|
|
||||||
${candidate}
|
|
||||||
a=rtcp-mux
|
|
||||||
a=rtpmap:${audioPayloadType} opus/48000/2
|
|
||||||
a=fmtp:${audioPayloadType} minptime=10;useinbandfec=1;usedtx=1
|
|
||||||
a=rtcp-fb:${audioPayloadType} transport-cc
|
|
||||||
a=rtcp-fb:${audioPayloadType} nack
|
|
||||||
a=ice-lite
|
|
||||||
`.trim();
|
|
||||||
const videoPayloads = Object.values(CodecPayloadType).filter(
|
|
||||||
(el) => el.type === "video",
|
|
||||||
);
|
|
||||||
const videoPayloadTypes = videoPayloads.flatMap((el) => [
|
|
||||||
el.payload_type,
|
|
||||||
el.rtx_payload_type ?? 0,
|
|
||||||
]);
|
|
||||||
const videoSection = `
|
|
||||||
m=video ${port} UDP/TLS/RTP/SAVPF ${videoPayloadTypes.join(" ")}
|
|
||||||
${ip}
|
|
||||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
|
||||||
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
|
|
||||||
a=extmap:14 urn:ietf:params:rtp-hdrext:toffset
|
|
||||||
a=extmap:13 urn:3gpp:video-orientation
|
|
||||||
a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay
|
|
||||||
a=setup:passive
|
|
||||||
a=mid:1
|
|
||||||
a=inactive
|
|
||||||
${iceUsername}
|
|
||||||
${icePassword}
|
|
||||||
${fingerprint}
|
|
||||||
${candidate}
|
|
||||||
a=rtcp-mux
|
|
||||||
a=ice-lite
|
|
||||||
`.trim();
|
|
||||||
const videoRtpMap = videoPayloads
|
|
||||||
.flatMap((el) => [
|
|
||||||
`a=rtpmap:${el.payload_type} ${el.name}/90000`,
|
|
||||||
`a=rtpmap:${el.rtx_payload_type} rtx/90000`,
|
|
||||||
`a=fmtp:${el.rtx_payload_type} apt=${el.payload_type}`,
|
|
||||||
// CRITICAL: H264 MUST advertise packetization-mode=1. The encoder
|
|
||||||
// emits baseline slices up to 8KB (>RTP MTU), so the packetizer
|
|
||||||
// fragments them into FU-A units (RFC 6184). Discord's receiver only
|
|
||||||
// reassembles FU-A when packetization-mode=1 is negotiated — without
|
|
||||||
// it the slices (type 28) are DROPPED while SPS/PPS (small single
|
|
||||||
// NALs) and Opus audio (no fragmentation) still arrive → black video
|
|
||||||
// with working audio. profile-level-id=42e01f (constrained baseline
|
|
||||||
// 3.1) matches the -profile:v baseline encoder + SPS VUI rewriter.
|
|
||||||
...(el.name === "H264"
|
|
||||||
? [
|
|
||||||
`a=fmtp:${el.payload_type} level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f`,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
`a=rtcp-fb:${el.payload_type} ccm fir`,
|
|
||||||
`a=rtcp-fb:${el.payload_type} nack`,
|
|
||||||
`a=rtcp-fb:${el.payload_type} nack pli`,
|
|
||||||
`a=rtcp-fb:${el.payload_type} goog-remb`,
|
|
||||||
`a=rtcp-fb:${el.payload_type} transport-cc`,
|
|
||||||
])
|
|
||||||
.join("\n");
|
|
||||||
const builtAnswer = [audioSection, videoSection, videoRtpMap].join("\n");
|
|
||||||
this._webRtcWrapper.webRtcConn?.setRemoteDescription(builtAnswer, "answer");
|
|
||||||
console.log(
|
|
||||||
`[goLive:${this.constructor.name}] SELECT_PROTOCOL_ACK processed — remote answer set (${builtAnswer.length}B) video_mline=${videoPayloadTypes.join(" ")}`,
|
|
||||||
);
|
|
||||||
this.emit("select_protocol_ack");
|
|
||||||
}
|
|
||||||
|
|
||||||
initDave(): void {
|
|
||||||
if (this._daveProtocolVersion) {
|
|
||||||
if (this._daveSession) {
|
|
||||||
this._daveSession.reinit(
|
|
||||||
this._daveProtocolVersion,
|
|
||||||
this.botId,
|
|
||||||
this.daveChannelId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this._daveSession = new Davey.DAVESession(
|
|
||||||
this._daveProtocolVersion,
|
|
||||||
this.botId,
|
|
||||||
this.daveChannelId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.sendOpcodeBinary(
|
|
||||||
VoiceOpCodesBinary.MLS_KEY_PACKAGE,
|
|
||||||
this._daveSession.getSerializedKeyPackage(),
|
|
||||||
);
|
|
||||||
} else if (this._daveSession) {
|
|
||||||
this._daveSession.reset();
|
|
||||||
this._daveSession.setPassthroughMode(true, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processInvalidCommit(transitionId: number): void {
|
|
||||||
this.sendOpcode(VoiceOpCodes.MLS_INVALID_COMMIT_WELCOME, {
|
|
||||||
transition_id: transitionId,
|
|
||||||
});
|
|
||||||
this.initDave();
|
|
||||||
}
|
|
||||||
|
|
||||||
executePendingTransition(transitionId: number): void {
|
|
||||||
const newVersion = this._davePendingTransitions.get(transitionId);
|
|
||||||
if (newVersion === undefined) {
|
|
||||||
console.error("Unrecognized transition ID", { transitionId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const oldVersion = this._daveProtocolVersion;
|
|
||||||
this._daveProtocolVersion = newVersion;
|
|
||||||
if (oldVersion !== newVersion && newVersion === 0) {
|
|
||||||
// Downgraded
|
|
||||||
this._daveDowngraded = true;
|
|
||||||
} else if (transitionId > 0 && this._daveDowngraded) {
|
|
||||||
this._daveDowngraded = false;
|
|
||||||
this._daveSession?.setPassthroughMode(true, 10);
|
|
||||||
}
|
|
||||||
this._davePendingTransitions.delete(transitionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEvents(): void {
|
|
||||||
this.ws?.addEventListener("message", async (e) => {
|
|
||||||
if (e.data instanceof ArrayBuffer) {
|
|
||||||
this.handleBinaryMessages(Buffer.from(e.data));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { op, d, seq } = JSON.parse(e.data as string) as {
|
|
||||||
op: number;
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Discord voice WS payload is dynamically typed
|
|
||||||
d: any;
|
|
||||||
seq?: number;
|
|
||||||
};
|
|
||||||
if (seq) this._sequenceNumber = seq;
|
|
||||||
if (op === VoiceOpCodes.READY) {
|
|
||||||
this.handleReady(d);
|
|
||||||
this.setProtocols()
|
|
||||||
.then(() => this.ready?.(this._webRtcWrapper))
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
// PC can be closed while setProtocols is in flight (stream
|
|
||||||
// teardown) — don't let that become an unhandledRejection.
|
|
||||||
console.log(
|
|
||||||
`[goLive:${this.constructor.name}] setProtocols rejected during teardown: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.setVideoAttributes(false);
|
|
||||||
} else if (op >= 4000) {
|
|
||||||
console.error(`${this.constructor.name} connection error`, d);
|
|
||||||
} else if (op === VoiceOpCodes.HELLO) {
|
|
||||||
this.setupHeartbeat(d.heartbeat_interval);
|
|
||||||
} else if (op === VoiceOpCodes.SELECT_PROTOCOL_ACK) {
|
|
||||||
await this.handleProtocolAck(d);
|
|
||||||
} else if (op === VoiceOpCodes.SPEAKING) {
|
|
||||||
// ignore speaking updates
|
|
||||||
} else if (op === VoiceOpCodes.HEARTBEAT_ACK) {
|
|
||||||
// ignore heartbeat acknowledgements
|
|
||||||
} else if (op === VoiceOpCodes.RESUMED) {
|
|
||||||
this.status.started = true;
|
|
||||||
} else if (op === VoiceOpCodes.CLIENTS_CONNECT) {
|
|
||||||
d.user_ids.forEach((id: string) => {
|
|
||||||
this._connectedUsers.add(id);
|
|
||||||
});
|
|
||||||
} else if (op === VoiceOpCodes.CLIENT_DISCONNECT) {
|
|
||||||
this._connectedUsers.delete(d.user_id);
|
|
||||||
} else if (op === VoiceOpCodes.DAVE_PREPARE_TRANSITION) {
|
|
||||||
this._davePendingTransitions.set(d.transition_id, d.protocol_version);
|
|
||||||
if (d.transition_id === 0) {
|
|
||||||
this.executePendingTransition(d.transition_id);
|
|
||||||
} else {
|
|
||||||
if (d.protocol_version === 0) {
|
|
||||||
this._daveSession?.setPassthroughMode(true, 120);
|
|
||||||
}
|
|
||||||
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
|
|
||||||
transition_id: d.transition_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (op === VoiceOpCodes.DAVE_EXECUTE_TRANSITION) {
|
|
||||||
this.executePendingTransition(d.transition_id);
|
|
||||||
} else if (op === VoiceOpCodes.DAVE_PREPARE_EPOCH) {
|
|
||||||
if (d.epoch === 1) {
|
|
||||||
this._daveProtocolVersion = d.protocol_version;
|
|
||||||
this.initDave();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBinaryMessages(msg: Buffer): void {
|
|
||||||
this._sequenceNumber = msg.readUint16BE(0);
|
|
||||||
const op = msg.readUint8(2);
|
|
||||||
switch (op) {
|
|
||||||
case VoiceOpCodesBinary.MLS_EXTERNAL_SENDER: {
|
|
||||||
this._daveSession?.setExternalSender(msg.subarray(3));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case VoiceOpCodesBinary.MLS_PROPOSALS: {
|
|
||||||
const optype = msg.readUint8(3);
|
|
||||||
if (!this._daveSession) break;
|
|
||||||
const { commit, welcome } = this._daveSession.processProposals(
|
|
||||||
optype,
|
|
||||||
msg.subarray(4),
|
|
||||||
[...this._connectedUsers],
|
|
||||||
);
|
|
||||||
if (commit) {
|
|
||||||
this.sendOpcodeBinary(
|
|
||||||
VoiceOpCodesBinary.MLS_COMMIT_WELCOME,
|
|
||||||
welcome ? Buffer.concat([commit, welcome]) : commit,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case VoiceOpCodesBinary.MLS_ANNOUNCE_COMMIT_TRANSITION: {
|
|
||||||
const transitionId = msg.readUInt16BE(3);
|
|
||||||
try {
|
|
||||||
this._daveSession?.processCommit(msg.subarray(5));
|
|
||||||
if (transitionId) {
|
|
||||||
this._davePendingTransitions.set(
|
|
||||||
transitionId,
|
|
||||||
this._daveProtocolVersion,
|
|
||||||
);
|
|
||||||
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
|
|
||||||
transition_id: transitionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug("MLS commit errored", e);
|
|
||||||
this.processInvalidCommit(transitionId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case VoiceOpCodesBinary.MLS_WELCOME: {
|
|
||||||
const transitionId = msg.readUInt16BE(3);
|
|
||||||
try {
|
|
||||||
this._daveSession?.processWelcome(msg.subarray(5));
|
|
||||||
if (transitionId) {
|
|
||||||
this._davePendingTransitions.set(
|
|
||||||
transitionId,
|
|
||||||
this._daveProtocolVersion,
|
|
||||||
);
|
|
||||||
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
|
|
||||||
transition_id: transitionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug("MLS welcome errored", e);
|
|
||||||
this.processInvalidCommit(transitionId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get daveReady(): boolean {
|
|
||||||
return !!this._daveProtocolVersion && !!this._daveSession?.ready;
|
|
||||||
}
|
|
||||||
|
|
||||||
get daveSession(): Davey.DAVESession | null {
|
|
||||||
return this._daveSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupHeartbeat(interval: number): void {
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
}
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
try {
|
|
||||||
this.sendOpcode(VoiceOpCodes.HEARTBEAT, {
|
|
||||||
t: Date.now(),
|
|
||||||
seq_ack: this._sequenceNumber,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendOpcode(code: number, data: unknown): void {
|
|
||||||
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
||||||
this.ws.send(JSON.stringify({ op: code, d: data }));
|
|
||||||
}
|
|
||||||
|
|
||||||
sendOpcodeBinary(code: number, data: Uint8Array): void {
|
|
||||||
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
||||||
const buf = Buffer.allocUnsafe(data.length + 1);
|
|
||||||
buf.writeUInt8(code);
|
|
||||||
Buffer.from(data).copy(buf, 1);
|
|
||||||
this.ws.send(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** serverId — overridden in VoiceConnection (guildId ?? channelId) and StreamConnection (rtc_server_id). */
|
|
||||||
get serverId(): string | null {
|
|
||||||
throw new Error("serverId not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** identifies with media server with credentials */
|
|
||||||
identify(): void {
|
|
||||||
if (!this.serverId) throw new Error("Server ID is null or empty");
|
|
||||||
if (!this.session_id) throw new Error("Session ID is null or empty");
|
|
||||||
if (!this.token) throw new Error("Token is null or empty");
|
|
||||||
this.sendOpcode(VoiceOpCodes.IDENTIFY, {
|
|
||||||
server_id: this.serverId,
|
|
||||||
user_id: this.botId,
|
|
||||||
session_id: this.session_id,
|
|
||||||
token: this.token,
|
|
||||||
video: true,
|
|
||||||
streams: STREAMS_SIMULCAST,
|
|
||||||
max_dave_protocol_version: Davey.DAVE_PROTOCOL_VERSION ?? 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resume(): void {
|
|
||||||
if (!this.serverId) throw new Error("Server ID is null or empty");
|
|
||||||
if (!this.session_id) throw new Error("Session ID is null or empty");
|
|
||||||
if (!this.token) throw new Error("Token is null or empty");
|
|
||||||
this.sendOpcode(VoiceOpCodes.RESUME, {
|
|
||||||
server_id: this.serverId,
|
|
||||||
session_id: this.session_id,
|
|
||||||
token: this.token,
|
|
||||||
seq_ack: this._sequenceNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets protocols and ip data used for video and audio (vp8 video, opus audio). */
|
|
||||||
async setProtocols(): Promise<void> {
|
|
||||||
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
|
|
||||||
if (!isNativeAvailable()) {
|
|
||||||
throw new Error(
|
|
||||||
"libdatachannel-min native binding not built — cannot start GoLive",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const reconnect = () => {
|
|
||||||
const webRtcConn = this._webRtcWrapper.initWebRtc();
|
|
||||||
webRtcConn.onStateChange((state) => {
|
|
||||||
console.log(`[goLive:${this.constructor.name}] pc state => ${state}`);
|
|
||||||
if (state === "closed" && !this._closed) reconnect();
|
|
||||||
});
|
|
||||||
this._webRtcWrapper.onLocalDescription = (sdp) => {
|
|
||||||
const rtc_connection_id = randomUUID();
|
|
||||||
console.log(
|
|
||||||
`[goLive:${this.constructor.name}] sending SELECT_PROTOCOL (offer ${sdp.length}B, rtc_connection_id=${rtc_connection_id.slice(0, 8)})`,
|
|
||||||
);
|
|
||||||
this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, {
|
|
||||||
protocol: "webrtc",
|
|
||||||
codecs: Object.values(CodecPayloadType),
|
|
||||||
data: sdp,
|
|
||||||
sdp,
|
|
||||||
rtc_connection_id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// createOffer (binding resolves full SDP incl. candidates after gathering)
|
|
||||||
void webRtcConn
|
|
||||||
.createOffer()
|
|
||||||
.then((sdp) => {
|
|
||||||
this._webRtcWrapper.onLocalDescription?.(sdp);
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
// PC closed while offer is gathering (stream teardown / reconnect) —
|
|
||||||
// swallow, the reconnect loop will start a fresh offer.
|
|
||||||
console.log(
|
|
||||||
`[goLive:${this.constructor.name}] createOffer rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
reconnect();
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.once("select_protocol_ack", () => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setVideoAttributes(enabled: boolean, attr?: VideoAttribute): void {
|
|
||||||
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
|
|
||||||
const { audioSsrc, videoSsrc, rtxSsrc } = this._webRtcParams;
|
|
||||||
const payload = !enabled
|
|
||||||
? {
|
|
||||||
audio_ssrc: audioSsrc,
|
|
||||||
video_ssrc: 0,
|
|
||||||
rtx_ssrc: 0,
|
|
||||||
streams: [],
|
|
||||||
}
|
|
||||||
: (() => {
|
|
||||||
if (!attr) throw new Error("Need to specify video attributes");
|
|
||||||
return {
|
|
||||||
audio_ssrc: audioSsrc,
|
|
||||||
video_ssrc: videoSsrc,
|
|
||||||
rtx_ssrc: rtxSsrc,
|
|
||||||
streams: [
|
|
||||||
{
|
|
||||||
type: "video",
|
|
||||||
rid: "100",
|
|
||||||
ssrc: videoSsrc,
|
|
||||||
active: true,
|
|
||||||
quality: 100,
|
|
||||||
rtx_ssrc: rtxSsrc,
|
|
||||||
// hardcode the max bitrate because we don't really know anyway
|
|
||||||
max_bitrate: 10000 * 1000,
|
|
||||||
max_framerate: enabled ? attr.fps : 0,
|
|
||||||
max_resolution: {
|
|
||||||
type: "fixed",
|
|
||||||
width: attr.width,
|
|
||||||
height: attr.height,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
// CRITICAL: The VIDEO opcode (op 12) is what tells Discord's media server
|
|
||||||
// to actually forward the video RTP stream on video_ssrc. sendOpcode() is a
|
|
||||||
// no-op when ws.readyState !== OPEN — and in GoLive the StreamConnection's
|
|
||||||
// WebSocket can still be in CONNECTING immediately after SELECT_PROTOCOL_ACK
|
|
||||||
// (the ack listener resolves playStream, but the data channel / ws open
|
|
||||||
// handshake may lag by a few ms). A dropped op 12 → Discord never activates
|
|
||||||
// the video SSRC → black/broken video while audio (whose SPEAKING on the
|
|
||||||
// VoiceConnection already fired) plays fine. Retry until the ws is OPEN
|
|
||||||
// instead of silently dropping this mandatory signal.
|
|
||||||
this.sendOpcodeWhenOpen(VoiceOpCodes.VIDEO, payload, "VIDEO");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set speaking status */
|
|
||||||
setSpeaking(speaking: boolean): void {
|
|
||||||
if (!this._webRtcParams) throw new Error("WebRTC connection not ready");
|
|
||||||
const payload = {
|
|
||||||
delay: 0,
|
|
||||||
speaking: speaking ? 1 : 0,
|
|
||||||
ssrc: this._webRtcParams.audioSsrc,
|
|
||||||
};
|
|
||||||
// Same race as setVideoAttributes: SPEAKING (op 5) must reach Discord. Retry
|
|
||||||
// until the ws is OPEN rather than dropping it on a transient not-yet-open.
|
|
||||||
this.sendOpcodeWhenOpen(VoiceOpCodes.SPEAKING, payload, "SPEAKING");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an opcode, retrying for a short window if the WebSocket is not yet
|
|
||||||
* OPEN. Discord's media signaling (VIDEO/op12, SPEAKING/op5) is mandatory —
|
|
||||||
* a silent no-op (the default sendOpcode behaviour when ws is still
|
|
||||||
* CONNECTING) breaks GoLive video while leaving audio intact. We wait for
|
|
||||||
* the open state instead of dropping.
|
|
||||||
*/
|
|
||||||
private sendOpcodeWhenOpen(code: number, data: unknown, label: string): void {
|
|
||||||
const attempt = (triesLeft: number) => {
|
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
||||||
this.sendOpcode(code, data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (triesLeft <= 0) {
|
|
||||||
console.error(
|
|
||||||
`[goLive:${this.constructor.name}] ${label} opcode (op=${code}) DROPPED — ws never opened (state=${this.ws?.readyState ?? "null"})`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ws still CONNECTING (or briefly closed during reconnect) — retry.
|
|
||||||
setTimeout(() => attempt(triesLeft - 1), 50);
|
|
||||||
};
|
|
||||||
attempt(40); // up to ~2s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { NativePeerConnection };
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/**
|
|
||||||
* BaseMediaStream — pacing/sync for GoLive frames. Ported from
|
|
||||||
* @dank074/discord-video-stream BaseMediaStream.js, minus node-av's
|
|
||||||
* AVFrame (frames are plain objects here) and debug-level (uses the GMW
|
|
||||||
* logger instead).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Writable } from "node:stream";
|
|
||||||
import { setTimeout as sleep } from "node:timers/promises";
|
|
||||||
|
|
||||||
export interface GoLiveFrame {
|
|
||||||
data: Buffer | null;
|
|
||||||
pts: number;
|
|
||||||
duration: number;
|
|
||||||
timeBase: { num: number; den: number };
|
|
||||||
free?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BaseMediaStream extends Writable {
|
|
||||||
_pts: number | undefined;
|
|
||||||
_syncTolerance = 20;
|
|
||||||
_noSleep: boolean;
|
|
||||||
_startTime: number | undefined;
|
|
||||||
_startPts: number | undefined;
|
|
||||||
_sync = true;
|
|
||||||
_syncStream: BaseMediaStream | undefined;
|
|
||||||
_type: string;
|
|
||||||
|
|
||||||
constructor(type: string, noSleep = false) {
|
|
||||||
super({ objectMode: true, highWaterMark: 0 });
|
|
||||||
this._type = type;
|
|
||||||
this._noSleep = noSleep;
|
|
||||||
}
|
|
||||||
|
|
||||||
get sync(): boolean {
|
|
||||||
return this._sync;
|
|
||||||
}
|
|
||||||
|
|
||||||
set sync(val: boolean) {
|
|
||||||
this._sync = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
get syncStream(): BaseMediaStream | undefined {
|
|
||||||
return this._syncStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
set syncStream(stream: BaseMediaStream | undefined) {
|
|
||||||
if (stream !== undefined && this === stream.syncStream) {
|
|
||||||
throw new Error("Cannot sync 2 streams with eachother");
|
|
||||||
}
|
|
||||||
this._syncStream = stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
get noSleep(): boolean {
|
|
||||||
return this._noSleep;
|
|
||||||
}
|
|
||||||
|
|
||||||
set noSleep(val: boolean) {
|
|
||||||
this._noSleep = val;
|
|
||||||
if (!val) this.resetTimingCompensation();
|
|
||||||
}
|
|
||||||
|
|
||||||
get pts(): number | undefined {
|
|
||||||
return this._pts;
|
|
||||||
}
|
|
||||||
|
|
||||||
get syncTolerance(): number {
|
|
||||||
return this._syncTolerance;
|
|
||||||
}
|
|
||||||
|
|
||||||
set syncTolerance(n: number) {
|
|
||||||
if (n < 0) return;
|
|
||||||
this._syncTolerance = n;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _sendFrame(_frame: Buffer, _frametime: number): Promise<void> {
|
|
||||||
throw new Error("Not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
ptsDelta(): number | undefined {
|
|
||||||
if (this.pts !== undefined && this.syncStream?.pts !== undefined) {
|
|
||||||
return this.pts - this.syncStream.pts;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
isAhead(): boolean {
|
|
||||||
const delta = this.ptsDelta();
|
|
||||||
return (
|
|
||||||
this.syncStream?.writableEnded === false &&
|
|
||||||
delta !== undefined &&
|
|
||||||
delta > this.syncTolerance
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
isBehind(): boolean {
|
|
||||||
const delta = this.ptsDelta();
|
|
||||||
return (
|
|
||||||
this.syncStream?.writableEnded === false &&
|
|
||||||
delta !== undefined &&
|
|
||||||
delta < -this.syncTolerance
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
resetTimingCompensation(): void {
|
|
||||||
this._startTime = this._startPts = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _write(
|
|
||||||
frame: GoLiveFrame,
|
|
||||||
_encoding: BufferEncoding,
|
|
||||||
callback: (error?: Error | null) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const { data, pts, duration, timeBase } = frame;
|
|
||||||
if (!data) {
|
|
||||||
frame.free?.();
|
|
||||||
callback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const frametime = (Number(duration) / timeBase.den) * timeBase.num * 1000;
|
|
||||||
const start_sendFrame = performance.now();
|
|
||||||
await this._sendFrame(Buffer.from(data), frametime);
|
|
||||||
const end_sendFrame = performance.now();
|
|
||||||
this._pts = (Number(pts) / timeBase.den) * timeBase.num * 1000;
|
|
||||||
this.emit("pts", this._pts);
|
|
||||||
const sendTime = end_sendFrame - start_sendFrame;
|
|
||||||
const ratio = sendTime / frametime;
|
|
||||||
if (ratio > 1) {
|
|
||||||
// Frame takes longer to send than its frametime — warn once per 100
|
|
||||||
if (
|
|
||||||
this._lastWarnedRatio === undefined ||
|
|
||||||
ratio > this._lastWarnedRatio
|
|
||||||
) {
|
|
||||||
this._lastWarnedRatio = ratio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._startTime ??= start_sendFrame;
|
|
||||||
this._startPts ??= this._pts;
|
|
||||||
const sleepMs = Math.max(
|
|
||||||
0,
|
|
||||||
this._pts -
|
|
||||||
this._startPts +
|
|
||||||
frametime -
|
|
||||||
(end_sendFrame - this._startTime),
|
|
||||||
);
|
|
||||||
if (this._noSleep || sleepMs === 0) {
|
|
||||||
callback(null);
|
|
||||||
} else if (this.sync && this.isBehind()) {
|
|
||||||
// Stream is behind — don't sleep for this frame
|
|
||||||
this.resetTimingCompensation();
|
|
||||||
callback(null);
|
|
||||||
} else if (this.sync && this.isAhead()) {
|
|
||||||
// Stream is ahead — wait until the sync stream catches up
|
|
||||||
do {
|
|
||||||
await sleep(frametime);
|
|
||||||
} while (this.sync && this.isAhead());
|
|
||||||
this.resetTimingCompensation();
|
|
||||||
callback(null);
|
|
||||||
} else {
|
|
||||||
await sleep(sleepMs);
|
|
||||||
callback(null);
|
|
||||||
}
|
|
||||||
frame.free?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastWarnedRatio: number | undefined;
|
|
||||||
|
|
||||||
_destroy(
|
|
||||||
error: Error | null,
|
|
||||||
callback: (error?: Error | null) => void,
|
|
||||||
): void {
|
|
||||||
super._destroy(error, callback);
|
|
||||||
this.syncStream = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/** Payload types for Discord GoLive media — ported from @dank074/discord-video-stream. */
|
|
||||||
export interface CodecPayloadTypeEntry {
|
|
||||||
name: string;
|
|
||||||
type: "audio" | "video";
|
|
||||||
clockRate: number;
|
|
||||||
priority: number;
|
|
||||||
payload_type: number;
|
|
||||||
rtx_payload_type?: number;
|
|
||||||
encode?: boolean;
|
|
||||||
decode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CodecPayloadType: Record<string, CodecPayloadTypeEntry> = {
|
|
||||||
opus: {
|
|
||||||
name: "opus",
|
|
||||||
type: "audio",
|
|
||||||
clockRate: 48000,
|
|
||||||
priority: 1000,
|
|
||||||
payload_type: 120,
|
|
||||||
},
|
|
||||||
H264: {
|
|
||||||
name: "H264",
|
|
||||||
type: "video",
|
|
||||||
clockRate: 90000,
|
|
||||||
priority: 1000,
|
|
||||||
payload_type: 101,
|
|
||||||
rtx_payload_type: 102,
|
|
||||||
encode: true,
|
|
||||||
decode: true,
|
|
||||||
},
|
|
||||||
H265: {
|
|
||||||
name: "H265",
|
|
||||||
type: "video",
|
|
||||||
clockRate: 90000,
|
|
||||||
priority: 1000,
|
|
||||||
payload_type: 103,
|
|
||||||
rtx_payload_type: 104,
|
|
||||||
encode: true,
|
|
||||||
decode: true,
|
|
||||||
},
|
|
||||||
VP8: {
|
|
||||||
name: "VP8",
|
|
||||||
type: "video",
|
|
||||||
clockRate: 90000,
|
|
||||||
priority: 1000,
|
|
||||||
payload_type: 105,
|
|
||||||
rtx_payload_type: 106,
|
|
||||||
encode: true,
|
|
||||||
decode: true,
|
|
||||||
},
|
|
||||||
VP9: {
|
|
||||||
name: "VP9",
|
|
||||||
type: "video",
|
|
||||||
clockRate: 90000,
|
|
||||||
priority: 1000,
|
|
||||||
payload_type: 107,
|
|
||||||
rtx_payload_type: 108,
|
|
||||||
encode: true,
|
|
||||||
decode: true,
|
|
||||||
},
|
|
||||||
AV1: {
|
|
||||||
name: "AV1",
|
|
||||||
type: "video",
|
|
||||||
clockRate: 90000,
|
|
||||||
priority: 1000,
|
|
||||||
payload_type: 109,
|
|
||||||
rtx_payload_type: 110,
|
|
||||||
encode: true,
|
|
||||||
decode: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,647 +0,0 @@
|
|||||||
/**
|
|
||||||
* Lightweight demuxer — replaces node-av's LibavDemuxer for GoLive.
|
|
||||||
*
|
|
||||||
* Spawns ffmpeg to remux input into H264 AnnexB on stdout (video only —
|
|
||||||
* screen share doesn't need to mux audio into the demuxer; audio goes
|
|
||||||
* separately). This replaces the 114MB node-av binary with a plain ffmpeg
|
|
||||||
* spawn.
|
|
||||||
*
|
|
||||||
* Each video "frame" emitted is a complete NAL sequence terminated by a
|
|
||||||
* keyframe boundary (IDR). Audio is not extracted here — for GoLive with
|
|
||||||
* audio, the NUT mux + full demuxer would be needed; screen share audio is
|
|
||||||
* handled via a separate ffmpeg instance (see getDirectScreenInput).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { existsSync, readdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { PassThrough } from "node:stream";
|
|
||||||
import type { GoLiveFrame } from "./BaseMediaStream.js";
|
|
||||||
|
|
||||||
/** 4-byte AnnexB start code (00 00 00 01) used when building access units. */
|
|
||||||
const startCode4 = Buffer.from([0, 0, 0, 1]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
data: Buffer | null;
|
|
||||||
pts: number;
|
|
||||||
duration: number;
|
|
||||||
timeBase: { num: number; den: number };
|
|
||||||
flags: number;
|
|
||||||
streamIndex: number;
|
|
||||||
free(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DemuxedStream {
|
|
||||||
codec: number;
|
|
||||||
codecName: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
framerate_num: number;
|
|
||||||
framerate_den: number;
|
|
||||||
sample_rate: number;
|
|
||||||
stream: PassThrough;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(FFMPEG, [
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"info",
|
|
||||||
"-i",
|
|
||||||
url,
|
|
||||||
"-f",
|
|
||||||
"null",
|
|
||||||
"-",
|
|
||||||
]);
|
|
||||||
let stderr = "";
|
|
||||||
proc.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
// 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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Demux input (URL string or readable stream) into video frames on a
|
|
||||||
* PassThrough. Streams input DIRECTLY into ffmpeg (no spool-to-file — the
|
|
||||||
* live NUT/H264 source never ends, so spooling deadlocks). ffmpeg emits
|
|
||||||
* AnnexB H264 on stdout; NAL units are split into frames on the fly.
|
|
||||||
* Video metadata is parsed from ffmpeg stderr during init.
|
|
||||||
*/
|
|
||||||
export async function demux(
|
|
||||||
input: string | PassThrough,
|
|
||||||
opts: { format: string; frameRate?: number },
|
|
||||||
): Promise<{
|
|
||||||
video: DemuxedStream | undefined;
|
|
||||||
audio: DemuxedStream | undefined;
|
|
||||||
close: () => void;
|
|
||||||
}> {
|
|
||||||
// objectMode pipes carrying one frame per item. HWM 2 keeps backpressure
|
|
||||||
// near-instant: at most ~1-2 frames in flight (~66ms @ 30fps) before the
|
|
||||||
// encoder is throttled, so the viewer sees near-live video instead of a
|
|
||||||
// multi-second backlog (the old HWM 128 held 128 frames ≈ 4.3s of lag).
|
|
||||||
// BaseMediaStream below has HWM 0, so the chain is tightly coupled to the
|
|
||||||
// WebRTC sender's real pace — faithful to @dank074/discord-video-stream.
|
|
||||||
const vPipe = new PassThrough({ objectMode: true, highWaterMark: 2 });
|
|
||||||
const aPipe = new PassThrough({ objectMode: true, highWaterMark: 2 });
|
|
||||||
|
|
||||||
const isStream = typeof input !== "string";
|
|
||||||
// NUT/matroska input (prepareStream with includeAudio) carries audio; the
|
|
||||||
// h264 path is video-only raw AnnexB. Video always goes to stdout (pipe:1);
|
|
||||||
// audio goes to fd3 (pipe:3) so stderr stays free for metadata parsing.
|
|
||||||
const containerFormat =
|
|
||||||
isStream && opts.format !== "h264" ? opts.format : null;
|
|
||||||
const withAudio = isStream && containerFormat !== null;
|
|
||||||
const args: string[] = [
|
|
||||||
"-hide_banner",
|
|
||||||
// info level: stream init lines ("Stream #0:0: Video: h264...") go to
|
|
||||||
// stderr and are parsed for dimensions/fps.
|
|
||||||
"-loglevel",
|
|
||||||
"info",
|
|
||||||
// Real-time throttle: read piped input at 1x so the WHOLE upstream chain
|
|
||||||
// (encoder x264, merge ffmpeg, yt-dlp download) is paced by wall-clock,
|
|
||||||
// not by network/VOD speed. Without this the encoder bursts ~10x faster
|
|
||||||
// than real-time and the vPipe queue grows unboundedly — the WebRTC
|
|
||||||
// sender correctly paces 30fps but always emits the OLDEST buffered
|
|
||||||
// frame, so video freezes/lags while audio (small, jitter-buffer
|
|
||||||
// recoverable) stays smooth. Pausing proc.stdout instead (previous fix)
|
|
||||||
// stalled the same ffmpeg's fd3 audio too → audio stuttered AND the
|
|
||||||
// already-built backlog never drained. `-re` fixes production rate at
|
|
||||||
// source; a bounded backlog below still protects against sender stalls.
|
|
||||||
...(isStream ? ["-re"] : []),
|
|
||||||
// Input format hint: raw H264 has NO magic header, so ffmpeg's
|
|
||||||
// auto-detection fails with "Invalid data found when processing input"
|
|
||||||
// whenever the first bytes arrive late/buffered. Pin the demuxer input
|
|
||||||
// format for streams (NUT for the audio-capable path).
|
|
||||||
...(withAudio
|
|
||||||
? ["-f", containerFormat as string]
|
|
||||||
: isStream
|
|
||||||
? ["-f", "h264"]
|
|
||||||
: []),
|
|
||||||
"-i",
|
|
||||||
isStream ? "pipe:0" : input,
|
|
||||||
"-map",
|
|
||||||
"0:v:0",
|
|
||||||
"-c:v",
|
|
||||||
"copy",
|
|
||||||
"-f",
|
|
||||||
"h264",
|
|
||||||
"pipe:1",
|
|
||||||
...(withAudio
|
|
||||||
? ["-map", "0:a:0?", "-c:a", "copy", "-f", "opus", "pipe:3"]
|
|
||||||
: ["-an"]),
|
|
||||||
];
|
|
||||||
const proc = spawn(FFMPEG, args, {
|
|
||||||
stdio: isStream
|
|
||||||
? withAudio
|
|
||||||
? ["pipe", "pipe", "pipe", "pipe"]
|
|
||||||
: ["pipe", "pipe", "pipe"]
|
|
||||||
: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
`[goLive:Demuxer] spawn ffmpeg pid=${proc.pid} input=${isStream ? "stream" : input} args=${args.join(" ")}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pipe live input straight into ffmpeg stdin — never await stream end.
|
|
||||||
if (isStream && proc.stdin) {
|
|
||||||
input.pipe(proc.stdin);
|
|
||||||
input.on("error", () => proc.stdin?.destroy());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio: ffmpeg writes Ogg Opus on fd3 (pipe:3). Parse OGG pages into
|
|
||||||
// opus packets and emit them as GoLiveFrames (20ms, 48kHz) on aPipe.
|
|
||||||
if (withAudio && proc.stdio[3]) {
|
|
||||||
createOggOpusDemux(
|
|
||||||
proc.stdio[3] as unknown as NodeJS.ReadableStream,
|
|
||||||
aPipe,
|
|
||||||
);
|
|
||||||
console.log("[goLive:Demuxer] audio pipe wired (fd3 → Ogg Opus → aPipe)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track which stream kinds we've seen. We must NOT stop parsing on the
|
|
||||||
// first stream found: ffmpeg can print the video line and audio line in
|
|
||||||
// separate stderr chunks (input arrives slowly), and the old
|
|
||||||
// early-return dropped the audio line forever
|
|
||||||
// → aInfo undefined → no audio RTP → static GoLive tile.
|
|
||||||
let seenVideo = false;
|
|
||||||
let seenAudio = false;
|
|
||||||
|
|
||||||
// Parse stream metadata from ffmpeg stderr as it arrives (first chunk has
|
|
||||||
// the init lines). Fall back to H264 defaults if parsing fails.
|
|
||||||
let vInfo: DemuxedStream = {
|
|
||||||
codec: AVCodecID.AV_CODEC_ID_H264,
|
|
||||||
codecName: "h264",
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
framerate_num: 0,
|
|
||||||
framerate_den: 1,
|
|
||||||
sample_rate: 0,
|
|
||||||
stream: vPipe,
|
|
||||||
};
|
|
||||||
let aInfo: DemuxedStream | undefined;
|
|
||||||
// With audio expected (NUT input), ALWAYS expose an audio stream even if
|
|
||||||
// ffmpeg's audio init line hasn't arrived in stderr yet. prepareStream
|
|
||||||
// encodes libopus into the NUT unconditionally (`-map 0:a:0? -c:a libopus`),
|
|
||||||
// so fd3 WILL carry Ogg Opus — aInfo must not stay undefined just because
|
|
||||||
// the metadata line raced the resolve. The stderr handler below upgrades
|
|
||||||
// this default with real sample_rate metadata when the line lands.
|
|
||||||
if (withAudio) {
|
|
||||||
aInfo = {
|
|
||||||
codec: AVCodecID.AV_CODEC_ID_OPUS,
|
|
||||||
codecName: "opus",
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
framerate_num: 0,
|
|
||||||
framerate_den: 0,
|
|
||||||
sample_rate: 48000,
|
|
||||||
stream: aPipe,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let stderrBuf = "";
|
|
||||||
if (proc.stderr) {
|
|
||||||
proc.stderr.on("data", (d: Buffer) => {
|
|
||||||
const text = d.toString();
|
|
||||||
stderrBuf = (stderrBuf + text).slice(-16384);
|
|
||||||
// Surface actionable lines: ffmpeg errors + stream init lines
|
|
||||||
if (/error|invalid|no such|failed|cannot|not found|unable/i.test(text)) {
|
|
||||||
console.log(
|
|
||||||
`[goLive:Demuxer] ffmpeg stderr: ${text.trim().split("\n").slice(0, 4).join(" | ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const streamRe = /Stream #0:(\d+): (Video|Audio): ([^,]+)/g;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
const found: Array<{ kind: string; codecRaw: string }> = [];
|
|
||||||
// biome-ignore lint/suspicious/noAssignInExpressions: regex loop idiom
|
|
||||||
while ((m = streamRe.exec(stderrBuf)) !== null) {
|
|
||||||
found.push({ kind: m[2], codecRaw: m[3] });
|
|
||||||
}
|
|
||||||
if (process.env.GMW_DEMUX_DEBUG) {
|
|
||||||
console.log(
|
|
||||||
`[goLive:Demuxer] DEBUG stderrBuf=${JSON.stringify(stderrBuf.slice(0, 300))} found=${JSON.stringify(found)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const v = found.find((s) => s.kind === "Video");
|
|
||||||
const a = found.find((s) => s.kind === "Audio");
|
|
||||||
if (v) {
|
|
||||||
seenVideo = true;
|
|
||||||
const codecName = v.codecRaw.split(" ")[0].toLowerCase();
|
|
||||||
const dim = /(\d{2,5})x(\d{2,5})/.exec(stderrBuf);
|
|
||||||
const fps = /(\d+(?:\.\d+)?) fps/.exec(stderrBuf);
|
|
||||||
vInfo = {
|
|
||||||
codec:
|
|
||||||
AVCodecID[
|
|
||||||
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
|
|
||||||
"AV_CODEC_ID_H264"
|
|
||||||
] ?? AVCodecID.AV_CODEC_ID_H264,
|
|
||||||
codecName,
|
|
||||||
width: dim ? Number(dim[1]) : 0,
|
|
||||||
height: dim ? Number(dim[2]) : 0,
|
|
||||||
framerate_num: fps ? Math.round(Number(fps[1]) * 1000) : 0,
|
|
||||||
framerate_den: fps ? 1000 : 1,
|
|
||||||
sample_rate: 0,
|
|
||||||
stream: vPipe,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (a) {
|
|
||||||
seenAudio = true;
|
|
||||||
const codecName = a.codecRaw.split(" ")[0].toLowerCase();
|
|
||||||
const sr = /(\d+) Hz/.exec(stderrBuf);
|
|
||||||
aInfo = {
|
|
||||||
codec:
|
|
||||||
AVCodecID[
|
|
||||||
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
|
|
||||||
"AV_CODEC_ID_OPUS"
|
|
||||||
] ?? AVCodecID.AV_CODEC_ID_OPUS,
|
|
||||||
codecName,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
framerate_num: 0,
|
|
||||||
framerate_den: 0,
|
|
||||||
sample_rate: sr ? Number(sr[1]) : 0,
|
|
||||||
stream: aPipe,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Mark that at least one stream kind was seen. Note: we must NOT
|
|
||||||
// resolve the metadata wait on the FIRST stream kind alone. With live
|
|
||||||
// NUT input, ffmpeg can print the video init line in one stderr chunk
|
|
||||||
// and the audio init line in the NEXT chunk (NUT info-stream packets
|
|
||||||
// arrive as ffmpeg reads them from the pipe). The old code returned
|
|
||||||
// immediately on `parsedMeta=true` — the audio line then landed in the
|
|
||||||
// handler AFTER `return { audio: aInfo }` had already captured
|
|
||||||
// `undefined` → no audio RTP → static GoLive tile even though the NUT
|
|
||||||
// carried audio. Wait for BOTH kinds (when audio is expected).
|
|
||||||
// (parsedMeta removed — we now wait for both via allSeen() below.)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait (briefly) for ffmpeg to print its stream init lines on stderr so
|
|
||||||
// vInfo/aInfo carry real metadata. With audio expected, wait for BOTH the
|
|
||||||
// video and audio init lines (they may arrive in separate stderr chunks on
|
|
||||||
// live input); the timeout covers slow starts / genuinely audio-less input.
|
|
||||||
const allSeen = () =>
|
|
||||||
withAudio ? seenVideo && seenAudio : seenVideo || seenAudio;
|
|
||||||
await Promise.race([
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
const check = setInterval(() => {
|
|
||||||
if (allSeen()) {
|
|
||||||
clearInterval(check);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, 25);
|
|
||||||
}),
|
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, 3000)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Scan stdout for AnnexB NAL units and group them into ACCESS UNITS
|
|
||||||
// (one picture). Discord's H264 decoder requires a complete access unit —
|
|
||||||
// parameter sets + slice — inside a single RTP frame. Emitting each NAL
|
|
||||||
// as its own frame (SPS/PPS/SEI separate from the slice) makes the decoder
|
|
||||||
// unable to produce ANY picture: production showed a black GoLive tile
|
|
||||||
// despite frames flowing (5892B slices + 4B PPS + 33B SPS as separate
|
|
||||||
// frames, each with a near-zero RTP timestamp delta). We therefore buffer
|
|
||||||
// NALs and flush one frame per slice, prepending the parameter sets that
|
|
||||||
// precede it, and timestamp it as ONE frame at the video frame rate.
|
|
||||||
|
|
||||||
// EMISSION: faithful to @dank074/discord-video-stream — the demuxer does NOT
|
|
||||||
// pace. It writes each access unit straight to vPipe (objectMode, HWM 128)
|
|
||||||
// with a monotonically increasing PTS; BaseMediaStream (ported 1:1 from dank)
|
|
||||||
// then paces playback via sleep-PTS + A/V sync and applies backpressure when
|
|
||||||
// the WebRTC sender can't keep up. This is what works upstream; the custom
|
|
||||||
// setInterval/tail-drop clocks we tried broke IDR delivery → blank tiles.
|
|
||||||
let videoBuf = Buffer.alloc(0);
|
|
||||||
let frameCount = 0;
|
|
||||||
let pendingNals: Buffer[] = [];
|
|
||||||
let pendingHasSlice = false;
|
|
||||||
let pendingIsKey = false;
|
|
||||||
// Raw H264 streams carry no timing info — ffmpeg's h264 demuxer guesses
|
|
||||||
// 25fps on stderr. Prefer the caller's explicit frameRate (the encode
|
|
||||||
// setting); it drives both RTP timestamp advance and pacing.
|
|
||||||
const videoFps =
|
|
||||||
opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30);
|
|
||||||
|
|
||||||
// Write one frame to the video pipe with backpressure: if the pipe buffer is
|
|
||||||
// full, pause ffmpeg's stdout so the encoder self-throttles (instead of
|
|
||||||
// building an unbounded backlog). Resumed on drain. Faithful to dank's
|
|
||||||
// `resume &&= vPipe.write(packet)` in LibavDemuxer.
|
|
||||||
const writeFrame = (frame: {
|
|
||||||
data: Buffer;
|
|
||||||
pts: number;
|
|
||||||
duration: number;
|
|
||||||
timeBase: { num: number; den: number };
|
|
||||||
flags: number;
|
|
||||||
streamIndex: number;
|
|
||||||
free: () => void;
|
|
||||||
}): void => {
|
|
||||||
const ok = vPipe.write(frame);
|
|
||||||
if (!ok) proc.stdout?.pause();
|
|
||||||
};
|
|
||||||
|
|
||||||
const flushAccessUnit = () => {
|
|
||||||
if (pendingNals.length === 0) return;
|
|
||||||
// AnnexB access unit: 00 00 00 01 + NAL for every buffered NAL. The
|
|
||||||
// packetizer (H264RtpPacketizer, StartSequence separator) needs the
|
|
||||||
// start codes to find NAL boundaries inside the frame.
|
|
||||||
const parts: Buffer[] = [];
|
|
||||||
for (const n of pendingNals) parts.push(startCode4, n);
|
|
||||||
const au = Buffer.concat(parts);
|
|
||||||
const isKey = pendingIsKey;
|
|
||||||
pendingNals = [];
|
|
||||||
pendingHasSlice = false;
|
|
||||||
pendingIsKey = false;
|
|
||||||
// Write directly to the video pipe (with backpressure via writeFrame).
|
|
||||||
// PTS advances one frame per output frame at videoFps (duration=1 in a
|
|
||||||
// 1/fps timebase) so BaseMediaStream computes the correct frametime and the
|
|
||||||
// WebRTC RTP timestamp advances by clockRate/fps per frame (3000 @ 30fps /
|
|
||||||
// 90kHz). Keyframes carry AV_PKT_FLAG_KEY so the decoder re-establishes a
|
|
||||||
// reference.
|
|
||||||
writeFrame({
|
|
||||||
data: au,
|
|
||||||
pts: frameCount,
|
|
||||||
duration: 1,
|
|
||||||
timeBase: { num: 1, den: videoFps },
|
|
||||||
flags: isKey ? AV_PKT_FLAG_KEY : 0,
|
|
||||||
streamIndex: 0,
|
|
||||||
free: () => {},
|
|
||||||
});
|
|
||||||
frameCount++;
|
|
||||||
if (frameCount === 1 || frameCount % 30 === 0) {
|
|
||||||
console.log(
|
|
||||||
`[goLive:Demuxer] frames=${frameCount} last=${au.length}B key=${isKey}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (proc.stdout) {
|
|
||||||
vPipe.on("drain", () => proc.stdout?.resume());
|
|
||||||
proc.stdout.on("data", (chunk: Buffer) => {
|
|
||||||
videoBuf = Buffer.concat([videoBuf, chunk]);
|
|
||||||
// Find start codes (00 00 01 or 00 00 00 01) and split NALs
|
|
||||||
let start = 0;
|
|
||||||
// If buffer starts with zeros, that's the first start code — emit from there
|
|
||||||
while (start < videoBuf.length) {
|
|
||||||
let scPos = -1;
|
|
||||||
for (let i = start + 1; i < videoBuf.length - 2; i++) {
|
|
||||||
if (
|
|
||||||
videoBuf[i] === 0 &&
|
|
||||||
videoBuf[i + 1] === 0 &&
|
|
||||||
videoBuf[i + 2] === 1
|
|
||||||
) {
|
|
||||||
scPos = i + 3;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (scPos === -1) break;
|
|
||||||
// Emit the NAL from `start` to `scPos` (but skip the start code bytes at `start`)
|
|
||||||
if (start < scPos) {
|
|
||||||
let nalStart = start;
|
|
||||||
// Skip start code bytes for the NAL itself (00 00 01)
|
|
||||||
if (
|
|
||||||
videoBuf[nalStart] === 0 &&
|
|
||||||
videoBuf[nalStart + 1] === 0 &&
|
|
||||||
videoBuf[nalStart + 2] === 1
|
|
||||||
) {
|
|
||||||
nalStart += 3;
|
|
||||||
} else if (
|
|
||||||
nalStart + 3 < scPos &&
|
|
||||||
videoBuf[nalStart] === 0 &&
|
|
||||||
videoBuf[nalStart + 1] === 0 &&
|
|
||||||
videoBuf[nalStart + 2] === 0 &&
|
|
||||||
videoBuf[nalStart + 3] === 1
|
|
||||||
) {
|
|
||||||
nalStart += 4;
|
|
||||||
}
|
|
||||||
const nal = videoBuf.subarray(nalStart, scPos);
|
|
||||||
// Trim trailing zero bytes (from start code overlap)
|
|
||||||
let end = nal.length;
|
|
||||||
while (end > 0 && nal[end - 1] === 0) end--;
|
|
||||||
if (end > 0) {
|
|
||||||
const nalTrimmed = nal.subarray(0, end);
|
|
||||||
const nalType = nalTrimmed[0] & 0x1f;
|
|
||||||
const isSlice = nalType === 1 || nalType === 5;
|
|
||||||
if (isSlice) {
|
|
||||||
// A new slice while one is pending closes the previous
|
|
||||||
// access unit (x264 emits one slice per frame).
|
|
||||||
if (pendingHasSlice) flushAccessUnit();
|
|
||||||
pendingNals.push(Buffer.from(nalTrimmed));
|
|
||||||
pendingHasSlice = true;
|
|
||||||
if (nalType === 5) pendingIsKey = true;
|
|
||||||
} else {
|
|
||||||
// Parameter-set / SEI / AUD / filler NAL. After a slice these
|
|
||||||
// belong to the NEXT access unit — flush the completed frame.
|
|
||||||
if (pendingHasSlice) flushAccessUnit();
|
|
||||||
pendingNals.push(Buffer.from(nalTrimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Skip the 00 00 01 at scPos-3 to find next
|
|
||||||
start = scPos;
|
|
||||||
// But the next start code needs at least 3 bytes
|
|
||||||
if (start > videoBuf.length - 3) break;
|
|
||||||
}
|
|
||||||
// Keep remaining bytes (potential partial NAL or start code)
|
|
||||||
if (start > 0 && start < videoBuf.length) {
|
|
||||||
videoBuf = videoBuf.subarray(start);
|
|
||||||
} else if (videoBuf.length > 4) {
|
|
||||||
// No full NAL found, but avoid unbounded growth
|
|
||||||
// Keep a sliding window
|
|
||||||
videoBuf = videoBuf.subarray(videoBuf.length - 3);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Backpressure (faithful to dank's `resume &&= vPipe.write`): when the
|
|
||||||
// downstream pipe's buffer is full, stop reading from ffmpeg's stdout so
|
|
||||||
// the encoder self-throttles instead of building an unbounded backlog.
|
|
||||||
// Resume on drain.
|
|
||||||
vPipe.on("drain", () => proc.stdout?.resume());
|
|
||||||
proc.stdout.on("end", () => {
|
|
||||||
flushAccessUnit();
|
|
||||||
vPipe.end();
|
|
||||||
aPipe.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.on("close", () => {
|
|
||||||
vPipe.end();
|
|
||||||
aPipe.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
proc.kill("SIGTERM");
|
|
||||||
vPipe.end();
|
|
||||||
aPipe.end();
|
|
||||||
};
|
|
||||||
|
|
||||||
return { video: vInfo, audio: aInfo, close };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse an Ogg Opus byte stream (as written by ffmpeg's `-f opus` muxer)
|
|
||||||
* into individual opus packets and push them onto `out` as GoLiveFrames
|
|
||||||
* (duration 960 @ 48kHz = 20ms, matching the OpusRtpPacketizer clock).
|
|
||||||
*
|
|
||||||
* OGG page structure:
|
|
||||||
* "OggS" | ver(1) | header_type(1) | granule(8 LE) | serial(4) | seq(4) |
|
|
||||||
* crc(4) | page_segments(1) | segment_table[n] | payload
|
|
||||||
* Lacing: a value < 255 ends a packet; 255 continues it (0.5KB chunk).
|
|
||||||
* The first packet is OpusHead (19B) — skipped, as is OpusTags.
|
|
||||||
*/
|
|
||||||
function createOggOpusDemux(
|
|
||||||
input: NodeJS.ReadableStream,
|
|
||||||
out: PassThrough,
|
|
||||||
): void {
|
|
||||||
let buf = Buffer.alloc(0);
|
|
||||||
// Packets assembled from lacing; packetParts accumulates across pages
|
|
||||||
// when a packet spans a page boundary (continued flag / 255 lacing).
|
|
||||||
let packetParts: Buffer[] = [];
|
|
||||||
let headerDone = false;
|
|
||||||
let frameIndex = 0;
|
|
||||||
|
|
||||||
const emitPacket = (packet: Buffer) => {
|
|
||||||
if (!headerDone) {
|
|
||||||
// First packet = OpusHead ("OpusHead"), second = OpusTags. Skip both.
|
|
||||||
const magic = packet.toString("latin1", 0, 8);
|
|
||||||
if (magic === "OpusHead" || magic === "OpusTags") return;
|
|
||||||
headerDone = true;
|
|
||||||
}
|
|
||||||
out.write({
|
|
||||||
data: packet,
|
|
||||||
pts: frameIndex * 960,
|
|
||||||
duration: 960,
|
|
||||||
timeBase: { num: 1, den: 48000 },
|
|
||||||
free: () => {},
|
|
||||||
} satisfies GoLiveFrame);
|
|
||||||
frameIndex++;
|
|
||||||
};
|
|
||||||
|
|
||||||
const processPages = () => {
|
|
||||||
while (true) {
|
|
||||||
// Sync to "OggS"
|
|
||||||
const sync = buf.indexOf("OggS", 0, "latin1");
|
|
||||||
if (sync === -1) {
|
|
||||||
// Keep the tail (partial sync pattern) for the next chunk
|
|
||||||
buf = buf.length > 3 ? buf.subarray(buf.length - 3) : buf;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sync > 0) buf = buf.subarray(sync);
|
|
||||||
if (buf.length < 27) return; // need full page header
|
|
||||||
const numSeg = buf[26];
|
|
||||||
if (buf.length < 27 + numSeg) return; // need segment table
|
|
||||||
let payloadLen = 0;
|
|
||||||
for (let i = 0; i < numSeg; i++) payloadLen += buf[27 + i];
|
|
||||||
if (buf.length < 27 + numSeg + payloadLen) return; // need payload
|
|
||||||
|
|
||||||
const headerType = buf[5];
|
|
||||||
// Extract packets from the payload using lacing values
|
|
||||||
let off = 27 + numSeg;
|
|
||||||
for (let i = 0; i < numSeg; i++) {
|
|
||||||
const lace = buf[27 + i];
|
|
||||||
const part = buf.subarray(off, off + lace);
|
|
||||||
off += lace;
|
|
||||||
packetParts.push(Buffer.from(part));
|
|
||||||
if (lace < 255) {
|
|
||||||
const packet = Buffer.concat(packetParts);
|
|
||||||
packetParts = [];
|
|
||||||
if ((headerType & 0x01) === 0) {
|
|
||||||
// Not a continuation page → packet starts here
|
|
||||||
emitPacket(packet);
|
|
||||||
} else if (headerDone) {
|
|
||||||
// Continued page — packet body, emit directly
|
|
||||||
emitPacket(packet);
|
|
||||||
}
|
|
||||||
// (header packets on continuation pages are dropped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf = buf.subarray(off);
|
|
||||||
if (buf.length === 0) return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
input.on("data", (chunk: Buffer) => {
|
|
||||||
buf = Buffer.concat([buf, chunk]);
|
|
||||||
processPages();
|
|
||||||
});
|
|
||||||
input.on("end", () => {
|
|
||||||
out.end();
|
|
||||||
});
|
|
||||||
input.on("error", () => {
|
|
||||||
out.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* Lightweight encoders config — ported from @dank074/discord-video-stream
|
|
||||||
* encoders/software.js. Only software (libx264) is needed for GoLive.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface EncoderSettings {
|
|
||||||
name: string;
|
|
||||||
options: string[];
|
|
||||||
outFilters?: string[];
|
|
||||||
globalOptions?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EncoderSet {
|
|
||||||
H264: EncoderSettings;
|
|
||||||
H265: EncoderSettings;
|
|
||||||
VP8: EncoderSettings;
|
|
||||||
VP9: EncoderSettings;
|
|
||||||
AV1: EncoderSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Software x264 encoder. Matches @dank074's software() defaults. */
|
|
||||||
export function software(
|
|
||||||
opts: {
|
|
||||||
x264?: { preset?: string; tune?: string };
|
|
||||||
x265?: { preset?: string; tune?: string };
|
|
||||||
} = {},
|
|
||||||
): () => EncoderSet {
|
|
||||||
const { x264, x265 } = opts;
|
|
||||||
const { preset: x264Preset = "superfast", tune: x264Tune = "zerolatency" } =
|
|
||||||
x264 ?? {};
|
|
||||||
const { preset: x265Preset = "superfast", tune: x265Tune } = x265 ?? {};
|
|
||||||
return () => ({
|
|
||||||
H264: {
|
|
||||||
name: "libx264",
|
|
||||||
// -profile:v baseline is REQUIRED: the SDP advertises
|
|
||||||
// profile-level-id=42e01f (constrained baseline) and Discord's
|
|
||||||
// receiver decodes with that profile. x264's default is High — a
|
|
||||||
// High-profile bitstream against a baseline SDP negotiation fails to
|
|
||||||
// decode → black GoLive tile (production bug, fixed 2026-08-12).
|
|
||||||
// zerolatency matches @dank074 (no lookahead — correct for live).
|
|
||||||
options: [
|
|
||||||
"-forced-idr 1",
|
|
||||||
"-profile:v baseline",
|
|
||||||
// repeat-headers: SPS/PPS inline BEFORE EVERY IDR, not just the
|
|
||||||
// first. Required for the NUT container path (NUT stores extradata
|
|
||||||
// in the header and `-c:v copy` remux loses it → decoder sees
|
|
||||||
// "non-existing PPS 0 referenced" → no picture) and lets Discord's
|
|
||||||
// decoder recover after any PLI/keyframe request mid-stream.
|
|
||||||
"-x264-params",
|
|
||||||
"repeat-headers=1",
|
|
||||||
`-tune ${x264Tune}`,
|
|
||||||
`-preset ${x264Preset}`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
H265: {
|
|
||||||
name: "libx265",
|
|
||||||
options: [
|
|
||||||
"-forced-idr 1",
|
|
||||||
...(x265Tune ? [`-tune ${x265Tune}`] : []),
|
|
||||||
`-preset ${x265Preset}`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
VP8: { name: "libvpx", options: ["-deadline 20000"] },
|
|
||||||
VP9: { name: "libvpx-vp9", options: ["-deadline 20000"] },
|
|
||||||
AV1: { name: "libsvtav1", options: [] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Encoders = { software };
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/** Discord gateway opcodes used by Streamer — ported from @dank074/discord-video-stream. */
|
|
||||||
export enum GatewayOpCodes {
|
|
||||||
DISPATCH = 0,
|
|
||||||
HEARTBEAT = 1,
|
|
||||||
IDENTIFY = 2,
|
|
||||||
PRESENCE_UPDATE = 3,
|
|
||||||
VOICE_STATE_UPDATE = 4,
|
|
||||||
VOICE_SERVER_PING = 5,
|
|
||||||
RESUME = 6,
|
|
||||||
RECONNECT = 7,
|
|
||||||
REQUEST_GUILD_MEMBERS = 8,
|
|
||||||
INVALID_SESSION = 9,
|
|
||||||
HELLO = 10,
|
|
||||||
HEARTBEAT_ACK = 11,
|
|
||||||
CALL_CONNECT = 13,
|
|
||||||
GUILD_SUBSCRIPTIONS = 14,
|
|
||||||
LOBBY_CONNECT = 15,
|
|
||||||
LOBBY_DISCONNECT = 16,
|
|
||||||
LOBBY_VOICE_STATES_UPDATE = 17,
|
|
||||||
STREAM_CREATE = 18,
|
|
||||||
STREAM_DELETE = 19,
|
|
||||||
STREAM_WATCH = 20,
|
|
||||||
STREAM_PING = 21,
|
|
||||||
STREAM_SET_PAUSED = 22,
|
|
||||||
REQUEST_GUILD_APPLICATION_COMMANDS = 24,
|
|
||||||
EMBEDDED_ACTIVITY_LAUNCH = 25,
|
|
||||||
EMBEDDED_ACTIVITY_CLOSE = 26,
|
|
||||||
EMBEDDED_ACTIVITY_UPDATE = 27,
|
|
||||||
REQUEST_FORUM_UNREADS = 28,
|
|
||||||
REMOTE_COMMAND = 29,
|
|
||||||
GET_DELETED_ENTITY_IDS_NOT_MATCHING_HASH = 30,
|
|
||||||
REQUEST_SOUNDBOARD_SOUNDS = 31,
|
|
||||||
SPEED_TEST_CREATE = 32,
|
|
||||||
SPEED_TEST_DELETE = 33,
|
|
||||||
REQUEST_LAST_MESSAGES = 34,
|
|
||||||
SEARCH_RECENT_MEMBERS = 35,
|
|
||||||
REQUEST_CHANNEL_STATUSES = 36,
|
|
||||||
GUILD_SUBSCRIPTIONS_BULK = 37,
|
|
||||||
GUILD_CHANNELS_RESYNC = 38,
|
|
||||||
REQUEST_CHANNEL_MEMBER_COUNT = 39,
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
/**
|
|
||||||
* H264 SPS VUI rewriter — ported from @dank074/discord-video-stream
|
|
||||||
* SPSVUIRewriter.js. Rewrites the SPS so Discord's receiver applies
|
|
||||||
* bitstream restrictions (max_num_reorder_frames=0, max_dec_frame_buffering
|
|
||||||
* bounded) — required for low-latency GoLive decode.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
AnnexBBitstreamReader,
|
|
||||||
AnnexBBitstreamWriter,
|
|
||||||
} from "./AnnexBBitstreamReaderWriter.js";
|
|
||||||
|
|
||||||
export function rewriteSPSVUI(buffer: Uint8Array): Buffer {
|
|
||||||
const reader = new AnnexBBitstreamReader(buffer.subarray(1));
|
|
||||||
const writer = new AnnexBBitstreamWriter();
|
|
||||||
const readBit = (n = 1) => reader.readBits(n);
|
|
||||||
const writeBit = (v: number, n = 1) => writer.writeBits(v, n);
|
|
||||||
const readU = (n: number) => reader.readUnsigned(n);
|
|
||||||
const writeU = (v: number, n: number) => writer.writeUnsigned(v, n);
|
|
||||||
const readUE = () => reader.readUnsignedExpGolomb();
|
|
||||||
const writeUE = (v: number) => writer.writeUnsignedExpGolomb(v);
|
|
||||||
const readSE = () => reader.readSignedExpGolomb();
|
|
||||||
const writeSE = (v: number) => writer.writeSignedExpGolomb(v);
|
|
||||||
|
|
||||||
// Rewrite the NAL header
|
|
||||||
writeU(buffer[0], 8);
|
|
||||||
const profile_idc = readU(8);
|
|
||||||
writeU(profile_idc, 8);
|
|
||||||
const constraint_flags = readU(8);
|
|
||||||
writeU(constraint_flags, 8);
|
|
||||||
const level_idc = readU(8);
|
|
||||||
writeU(level_idc, 8);
|
|
||||||
const seq_parameter_set_id = readUE();
|
|
||||||
writeUE(seq_parameter_set_id);
|
|
||||||
|
|
||||||
// If profile in high profiles, additional fields
|
|
||||||
const highProfiles = new Set([
|
|
||||||
100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 144,
|
|
||||||
]);
|
|
||||||
if (highProfiles.has(profile_idc)) {
|
|
||||||
const chroma_format_idc = readUE();
|
|
||||||
writeUE(chroma_format_idc);
|
|
||||||
if (chroma_format_idc === 3) {
|
|
||||||
const separate_colour_plane_flag = readBit(1);
|
|
||||||
writeBit(separate_colour_plane_flag, 1);
|
|
||||||
}
|
|
||||||
const bit_depth_luma_minus8 = readUE();
|
|
||||||
writeUE(bit_depth_luma_minus8);
|
|
||||||
const bit_depth_chroma_minus8 = readUE();
|
|
||||||
writeUE(bit_depth_chroma_minus8);
|
|
||||||
const qpprime_y_zero_transform_bypass_flag = readBit(1);
|
|
||||||
writeBit(qpprime_y_zero_transform_bypass_flag, 1);
|
|
||||||
const seq_scaling_matrix_present_flag = readBit(1);
|
|
||||||
writeBit(seq_scaling_matrix_present_flag, 1);
|
|
||||||
if (seq_scaling_matrix_present_flag) {
|
|
||||||
const scalingCount = chroma_format_idc !== 3 ? 8 : 12;
|
|
||||||
for (let i = 0; i < scalingCount; i++) {
|
|
||||||
const seq_scaling_list_present_flag = readBit(1);
|
|
||||||
writeBit(seq_scaling_list_present_flag, 1);
|
|
||||||
if (seq_scaling_list_present_flag) {
|
|
||||||
const size = i < 6 ? 16 : 64;
|
|
||||||
let lastScale = 8;
|
|
||||||
let nextScale = 8;
|
|
||||||
for (let j = 0; j < size; j++) {
|
|
||||||
const delta = readSE();
|
|
||||||
writeSE(delta);
|
|
||||||
nextScale = (lastScale + delta + 256) % 256;
|
|
||||||
if (nextScale !== 0) lastScale = nextScale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const log2_max_frame_num_minus4 = readUE();
|
|
||||||
writeUE(log2_max_frame_num_minus4);
|
|
||||||
const pic_order_cnt_type = readUE();
|
|
||||||
writeUE(pic_order_cnt_type);
|
|
||||||
if (pic_order_cnt_type === 0) {
|
|
||||||
const log2_max_pic_order_cnt_lsb_minus4 = readUE();
|
|
||||||
writeUE(log2_max_pic_order_cnt_lsb_minus4);
|
|
||||||
} else if (pic_order_cnt_type === 1) {
|
|
||||||
const delta_pic_order_always_zero_flag = readBit(1);
|
|
||||||
writeBit(delta_pic_order_always_zero_flag, 1);
|
|
||||||
const offset_for_non_ref_pic = readSE();
|
|
||||||
writeSE(offset_for_non_ref_pic);
|
|
||||||
const offset_for_top_to_bottom_field = readSE();
|
|
||||||
writeSE(offset_for_top_to_bottom_field);
|
|
||||||
const num_ref_frames_in_pic_order_cnt_cycle = readUE();
|
|
||||||
writeUE(num_ref_frames_in_pic_order_cnt_cycle);
|
|
||||||
for (let i = 0; i < num_ref_frames_in_pic_order_cnt_cycle; i++) {
|
|
||||||
const offset_for_ref_frame = readSE();
|
|
||||||
writeSE(offset_for_ref_frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const max_num_ref_frames = readUE();
|
|
||||||
writeUE(max_num_ref_frames);
|
|
||||||
const gaps_in_frame_num_value_allowed_flag = readBit(1);
|
|
||||||
writeBit(gaps_in_frame_num_value_allowed_flag, 1);
|
|
||||||
const pic_width_in_mbs_minus1 = readUE();
|
|
||||||
writeUE(pic_width_in_mbs_minus1);
|
|
||||||
const pic_height_in_map_units_minus1 = readUE();
|
|
||||||
writeUE(pic_height_in_map_units_minus1);
|
|
||||||
const frame_mbs_only_flag = readBit(1);
|
|
||||||
writeBit(frame_mbs_only_flag, 1);
|
|
||||||
if (frame_mbs_only_flag === 0) {
|
|
||||||
const mb_adaptive_frame_field_flag = readBit(1);
|
|
||||||
writeBit(mb_adaptive_frame_field_flag, 1);
|
|
||||||
}
|
|
||||||
const direct_8x8_inference_flag = readBit(1);
|
|
||||||
writeBit(direct_8x8_inference_flag, 1);
|
|
||||||
const frame_cropping_flag = readBit(1);
|
|
||||||
writeBit(frame_cropping_flag, 1);
|
|
||||||
if (frame_cropping_flag) {
|
|
||||||
const frame_crop_left_offset = readUE();
|
|
||||||
writeUE(frame_crop_left_offset);
|
|
||||||
const frame_crop_right_offset = readUE();
|
|
||||||
writeUE(frame_crop_right_offset);
|
|
||||||
const frame_crop_top_offset = readUE();
|
|
||||||
writeUE(frame_crop_top_offset);
|
|
||||||
const frame_crop_bottom_offset = readUE();
|
|
||||||
writeUE(frame_crop_bottom_offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://webrtc.googlesource.com/src/+/5f2c9278f35e47ff72eb191669d473b7400c9f3e/common_video/h264/sps_vui_rewriter.cc#283
|
|
||||||
function addBitstreamRestriction() {
|
|
||||||
// motion_vectors_over_pic_boundaries_flag: u(1) — Default is 1 when not present.
|
|
||||||
writeBit(1, 1);
|
|
||||||
// max_bytes_per_pic_denom: ue(v) — Default is 2 when not present.
|
|
||||||
writeUE(2);
|
|
||||||
// max_bits_per_mb_denom: ue(v) — Default is 1 when not present.
|
|
||||||
writeUE(1);
|
|
||||||
// log2_max_mv_length_horizontal / vertical — both default to 16.
|
|
||||||
writeUE(16);
|
|
||||||
writeUE(16);
|
|
||||||
// IMPORTANT: max_num_reorder_frames must be 0 for low latency.
|
|
||||||
writeUE(0);
|
|
||||||
writeUE(max_num_ref_frames);
|
|
||||||
}
|
|
||||||
|
|
||||||
const vui_parameters_present_flag = readBit(1);
|
|
||||||
writeBit(1, 1);
|
|
||||||
// If no VUI exists, write one
|
|
||||||
if (!vui_parameters_present_flag) {
|
|
||||||
// aspect_ratio_info_present_flag, overscan_info_present_flag. Both u(1).
|
|
||||||
writeBit(0, 2);
|
|
||||||
// video_signal_type_present_flag, u(1) — write 0, ignore color space.
|
|
||||||
writeBit(0, 1);
|
|
||||||
// chroma_loc_info_present_flag, timing_info_present_flag,
|
|
||||||
// nal_hrd_parameters_present_flag, vcl_hrd_parameters_present_flag,
|
|
||||||
// pic_struct_present_flag — all u(1)
|
|
||||||
writeBit(0, 5);
|
|
||||||
// bitstream_restriction_flag: u(1)
|
|
||||||
writeBit(1, 1);
|
|
||||||
addBitstreamRestriction();
|
|
||||||
} else {
|
|
||||||
// VUI parsing and copying
|
|
||||||
const aspect_ratio_info_present_flag = readBit(1);
|
|
||||||
writeBit(aspect_ratio_info_present_flag, 1);
|
|
||||||
if (aspect_ratio_info_present_flag) {
|
|
||||||
const aspect_ratio_idc = readU(8);
|
|
||||||
writeU(aspect_ratio_idc, 8);
|
|
||||||
if (aspect_ratio_idc === 255) {
|
|
||||||
const sar_width = readU(16);
|
|
||||||
writeU(sar_width, 16);
|
|
||||||
const sar_height = readU(16);
|
|
||||||
writeU(sar_height, 16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const overscan_info_present_flag = readBit(1);
|
|
||||||
writeBit(overscan_info_present_flag, 1);
|
|
||||||
if (overscan_info_present_flag) {
|
|
||||||
const overscan_appropriate_flag = readBit(1);
|
|
||||||
writeBit(overscan_appropriate_flag, 1);
|
|
||||||
}
|
|
||||||
// Read the video signal type, but don't copy it
|
|
||||||
const video_signal_type_present_flag = readBit(1);
|
|
||||||
writeBit(0, 1);
|
|
||||||
if (video_signal_type_present_flag) {
|
|
||||||
readBit(3); // _video_format
|
|
||||||
readBit(1); // _video_full_range_flag
|
|
||||||
const colour_description_present_flag = readBit(1);
|
|
||||||
if (colour_description_present_flag) {
|
|
||||||
readU(8); // _colour_primaries
|
|
||||||
readU(8); // _transfer_characteristics
|
|
||||||
readU(8); // _matrix_coeffs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const chroma_loc_info_present_flag = readBit(1);
|
|
||||||
writeBit(chroma_loc_info_present_flag, 1);
|
|
||||||
if (chroma_loc_info_present_flag) {
|
|
||||||
const chroma_sample_loc_type_top_field = readUE();
|
|
||||||
writeUE(chroma_sample_loc_type_top_field);
|
|
||||||
const chroma_sample_loc_type_bottom_field = readUE();
|
|
||||||
writeUE(chroma_sample_loc_type_bottom_field);
|
|
||||||
}
|
|
||||||
const timing_info_present_flag = readBit(1);
|
|
||||||
writeBit(timing_info_present_flag, 1);
|
|
||||||
if (timing_info_present_flag) {
|
|
||||||
const num_units_in_tick = readU(32);
|
|
||||||
writeU(num_units_in_tick, 32);
|
|
||||||
const time_scale = readU(32);
|
|
||||||
writeU(time_scale, 32);
|
|
||||||
const fixed_frame_rate_flag = readBit(1);
|
|
||||||
writeBit(fixed_frame_rate_flag, 1);
|
|
||||||
}
|
|
||||||
const nal_hrd_parameters_present_flag = readBit(1);
|
|
||||||
writeBit(nal_hrd_parameters_present_flag, 1);
|
|
||||||
if (nal_hrd_parameters_present_flag) {
|
|
||||||
// hrd_parameters()
|
|
||||||
const cpb_cnt_minus1 = readUE();
|
|
||||||
writeUE(cpb_cnt_minus1);
|
|
||||||
const bit_rate_scale = readBit(4);
|
|
||||||
writeBit(bit_rate_scale, 4);
|
|
||||||
const cpb_size_scale = readBit(4);
|
|
||||||
writeBit(cpb_size_scale, 4);
|
|
||||||
for (let i = 0; i <= cpb_cnt_minus1; i++) {
|
|
||||||
const bit_rate_value_minus1 = readUE();
|
|
||||||
writeUE(bit_rate_value_minus1);
|
|
||||||
const cpb_size_value_minus1 = readUE();
|
|
||||||
writeUE(cpb_size_value_minus1);
|
|
||||||
const cbr_flag = readBit(1);
|
|
||||||
writeBit(cbr_flag, 1);
|
|
||||||
}
|
|
||||||
const initial_cpb_removal_delay_length_minus1 = readBit(5);
|
|
||||||
writeBit(initial_cpb_removal_delay_length_minus1, 5);
|
|
||||||
const cpb_removal_delay_length_minus1 = readBit(5);
|
|
||||||
writeBit(cpb_removal_delay_length_minus1, 5);
|
|
||||||
const dpb_output_delay_length_minus1 = readBit(5);
|
|
||||||
writeBit(dpb_output_delay_length_minus1, 5);
|
|
||||||
const time_offset_length = readBit(5);
|
|
||||||
writeBit(time_offset_length, 5);
|
|
||||||
}
|
|
||||||
const vcl_hrd_parameters_present_flag = readBit(1);
|
|
||||||
writeBit(vcl_hrd_parameters_present_flag, 1);
|
|
||||||
if (vcl_hrd_parameters_present_flag) {
|
|
||||||
// hrd_parameters()
|
|
||||||
const cpb_cnt_minus1 = readUE();
|
|
||||||
writeUE(cpb_cnt_minus1);
|
|
||||||
const bit_rate_scale = readBit(4);
|
|
||||||
writeBit(bit_rate_scale, 4);
|
|
||||||
const cpb_size_scale = readBit(4);
|
|
||||||
writeBit(cpb_size_scale, 4);
|
|
||||||
for (let i = 0; i <= cpb_cnt_minus1; i++) {
|
|
||||||
const bit_rate_value_minus1 = readUE();
|
|
||||||
writeUE(bit_rate_value_minus1);
|
|
||||||
const cpb_size_value_minus1 = readUE();
|
|
||||||
writeUE(cpb_size_value_minus1);
|
|
||||||
const cbr_flag = readBit(1);
|
|
||||||
writeBit(cbr_flag, 1);
|
|
||||||
}
|
|
||||||
const initial_cpb_removal_delay_length_minus1 = readBit(5);
|
|
||||||
writeBit(initial_cpb_removal_delay_length_minus1, 5);
|
|
||||||
const cpb_removal_delay_length_minus1 = readBit(5);
|
|
||||||
writeBit(cpb_removal_delay_length_minus1, 5);
|
|
||||||
const dpb_output_delay_length_minus1 = readBit(5);
|
|
||||||
writeBit(dpb_output_delay_length_minus1, 5);
|
|
||||||
const time_offset_length = readBit(5);
|
|
||||||
writeBit(time_offset_length, 5);
|
|
||||||
}
|
|
||||||
if (nal_hrd_parameters_present_flag || vcl_hrd_parameters_present_flag) {
|
|
||||||
const low_delay_hrd_flag = readBit(1);
|
|
||||||
writeBit(low_delay_hrd_flag, 1);
|
|
||||||
}
|
|
||||||
const pic_struct_present_flag = readBit(1);
|
|
||||||
writeBit(pic_struct_present_flag, 1);
|
|
||||||
const bitstream_restriction_flag = readBit(1);
|
|
||||||
writeBit(1, 1);
|
|
||||||
if (!bitstream_restriction_flag) {
|
|
||||||
addBitstreamRestriction();
|
|
||||||
} else {
|
|
||||||
const motion_vectors_over_pic_boundaries_flag = readBit(1);
|
|
||||||
writeBit(motion_vectors_over_pic_boundaries_flag, 1);
|
|
||||||
const max_bytes_per_pic_denom = readUE();
|
|
||||||
writeUE(max_bytes_per_pic_denom);
|
|
||||||
const max_bits_per_mb_denom = readUE();
|
|
||||||
writeUE(max_bits_per_mb_denom);
|
|
||||||
const log2_max_mv_length_horizontal = readUE();
|
|
||||||
writeUE(log2_max_mv_length_horizontal);
|
|
||||||
const log2_max_mv_length_vertical = readUE();
|
|
||||||
writeUE(log2_max_mv_length_vertical);
|
|
||||||
readUE(); // _num_reorder_frames
|
|
||||||
writeUE(0);
|
|
||||||
readUE(); // _max_dec_frame_buffering
|
|
||||||
writeUE(max_num_ref_frames);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeBit(1, 1); // rbsp_stop_one_bit
|
|
||||||
writer.flush();
|
|
||||||
return writer.toBuffer();
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* StreamConnection — GoLive stream connection (screen share).
|
|
||||||
* Ported from @dank074/discord-video-stream StreamConnection.js.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BaseMediaConnection } from "./BaseMediaConnection.js";
|
|
||||||
import { VoiceOpCodes } from "./VoiceOpCodes.js";
|
|
||||||
|
|
||||||
export class StreamConnection extends BaseMediaConnection {
|
|
||||||
_streamKey: string | null = null;
|
|
||||||
_serverId: string | null = null;
|
|
||||||
|
|
||||||
setSpeaking(speaking: boolean): void {
|
|
||||||
if (!this.webRtcParams) throw new Error("WebRTC connection not ready");
|
|
||||||
this.sendOpcode(VoiceOpCodes.SPEAKING, {
|
|
||||||
delay: 0,
|
|
||||||
speaking: speaking ? 2 : 0,
|
|
||||||
ssrc: this.webRtcParams.audioSsrc,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get daveChannelId(): string {
|
|
||||||
if (this._serverId === null) {
|
|
||||||
throw new Error("Server ID not set (this shouldn't happen)");
|
|
||||||
}
|
|
||||||
const channelId = BigInt(this._serverId) - 1n;
|
|
||||||
return channelId.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverId(): string | null {
|
|
||||||
return this._serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
set serverId(id: string | null) {
|
|
||||||
this._serverId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
get streamKey(): string | null {
|
|
||||||
return this._streamKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
set streamKey(value: string | null) {
|
|
||||||
this._streamKey = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
/**
|
|
||||||
* Streamer — gateway-level GoLive controller. Ported from
|
|
||||||
* @dank074/discord-video-stream Streamer.js.
|
|
||||||
*
|
|
||||||
* Drives the Discord gateway (VOICE_STATE_UPDATE, STREAM_CREATE, ...) and
|
|
||||||
* hands back a VoiceConnection / StreamConnection once the media server
|
|
||||||
* session is ready.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { GatewayOpCodes } from "./GatewayOpCodes.js";
|
|
||||||
import type { NativePeerConnection } from "./native.js";
|
|
||||||
import { StreamConnection } from "./StreamConnection.js";
|
|
||||||
import { generateStreamKey, parseStreamKey } from "./utils.js";
|
|
||||||
import { VoiceConnection } from "./VoiceConnection.js";
|
|
||||||
import type { WebRtcConnWrapper } from "./WebRtcWrapper.js";
|
|
||||||
|
|
||||||
/** Minimal surface of a discord.js-selfbot-v13 client used by Streamer. */
|
|
||||||
export interface StreamerClientLike {
|
|
||||||
user: { id: string; username?: string } | null;
|
|
||||||
token: string | null;
|
|
||||||
on(
|
|
||||||
event: "raw",
|
|
||||||
listener: (packet: { t: string; d: unknown }) => void,
|
|
||||||
): unknown;
|
|
||||||
ws: {
|
|
||||||
broadcast(data: { op: number; d: unknown }): void;
|
|
||||||
};
|
|
||||||
guilds?: {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot client shape is dynamic
|
|
||||||
fetch(id: string): Promise<any>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal channel shape accepted by joinVoiceChannel. */
|
|
||||||
export interface VoiceChannelLike {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
guildId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Streamer {
|
|
||||||
_voiceConnection: VoiceConnection | null = null;
|
|
||||||
_client: StreamerClientLike;
|
|
||||||
_gatewayEmitter = new EventEmitter();
|
|
||||||
|
|
||||||
constructor(client: StreamerClientLike) {
|
|
||||||
this._client = client;
|
|
||||||
// listen for gateway dispatch events
|
|
||||||
this.client.on("raw", (packet) => {
|
|
||||||
const t = packet.t as string;
|
|
||||||
if (
|
|
||||||
t === "STREAM_CREATE" ||
|
|
||||||
t === "STREAM_SERVER_UPDATE" ||
|
|
||||||
t === "VOICE_STATE_UPDATE" ||
|
|
||||||
t === "VOICE_SERVER_UPDATE"
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
`[goLive:Streamer] raw dispatch ${t}`,
|
|
||||||
JSON.stringify(packet.d).slice(0, 220),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this._gatewayEmitter.emit(t, packet.d);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get client(): StreamerClientLike {
|
|
||||||
return this._client;
|
|
||||||
}
|
|
||||||
|
|
||||||
get opts(): Record<string, unknown> {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
get voiceConnection(): VoiceConnection | null {
|
|
||||||
return this._voiceConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendOpcode(code: number, data: unknown): void {
|
|
||||||
// Direct instrumentation — bypasses the bootstrap debug filter (which
|
|
||||||
// drops messages without [VOICE / [ffmpeg / error / stream).
|
|
||||||
console.log(
|
|
||||||
`[goLive:Streamer] sendOpcode op=${code} d=${JSON.stringify(data)}`,
|
|
||||||
);
|
|
||||||
this.client.ws.broadcast({ op: code, d: data });
|
|
||||||
}
|
|
||||||
|
|
||||||
joinVoiceChannel(channel: VoiceChannelLike): Promise<WebRtcConnWrapper> {
|
|
||||||
let guildId: string | null = null;
|
|
||||||
if (
|
|
||||||
channel.type === "GUILD_STAGE_VOICE" ||
|
|
||||||
channel.type === "GUILD_VOICE"
|
|
||||||
) {
|
|
||||||
guildId = channel.guildId ?? null;
|
|
||||||
}
|
|
||||||
return this.joinVoice(guildId, channel.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Joins a voice channel and resolves with the WebRtcConnWrapper when the
|
|
||||||
* media session is ready.
|
|
||||||
*/
|
|
||||||
joinVoice(
|
|
||||||
guild_id: string | null,
|
|
||||||
channel_id: string,
|
|
||||||
): Promise<WebRtcConnWrapper> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!this.client.user) {
|
|
||||||
reject(new Error("Client not logged in"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user_id = this.client.user.id;
|
|
||||||
const voiceConn = new VoiceConnection(
|
|
||||||
this,
|
|
||||||
guild_id,
|
|
||||||
user_id,
|
|
||||||
channel_id,
|
|
||||||
(conn) => {
|
|
||||||
resolve(conn);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this._voiceConnection = voiceConn;
|
|
||||||
this._gatewayEmitter.on(
|
|
||||||
"VOICE_STATE_UPDATE",
|
|
||||||
(d: { user_id: string; session_id: string }) => {
|
|
||||||
if (user_id !== d.user_id) return;
|
|
||||||
voiceConn.setSession(d.session_id);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this._gatewayEmitter.on(
|
|
||||||
"VOICE_SERVER_UPDATE",
|
|
||||||
(d: {
|
|
||||||
guild_id: string | null;
|
|
||||||
channel_id?: string;
|
|
||||||
endpoint: string;
|
|
||||||
token: string;
|
|
||||||
}) => {
|
|
||||||
if (guild_id !== d.guild_id) return;
|
|
||||||
// channel_id is not set for guild voice calls
|
|
||||||
if (d.channel_id && channel_id !== d.channel_id) return;
|
|
||||||
voiceConn.setTokens(d.endpoint, d.token);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.signalVideo(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a GoLive stream (screen share) on top of the voice connection. */
|
|
||||||
createStream(): Promise<WebRtcConnWrapper> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!this.client.user) {
|
|
||||||
reject(new Error("Client not logged in"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.voiceConnection) {
|
|
||||||
reject(
|
|
||||||
new Error("cannot start stream without first joining voice channel"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
guildId: clientGuildId,
|
|
||||||
channelId: clientChannelId,
|
|
||||||
session_id,
|
|
||||||
} = this.voiceConnection;
|
|
||||||
const clientUserId = this.client.user.id;
|
|
||||||
if (!session_id) throw new Error("Session doesn't exist yet");
|
|
||||||
const streamConn = new StreamConnection(
|
|
||||||
this,
|
|
||||||
clientGuildId,
|
|
||||||
clientUserId,
|
|
||||||
clientChannelId,
|
|
||||||
(conn) => {
|
|
||||||
clearTimeout(streamTimeout);
|
|
||||||
clearInterval(retryInterval);
|
|
||||||
resolve(conn);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.voiceConnection.streamConnection = streamConn;
|
|
||||||
|
|
||||||
// Attach listeners BEFORE the first signal so a fast dispatch can't
|
|
||||||
// be lost between signalStream() and listener registration.
|
|
||||||
const onStreamCreate = (d: {
|
|
||||||
stream_key: string;
|
|
||||||
rtc_server_id: string;
|
|
||||||
}) => {
|
|
||||||
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
|
||||||
if (
|
|
||||||
clientGuildId !== guildId ||
|
|
||||||
clientChannelId !== channelId ||
|
|
||||||
clientUserId !== userId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
streamConn.serverId = d.rtc_server_id;
|
|
||||||
streamConn.streamKey = d.stream_key;
|
|
||||||
streamConn.setSession(session_id);
|
|
||||||
};
|
|
||||||
const onStreamServerUpdate = (d: {
|
|
||||||
stream_key: string;
|
|
||||||
endpoint: string;
|
|
||||||
token: string;
|
|
||||||
}) => {
|
|
||||||
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
|
||||||
if (
|
|
||||||
clientGuildId !== guildId ||
|
|
||||||
clientChannelId !== channelId ||
|
|
||||||
clientUserId !== userId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
streamConn.setTokens(d.endpoint, d.token);
|
|
||||||
};
|
|
||||||
this._gatewayEmitter.on("STREAM_CREATE", onStreamCreate);
|
|
||||||
this._gatewayEmitter.on("STREAM_SERVER_UPDATE", onStreamServerUpdate);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(streamTimeout);
|
|
||||||
clearInterval(retryInterval);
|
|
||||||
this._gatewayEmitter.removeListener("STREAM_CREATE", onStreamCreate);
|
|
||||||
this._gatewayEmitter.removeListener(
|
|
||||||
"STREAM_SERVER_UPDATE",
|
|
||||||
onStreamServerUpdate,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const streamTimeout = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
"Timed out waiting for STREAM_CREATE/STREAM_SERVER_UPDATE from Discord (stream handshake) — voice media session may not be active",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, 12_000);
|
|
||||||
|
|
||||||
// Discord sometimes drops the STREAM_CREATE request silently (upstream
|
|
||||||
// issue #217/#219) — resend a few times instead of giving up after one.
|
|
||||||
let attempt = 0;
|
|
||||||
const retryInterval = setInterval(() => {
|
|
||||||
attempt += 1;
|
|
||||||
if (attempt >= 4) {
|
|
||||||
clearInterval(retryInterval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`[goLive:Streamer] createStream: retrying STREAM_CREATE (attempt ${attempt + 1}/4)`,
|
|
||||||
);
|
|
||||||
this.signalStream();
|
|
||||||
}, 3_000);
|
|
||||||
console.log(
|
|
||||||
`[goLive:Streamer] createStream: sending STREAM_CREATE (attempt 1/4)`,
|
|
||||||
);
|
|
||||||
this.signalStream();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async setStreamPreview(image: Buffer): Promise<void> {
|
|
||||||
if (!this.client.token) throw new Error("Please login :)");
|
|
||||||
if (!this.voiceConnection?.streamConnection?.guildId) return;
|
|
||||||
const data = `data:image/jpeg;base64,${image.toString("base64")}`;
|
|
||||||
const { guildId } = this.voiceConnection.streamConnection;
|
|
||||||
if (!this.client.guilds) return;
|
|
||||||
const server = await this.client.guilds.fetch(guildId);
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot dynamic
|
|
||||||
(server as any).members.me?.voice?.postPreview(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopStream(): void {
|
|
||||||
const stream = this.voiceConnection?.streamConnection;
|
|
||||||
if (!stream) return;
|
|
||||||
stream.stop();
|
|
||||||
this.signalStopStream();
|
|
||||||
this.voiceConnection.streamConnection = null;
|
|
||||||
this._gatewayEmitter.removeAllListeners("STREAM_CREATE");
|
|
||||||
this._gatewayEmitter.removeAllListeners("STREAM_SERVER_UPDATE");
|
|
||||||
}
|
|
||||||
|
|
||||||
leaveVoice(): void {
|
|
||||||
this.voiceConnection?.stop();
|
|
||||||
this.signalLeaveVoice();
|
|
||||||
this._voiceConnection = null;
|
|
||||||
this._gatewayEmitter.removeAllListeners("VOICE_STATE_UPDATE");
|
|
||||||
this._gatewayEmitter.removeAllListeners("VOICE_SERVER_UPDATE");
|
|
||||||
}
|
|
||||||
|
|
||||||
signalVideo(video_enabled: boolean): void {
|
|
||||||
if (!this.voiceConnection) return;
|
|
||||||
const { guildId: guild_id, channelId: channel_id } = this.voiceConnection;
|
|
||||||
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
|
|
||||||
guild_id: guild_id,
|
|
||||||
channel_id,
|
|
||||||
self_mute: false,
|
|
||||||
self_deaf: true,
|
|
||||||
self_video: video_enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
signalStream(): void {
|
|
||||||
if (!this.voiceConnection) return;
|
|
||||||
const {
|
|
||||||
type,
|
|
||||||
guildId: guild_id,
|
|
||||||
channelId: channel_id,
|
|
||||||
botId: user_id,
|
|
||||||
} = this.voiceConnection;
|
|
||||||
// Un-deafen before requesting the stream (mimic real client). Do NOT
|
|
||||||
// set self_video: true — that flips on the bot's camera in Discord
|
|
||||||
// (visible to everyone); screen share should not enable the camera.
|
|
||||||
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
|
|
||||||
guild_id,
|
|
||||||
channel_id,
|
|
||||||
self_mute: false,
|
|
||||||
self_deaf: false,
|
|
||||||
self_video: false,
|
|
||||||
});
|
|
||||||
this.sendOpcode(GatewayOpCodes.STREAM_CREATE, {
|
|
||||||
type,
|
|
||||||
guild_id,
|
|
||||||
channel_id,
|
|
||||||
preferred_region: null,
|
|
||||||
});
|
|
||||||
this.sendOpcode(GatewayOpCodes.STREAM_SET_PAUSED, {
|
|
||||||
stream_key: generateStreamKey(type, guild_id, channel_id, user_id),
|
|
||||||
paused: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
signalStopStream(): void {
|
|
||||||
if (!this.voiceConnection) return;
|
|
||||||
const {
|
|
||||||
type,
|
|
||||||
guildId: guild_id,
|
|
||||||
channelId: channel_id,
|
|
||||||
botId: user_id,
|
|
||||||
} = this.voiceConnection;
|
|
||||||
this.sendOpcode(GatewayOpCodes.STREAM_DELETE, {
|
|
||||||
stream_key: generateStreamKey(type, guild_id, channel_id, user_id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
signalLeaveVoice(): void {
|
|
||||||
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
|
|
||||||
guild_id: null,
|
|
||||||
channel_id: null,
|
|
||||||
self_mute: true,
|
|
||||||
self_deaf: false,
|
|
||||||
self_video: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { NativePeerConnection };
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* VideoStream — feeds encoded H264 frames into the WebRTC connection.
|
|
||||||
* Ported from @dank074/discord-video-stream VideoStream.js.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BaseMediaStream } from "./BaseMediaStream.js";
|
|
||||||
import type { WebRtcConnWrapper } from "./WebRtcWrapper.js";
|
|
||||||
|
|
||||||
export class VideoStream extends BaseMediaStream {
|
|
||||||
_conn: WebRtcConnWrapper;
|
|
||||||
|
|
||||||
constructor(conn: WebRtcConnWrapper, noSleep = false) {
|
|
||||||
super("video", noSleep);
|
|
||||||
this._conn = conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _sendFrame(frame: Buffer, frametime: number): Promise<void> {
|
|
||||||
this._conn.sendVideoFrame(frame, frametime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* VoiceConnection — guild/DM voice channel GoLive connection.
|
|
||||||
* Ported from @dank074/discord-video-stream VoiceConnection.js.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BaseMediaConnection } from "./BaseMediaConnection.js";
|
|
||||||
import type { StreamConnection } from "./StreamConnection.js";
|
|
||||||
|
|
||||||
export class VoiceConnection extends BaseMediaConnection {
|
|
||||||
streamConnection: StreamConnection | null = null;
|
|
||||||
|
|
||||||
get daveChannelId(): string {
|
|
||||||
return this.channelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverId(): string | null {
|
|
||||||
// for guild vc it is the guild id, for dm voice it is the channel id
|
|
||||||
return this.guildId ?? this.channelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
super.stop();
|
|
||||||
this.streamConnection?.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/** Discord voice WebSocket opcodes — ported from @dank074/discord-video-stream. */
|
|
||||||
export enum VoiceOpCodes {
|
|
||||||
IDENTIFY = 0,
|
|
||||||
SELECT_PROTOCOL = 1,
|
|
||||||
READY = 2,
|
|
||||||
HEARTBEAT = 3,
|
|
||||||
SELECT_PROTOCOL_ACK = 4,
|
|
||||||
SPEAKING = 5,
|
|
||||||
HEARTBEAT_ACK = 6,
|
|
||||||
RESUME = 7,
|
|
||||||
HELLO = 8,
|
|
||||||
RESUMED = 9,
|
|
||||||
CLIENTS_CONNECT = 11,
|
|
||||||
VIDEO = 12,
|
|
||||||
CLIENT_DISCONNECT = 13,
|
|
||||||
SESSION_UPDATE = 14,
|
|
||||||
MEDIA_SINK_WANTS = 15,
|
|
||||||
VOICE_BACKEND_VERSION = 16,
|
|
||||||
CHANNEL_OPTIONS_UPDATE = 17,
|
|
||||||
FLAGS = 18,
|
|
||||||
SPEED_TEST = 19,
|
|
||||||
PLATFORM = 20,
|
|
||||||
DAVE_PREPARE_TRANSITION = 21,
|
|
||||||
DAVE_EXECUTE_TRANSITION = 22,
|
|
||||||
DAVE_TRANSITION_READY = 23,
|
|
||||||
DAVE_PREPARE_EPOCH = 24,
|
|
||||||
MLS_INVALID_COMMIT_WELCOME = 31,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Binary voice WebSocket opcodes (DAVE / MLS). */
|
|
||||||
export enum VoiceOpCodesBinary {
|
|
||||||
MLS_EXTERNAL_SENDER = 25,
|
|
||||||
MLS_KEY_PACKAGE = 26,
|
|
||||||
MLS_PROPOSALS = 27,
|
|
||||||
MLS_COMMIT_WELCOME = 28,
|
|
||||||
MLS_ANNOUNCE_COMMIT_TRANSITION = 29,
|
|
||||||
MLS_WELCOME = 30,
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
/**
|
|
||||||
* WebRTC connection wrapper for GoLive — ported from
|
|
||||||
* @dank074/discord-video-stream WebRtcWrapper.js, with the media stack
|
|
||||||
* (packetizers, RTCP SR/NACK, pacing) provided by the libdatachannel-min
|
|
||||||
* binding instead of node-datachannel's JS-exposed media classes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
H264Helpers,
|
|
||||||
H264NalUnitTypes,
|
|
||||||
splitNalu,
|
|
||||||
startCode3,
|
|
||||||
} from "./AnnexBHelper.js";
|
|
||||||
import { CodecPayloadType } from "./CodecPayloadType.js";
|
|
||||||
import type { NativePeerConnection, NativeTrack } from "./native.js";
|
|
||||||
import { loadNative } from "./native.js";
|
|
||||||
import { rewriteSPSVUI } from "./SPSVUIRewriter.js";
|
|
||||||
import { normalizeVideoCodec } from "./utils.js";
|
|
||||||
|
|
||||||
export type WebRtcVideoCodec = "H264" | "H265" | "VP8" | "VP9" | "AV1";
|
|
||||||
|
|
||||||
export interface WebRtcParams {
|
|
||||||
address: string;
|
|
||||||
port: number;
|
|
||||||
audioSsrc: number;
|
|
||||||
videoSsrc: number;
|
|
||||||
rtxSsrc: number;
|
|
||||||
supportedEncryptionModes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal surface of the media connection that WebRtcWrapper drives. */
|
|
||||||
export interface VideoAttribute {
|
|
||||||
fps: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaConnectionLike {
|
|
||||||
daveReady: boolean;
|
|
||||||
daveSession: {
|
|
||||||
encryptOpus(frame: Buffer): Buffer;
|
|
||||||
encrypt(mediaType: number, codec: number, frame: Buffer): Buffer;
|
|
||||||
} | null;
|
|
||||||
webRtcParams: WebRtcParams | null;
|
|
||||||
setSpeaking(speaking: boolean): void;
|
|
||||||
setVideoAttributes(enabled: boolean, attr?: VideoAttribute): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Media types used by DAVE encryption (from @dank074). */
|
|
||||||
export enum DaveMediaType {
|
|
||||||
AUDIO = 0,
|
|
||||||
VIDEO = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DAVE codec ids (from @dank074). */
|
|
||||||
export enum DaveCodec {
|
|
||||||
UNKNOWN = 0,
|
|
||||||
VP8 = 2,
|
|
||||||
VP9 = 3,
|
|
||||||
H264 = 4,
|
|
||||||
H265 = 5,
|
|
||||||
AV1 = 6,
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WebRtcConnWrapper {
|
|
||||||
private _mediaConn: MediaConnectionLike;
|
|
||||||
private _webRtcConn: NativePeerConnection | null = null;
|
|
||||||
private _audioTrack: NativeTrack | null = null;
|
|
||||||
private _videoTrack: NativeTrack | null = null;
|
|
||||||
private _videoCodec: WebRtcVideoCodec | null = null;
|
|
||||||
private _videoFrameLog = 0;
|
|
||||||
/** Assigned by BaseMediaConnection to send the gathered SDP to Discord. */
|
|
||||||
onLocalDescription: ((sdp: string) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(mediaConn: MediaConnectionLike) {
|
|
||||||
this._mediaConn = mediaConn;
|
|
||||||
}
|
|
||||||
|
|
||||||
initWebRtc(): NativePeerConnection {
|
|
||||||
const native = loadNative();
|
|
||||||
this._webRtcConn = new native.PeerConnection({
|
|
||||||
iceServers: ["stun:stun.l.google.com:19302"],
|
|
||||||
});
|
|
||||||
// Track mids must match @dank074: "0" audio, "1" video.
|
|
||||||
this._audioTrack = this._webRtcConn.addTrack("0", "audio");
|
|
||||||
this._videoTrack = this._webRtcConn.addTrack("1", "video");
|
|
||||||
return this._webRtcConn;
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this._webRtcConn?.close();
|
|
||||||
this._webRtcConn = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get webRtcConn(): NativePeerConnection | null {
|
|
||||||
return this._webRtcConn;
|
|
||||||
}
|
|
||||||
|
|
||||||
get ready(): boolean {
|
|
||||||
return this._webRtcConn?.state() === "connected";
|
|
||||||
}
|
|
||||||
|
|
||||||
get mediaConnection(): MediaConnectionLike {
|
|
||||||
return this._mediaConn;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendAudioFrame(frame: Buffer, frametime: number): void {
|
|
||||||
if (!this.ready || !this._audioTrack) return;
|
|
||||||
const clockRate = CodecPayloadType.opus.clockRate;
|
|
||||||
if (this.mediaConnection.daveReady && this.mediaConnection.daveSession) {
|
|
||||||
frame = this.mediaConnection.daveSession.encryptOpus(frame);
|
|
||||||
}
|
|
||||||
this._audioTrack.sendFrame(frame);
|
|
||||||
this._audioTrack.addTimestamp(Math.round((frametime * clockRate) / 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
sendVideoFrame(frame: Buffer, frametime: number): void {
|
|
||||||
if (!this.ready || !this._videoTrack) {
|
|
||||||
if (this._videoFrameLog === 0) {
|
|
||||||
console.log(
|
|
||||||
`[goLive:WebRtc] sendVideoFrame DROPPED ready=${this.ready} track=${this._videoTrack !== null}`,
|
|
||||||
);
|
|
||||||
this._videoFrameLog++;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const clockRate = CodecPayloadType[this._videoCodec ?? "H264"].clockRate;
|
|
||||||
if (this._videoCodec === "H264") {
|
|
||||||
let spsRewritten = false;
|
|
||||||
const nalus = splitNalu(frame).map((el) => {
|
|
||||||
if (H264Helpers.getUnitType(el) === H264NalUnitTypes.SPS) {
|
|
||||||
spsRewritten = true;
|
|
||||||
return rewriteSPSVUI(el);
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
if (spsRewritten)
|
|
||||||
frame = Buffer.concat(nalus.flatMap((el) => [startCode3, el]));
|
|
||||||
}
|
|
||||||
if (this.mediaConnection.daveReady && this.mediaConnection.daveSession) {
|
|
||||||
let daveCodec = DaveCodec.UNKNOWN;
|
|
||||||
switch (this._videoCodec) {
|
|
||||||
case "H264":
|
|
||||||
daveCodec = DaveCodec.H264;
|
|
||||||
break;
|
|
||||||
case "H265":
|
|
||||||
daveCodec = DaveCodec.H265;
|
|
||||||
break;
|
|
||||||
case "VP8":
|
|
||||||
daveCodec = DaveCodec.VP8;
|
|
||||||
break;
|
|
||||||
case "VP9":
|
|
||||||
daveCodec = DaveCodec.VP9;
|
|
||||||
break;
|
|
||||||
case "AV1":
|
|
||||||
daveCodec = DaveCodec.AV1;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
frame = this.mediaConnection.daveSession.encrypt(
|
|
||||||
DaveMediaType.VIDEO,
|
|
||||||
daveCodec,
|
|
||||||
frame,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this._videoTrack.sendFrame(frame);
|
|
||||||
this._videoTrack.addTimestamp(Math.round((frametime * clockRate) / 1000));
|
|
||||||
this._videoFrameLog++;
|
|
||||||
if (this._videoFrameLog === 1 || this._videoFrameLog % 30 === 0) {
|
|
||||||
console.log(
|
|
||||||
`[goLive:WebRtc] sendVideoFrame #${this._videoFrameLog} bytes=${frame.length} ready=${this.ready}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPacketizer(videoCodec: string): void {
|
|
||||||
if (!this.mediaConnection.webRtcParams) {
|
|
||||||
throw new Error("WebRTC connection not ready");
|
|
||||||
}
|
|
||||||
const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams;
|
|
||||||
console.log(
|
|
||||||
`[goLive:WebRtc] setPacketizer(${videoCodec}) audioSsrc=${audioSsrc} videoSsrc=${videoSsrc} rtxSsrc=${this.mediaConnection.webRtcParams.rtxSsrc}`,
|
|
||||||
);
|
|
||||||
this._videoCodec = normalizeVideoCodec(videoCodec);
|
|
||||||
// Audio packetizer: opus 120 @ 48kHz, playout delay ext id 5 (like @dank074)
|
|
||||||
this._audioTrack?.setPacketizer(
|
|
||||||
"audio",
|
|
||||||
audioSsrc,
|
|
||||||
CodecPayloadType.opus.payload_type,
|
|
||||||
CodecPayloadType.opus.clockRate,
|
|
||||||
5,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
// Video packetizer: H264/H265/AV1 with their payload types
|
|
||||||
const codecEntry = CodecPayloadType[this._videoCodec];
|
|
||||||
if (!codecEntry) {
|
|
||||||
throw new Error(`Packetizer not implemented for ${this._videoCodec}`);
|
|
||||||
}
|
|
||||||
const nativeKind =
|
|
||||||
this._videoCodec === "H264"
|
|
||||||
? "h264"
|
|
||||||
: this._videoCodec === "H265"
|
|
||||||
? "h265"
|
|
||||||
: this._videoCodec === "AV1"
|
|
||||||
? "av1"
|
|
||||||
: (() => {
|
|
||||||
throw new Error(
|
|
||||||
`Packetizer not implemented for ${this._videoCodec}`,
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
this._videoTrack?.setPacketizer(
|
|
||||||
nativeKind,
|
|
||||||
videoSsrc,
|
|
||||||
codecEntry.payload_type,
|
|
||||||
codecEntry.clockRate,
|
|
||||||
5,
|
|
||||||
0,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* goLive public API — re-exports the ported @dank074 modules.
|
|
||||||
* Drop-in replacement for `@dank074/discord-video-stream` in
|
|
||||||
* screenShareController.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { AudioStream } from "./AudioStream.js";
|
|
||||||
export { BaseMediaConnection } from "./BaseMediaConnection.js";
|
|
||||||
export { BaseMediaStream } from "./BaseMediaStream.js";
|
|
||||||
export { CodecPayloadType } from "./CodecPayloadType.js";
|
|
||||||
export { demux } from "./Demuxer.js";
|
|
||||||
export { Encoders } from "./Encoders.js";
|
|
||||||
export { playStream, prepareStream } from "./prepareStream.js";
|
|
||||||
export { StreamConnection } from "./StreamConnection.js";
|
|
||||||
export { Streamer } from "./Streamer.js";
|
|
||||||
export { normalizeVideoCodec } from "./utils.js";
|
|
||||||
export { VideoStream } from "./VideoStream.js";
|
|
||||||
export { VoiceConnection } from "./VoiceConnection.js";
|
|
||||||
export { WebRtcConnWrapper } from "./WebRtcWrapper.js";
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/**
|
|
||||||
* Loader + typings for the minimal libdatachannel N-API binding
|
|
||||||
* (native/libdatachannel-min). The binding exposes ONLY what GoLive needs:
|
|
||||||
* PeerConnection, DataChannel, Track (raw RTP + media packetizer chain).
|
|
||||||
*
|
|
||||||
* The .node file is built by node-gyp against libdatachannel 0.24.0 (built
|
|
||||||
* from source — nixpkgs 0.24.1 is glibc-incompatible with this host). It is
|
|
||||||
* NOT shipped via npm; the Nix flake builds it as part of the gateway.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface NativeTrack {
|
|
||||||
/** Send a RAW RTP/RTCP packet (no media handler installed). */
|
|
||||||
send(buffer: Uint8Array): void;
|
|
||||||
/** Send an ENCODED frame; the packetizer chain turns it into RTP. */
|
|
||||||
sendFrame(buffer: Uint8Array): void;
|
|
||||||
/** Advance the packetizer RTP timestamp by delta (clock-rate units). */
|
|
||||||
addTimestamp(delta: number): void;
|
|
||||||
/** Install the media-handler chain (packetizer → RTCP SR → NACK → pacing). */
|
|
||||||
setPacketizer(
|
|
||||||
kind: "audio" | "h264" | "h265" | "av1",
|
|
||||||
ssrc: number,
|
|
||||||
payloadType: number,
|
|
||||||
clockRate: number,
|
|
||||||
playoutDelayId: number,
|
|
||||||
playoutDelayMin: number,
|
|
||||||
playoutDelayMax: number,
|
|
||||||
): void;
|
|
||||||
isOpen(): boolean;
|
|
||||||
close(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NativePeerConnection {
|
|
||||||
/** mid must be "0" (audio) or "1" (video) — matches @dank074's track defs. */
|
|
||||||
addTrack(mid: string, kind: "audio" | "video"): NativeTrack;
|
|
||||||
/** Resolves with the full SDP (incl. candidates) after gathering completes. */
|
|
||||||
createOffer(): Promise<string>;
|
|
||||||
/** Resolves with the auto-generated answer SDP. */
|
|
||||||
createAnswer(offerSdp: string): Promise<string>;
|
|
||||||
setRemoteDescription(sdp: string, type: "offer" | "answer"): void;
|
|
||||||
state(): string;
|
|
||||||
close(): void;
|
|
||||||
onStateChange(cb: (state: string) => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NativeBinding {
|
|
||||||
PeerConnection: new (config: {
|
|
||||||
iceServers: string[];
|
|
||||||
}) => NativePeerConnection;
|
|
||||||
DataChannel: unknown;
|
|
||||||
Track: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cached: NativeBinding | null = null;
|
|
||||||
|
|
||||||
/** Load the native binding. Throws only if the .node is truly missing —
|
|
||||||
* callers (screen share) guard with `isNativeAvailable()`. */
|
|
||||||
export function loadNative(): NativeBinding {
|
|
||||||
if (cached) return cached;
|
|
||||||
// Resolve relative to this file: src/goLive/ → native/libdatachannel-min/
|
|
||||||
const candidates = [
|
|
||||||
new URL(
|
|
||||||
"../../native/libdatachannel-min/build/Release/datachannel_min.node",
|
|
||||||
import.meta.url,
|
|
||||||
),
|
|
||||||
new URL(
|
|
||||||
"../../../native/libdatachannel-min/build/Release/datachannel_min.node",
|
|
||||||
import.meta.url,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let lastErr: unknown;
|
|
||||||
for (const url of candidates) {
|
|
||||||
try {
|
|
||||||
// @ts-expect-error — .node modules are not typed; dynamic require via file URL
|
|
||||||
const mod = process.dlopen ? null : null;
|
|
||||||
void mod;
|
|
||||||
const nativePath = url.pathname;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const req = createRequire(import.meta.url);
|
|
||||||
const binding = req(nativePath) as NativeBinding;
|
|
||||||
if (typeof binding.PeerConnection === "function") {
|
|
||||||
cached = binding;
|
|
||||||
return binding;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
lastErr = e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: plain relative require (tsx / jest environments)
|
|
||||||
try {
|
|
||||||
const req = createRequire(import.meta.url);
|
|
||||||
const binding = req(
|
|
||||||
"../../native/libdatachannel-min/build/Release/datachannel_min.node",
|
|
||||||
) as NativeBinding;
|
|
||||||
if (typeof binding.PeerConnection === "function") {
|
|
||||||
cached = binding;
|
|
||||||
return binding;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
lastErr = e;
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`libdatachannel-min native binding not built (${String(lastErr)}). Run: cd native/libdatachannel-min && npx node-gyp rebuild`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
|
|
||||||
/** True when the native binding is built — screen share stays disabled otherwise. */
|
|
||||||
export function isNativeAvailable(): boolean {
|
|
||||||
try {
|
|
||||||
loadNative();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
/**
|
|
||||||
* prepareStream & playStream — ported from @dank074/discord-video-stream
|
|
||||||
* newApi.js (Encoders/prepareStream/playStream), but uses `child_process.spawn`
|
|
||||||
* + ffmpeg CLI args directly instead of fluent-ffmpeg + node-av.
|
|
||||||
*
|
|
||||||
* Replaces the @dank074 video pipeline entirely:
|
|
||||||
* input (URL or Readable) → ffmpeg spawn → H264 AnnexB frames
|
|
||||||
* → Demuxer stream → VideoStream/AudioStream → WebRtcConnWrapper
|
|
||||||
*/
|
|
||||||
|
|
||||||
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 { AudioStream } from "./AudioStream.js";
|
|
||||||
import { demux } from "./Demuxer.js";
|
|
||||||
import { type EncoderSettings, Encoders } from "./Encoders.js";
|
|
||||||
import { VideoStream } from "./VideoStream.js";
|
|
||||||
import type { WebRtcConnWrapper } from "./WebRtcWrapper.js";
|
|
||||||
|
|
||||||
export interface PrepareStreamResult {
|
|
||||||
command: ChildProcess;
|
|
||||||
output: PassThrough;
|
|
||||||
encoder: () => Record<string, EncoderSettings>;
|
|
||||||
options: Record<string, unknown>;
|
|
||||||
videoCodec: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
frameRate?: number;
|
|
||||||
includeAudio: boolean;
|
|
||||||
/** Container the encoder muxes to: "nut" (audio-capable) or "h264" (raw). */
|
|
||||||
format: "nut" | "h264";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFiniteNonZero(n: unknown): n is number {
|
|
||||||
return typeof n === "number" && !!n && Number.isFinite(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_HEADERS = {
|
|
||||||
"User-Agent":
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36",
|
|
||||||
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
|
|
||||||
* prepareStream but produces a raw spawn instead of a fluent-ffmpeg command.
|
|
||||||
*/
|
|
||||||
export function prepareStream(
|
|
||||||
input: string | Readable,
|
|
||||||
options: Record<string, unknown> = {},
|
|
||||||
): PrepareStreamResult {
|
|
||||||
const mergedOptions = {
|
|
||||||
noTranscoding: false,
|
|
||||||
width: isFiniteNonZero(options.width)
|
|
||||||
? Math.round(options.width as number)
|
|
||||||
: -2,
|
|
||||||
height: isFiniteNonZero(options.height)
|
|
||||||
? Math.round(options.height as number)
|
|
||||||
: -2,
|
|
||||||
frameRate:
|
|
||||||
isFiniteNonZero(options.frameRate) && (options.frameRate as number) > 0
|
|
||||||
? options.frameRate
|
|
||||||
: undefined,
|
|
||||||
videoCodec: (options.videoCodec as string) ?? "H264",
|
|
||||||
bitrateVideo:
|
|
||||||
isFiniteNonZero(options.bitrateVideo) &&
|
|
||||||
(options.bitrateVideo as number) > 0
|
|
||||||
? Math.round(options.bitrateVideo as number)
|
|
||||||
: 5000,
|
|
||||||
bitrateVideoMax:
|
|
||||||
isFiniteNonZero(options.bitrateVideoMax) &&
|
|
||||||
(options.bitrateVideoMax as number) > 0
|
|
||||||
? Math.round(options.bitrateVideoMax as number)
|
|
||||||
: 7000,
|
|
||||||
bitrateAudio:
|
|
||||||
isFiniteNonZero(options.bitrateAudio) &&
|
|
||||||
(options.bitrateAudio as number) > 0
|
|
||||||
? Math.round(options.bitrateAudio as number)
|
|
||||||
: 128,
|
|
||||||
includeAudio: options.includeAudio ?? true,
|
|
||||||
encoder:
|
|
||||||
(options.encoder as () => Record<string, EncoderSettings>) ??
|
|
||||||
Encoders.software(),
|
|
||||||
customHeaders: {
|
|
||||||
...DEFAULT_HEADERS,
|
|
||||||
...(options.customHeaders as Record<string, string> | undefined),
|
|
||||||
},
|
|
||||||
customInputOptions: (options.customInputOptions as string[]) ?? [],
|
|
||||||
customFfmpegFlags: (options.customFfmpegFlags as string[]) ?? [],
|
|
||||||
minimizeLatency: options.minimizeLatency ?? false,
|
|
||||||
// realtime: throttle ffmpeg's INPUT read to 1x so the encoder tracks
|
|
||||||
// wall-clock instead of slurping a VOD at network speed. Without this,
|
|
||||||
// a YouTube screen share downloads the whole clip fast and the encoder
|
|
||||||
// produces a ~10x frame backlog that the demuxer buffers unboundedly —
|
|
||||||
// the sender paces at 30fps but always emits the OLDEST buffered frames,
|
|
||||||
// so the viewer sees frozen/laggy video while audio (tiny, jitter-
|
|
||||||
// buffer recoverable) stays smooth. That is the "video stuck, voice
|
|
||||||
// normal" symptom. `-re` caps the pipeline at 1x end-to-end. Default on
|
|
||||||
// because this prepareStream is only used for screen share (VOD URLs).
|
|
||||||
realtime: options.realtime ?? true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const output = new PassThrough();
|
|
||||||
|
|
||||||
const args: string[] = [
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
// Real-time throttle: read the input at 1x so the encoder paces with
|
|
||||||
// wall-clock and does NOT force-duplicate frames to fill -r 30. This
|
|
||||||
// applies to BOTH URL and live-Pipe (Readable) inputs: a screen-share
|
|
||||||
// pipe (yt-dlp merge → ffmpeg → stdout) is delivered at NETWORK speed
|
|
||||||
// (bursts, stalls) and is NOT self-paced — without -re the encoder slurps
|
|
||||||
// it instantly and, when the merge stalls, x264 -r 30 repeats the last
|
|
||||||
// held frame ~30x → the viewer sees ~1fps while WebRTC still pushes
|
|
||||||
// 30fps. -re reads the pipe at the stream's native PTS rate so each
|
|
||||||
// output frame is a fresh picture. (Previous builds only added -re for
|
|
||||||
// string URLs, so the screen-share pipe got none — root of the 1fps
|
|
||||||
// symptom.)
|
|
||||||
...(mergedOptions.realtime ? ["-re"] : []),
|
|
||||||
...(typeof input === "string" ? ["-i", input] : ["-i", "pipe:0"]),
|
|
||||||
...mergedOptions.customInputOptions,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (mergedOptions.minimizeLatency) {
|
|
||||||
args.push("-fflags", "nobuffer", "-analyzeduration", "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input === "string" && input.startsWith("http")) {
|
|
||||||
const headerStr = Object.entries(mergedOptions.customHeaders)
|
|
||||||
.map(([k, v]) => `${k}: ${v}`)
|
|
||||||
.join("\r\n");
|
|
||||||
args.push(
|
|
||||||
"-headers",
|
|
||||||
headerStr,
|
|
||||||
"-reconnect",
|
|
||||||
"1",
|
|
||||||
"-reconnect_at_eof",
|
|
||||||
"1",
|
|
||||||
"-reconnect_streamed",
|
|
||||||
"1",
|
|
||||||
"-reconnect_delay_max",
|
|
||||||
"4294",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Video
|
|
||||||
args.push("-map", "0:v:0");
|
|
||||||
if (mergedOptions.noTranscoding) {
|
|
||||||
args.push("-c:v", "copy");
|
|
||||||
} else {
|
|
||||||
args.push(`-vf`, `scale=${mergedOptions.width}:${mergedOptions.height}`);
|
|
||||||
if (mergedOptions.frameRate)
|
|
||||||
args.push("-r", String(mergedOptions.frameRate));
|
|
||||||
const enc = mergedOptions.encoder()[mergedOptions.videoCodec];
|
|
||||||
if (!enc)
|
|
||||||
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`,
|
|
||||||
"-maxrate:v",
|
|
||||||
`${mergedOptions.bitrateVideoMax}k`,
|
|
||||||
"-bufsize:v",
|
|
||||||
`${Math.round(mergedOptions.bitrateVideo / 2)}k`,
|
|
||||||
"-bf",
|
|
||||||
"0",
|
|
||||||
"-pix_fmt",
|
|
||||||
"yuv420p",
|
|
||||||
"-force_key_frames",
|
|
||||||
"expr:gte(t,n_forced*1)",
|
|
||||||
"-c:v",
|
|
||||||
enc.name,
|
|
||||||
...encOptions,
|
|
||||||
...(enc.globalOptions ?? []).flatMap((opt) =>
|
|
||||||
opt.split(/\s+/).filter(Boolean),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio: transcode to libopus. NUT muxer on stdout carries video (H264
|
|
||||||
// AnnexB) + audio (Ogg Opus) as ONE stream into the Demuxer, which re-splits
|
|
||||||
// them via a child ffmpeg -f nut -i pipe:0 -c:v copy -f h264 pipe:1 ... . The
|
|
||||||
// Demuxer's start-code scan runs on THAT child ffmpeg's stdout (pure H264),
|
|
||||||
// NOT on NUT — so NAL type 5 (IDR) is parsed correctly. (Outputting raw
|
|
||||||
// h264+opus on two pipes directly was tried and broke: the audio pipe was
|
|
||||||
// never attached to the demuxer's input, so audio RTP never flowed.)
|
|
||||||
if (mergedOptions.includeAudio) {
|
|
||||||
args.push(
|
|
||||||
"-map",
|
|
||||||
"0:a:0?",
|
|
||||||
"-c:a",
|
|
||||||
"libopus",
|
|
||||||
"-b:a",
|
|
||||||
`${mergedOptions.bitrateAudio}k`,
|
|
||||||
"-ar",
|
|
||||||
"48000",
|
|
||||||
"-ac",
|
|
||||||
"2",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
args.push("-an");
|
|
||||||
}
|
|
||||||
|
|
||||||
// NUT muxer carries video+audio; the raw h264 muxer cannot ("h264 muxer
|
|
||||||
// does not support any stream of type audio" -> header write fails ->
|
|
||||||
// empty stdout -> black tile). Audio delivery requires NUT.
|
|
||||||
const outFormat = mergedOptions.includeAudio ? "nut" : "h264";
|
|
||||||
args.push("-f", outFormat, "pipe:1");
|
|
||||||
|
|
||||||
const isUrl = typeof input === "string";
|
|
||||||
const proc: ChildProcess = isUrl
|
|
||||||
? spawn(FFMPEG_BIN, args, { stdio: ["ignore", "pipe", "pipe"] })
|
|
||||||
: spawn(FFMPEG_BIN, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
||||||
|
|
||||||
if (proc.stdin && !isUrl) {
|
|
||||||
// Race guard: the merge ffmpeg may have already exited (transient 403
|
|
||||||
// or stream death) before this function attaches its listeners — the
|
|
||||||
// input's 'end'/'error' events then fire into the void and the encoder
|
|
||||||
// stdin NEVER receives EOF, leaving an encoder that waits forever and a
|
|
||||||
// screen share that shows a black tile with zero frames. Check the
|
|
||||||
// terminal state eagerly and EOF the encoder immediately.
|
|
||||||
if (input.readableEnded || input.destroyed) {
|
|
||||||
proc.stdin.end();
|
|
||||||
} else {
|
|
||||||
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
|
|
||||||
input.on("end", () => proc.stdin?.end());
|
|
||||||
input.on("error", () => proc.stdin?.destroy());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safety: proc may error before playStream attaches a demux listener on
|
|
||||||
// `output`. A no-op listener here prevents an unhandled 'error' event
|
|
||||||
// on the PassThrough from crashing the gateway on ffmpeg spawn failure.
|
|
||||||
output.on("error", () => {});
|
|
||||||
proc.stdout?.pipe(output);
|
|
||||||
proc.stderr?.on("data", () => {
|
|
||||||
/* swallow ffmpeg stderr */
|
|
||||||
});
|
|
||||||
proc.on("error", (err) => {
|
|
||||||
// spawn failed (e.g. ffmpeg missing). If someone is consuming output
|
|
||||||
// (demux attaches an 'error' listener) propagate; otherwise just end.
|
|
||||||
if (output.listenerCount("error") > 0) {
|
|
||||||
output.destroy(err);
|
|
||||||
} else {
|
|
||||||
output.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
proc.on("close", () => {
|
|
||||||
output.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: proc,
|
|
||||||
output,
|
|
||||||
encoder: mergedOptions.encoder,
|
|
||||||
options: mergedOptions,
|
|
||||||
videoCodec: mergedOptions.videoCodec,
|
|
||||||
width: mergedOptions.width,
|
|
||||||
height: mergedOptions.height,
|
|
||||||
frameRate: mergedOptions.frameRate,
|
|
||||||
includeAudio: !!mergedOptions.includeAudio,
|
|
||||||
format: outFormat,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlayStreamOptions {
|
|
||||||
type?: "go-live" | "video";
|
|
||||||
format?: string;
|
|
||||||
width?: number | ((v: unknown) => number);
|
|
||||||
height?: number | ((v: unknown) => number);
|
|
||||||
frameRate?: number | ((v: unknown) => number);
|
|
||||||
readrateInitialBurst?: number;
|
|
||||||
streamPreview?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* playStream — demux the prepareStream output and pipe frames into the
|
|
||||||
* WebRTC connection's video/audio streams. Resolves when the video stream
|
|
||||||
* ends (natural EOF or the ffmpeg command is killed via cleanup/stop).
|
|
||||||
*/
|
|
||||||
export async function playStream(
|
|
||||||
prepared: PrepareStreamResult,
|
|
||||||
streamer: { createStream: () => Promise<WebRtcConnWrapper> },
|
|
||||||
options: PlayStreamOptions = {},
|
|
||||||
): Promise<void> {
|
|
||||||
const conn = await streamer.createStream();
|
|
||||||
console.log("[goLive:playStream] createStream resolved");
|
|
||||||
|
|
||||||
const {
|
|
||||||
video,
|
|
||||||
audio,
|
|
||||||
close: demuxClose,
|
|
||||||
} = await demux(prepared.output, {
|
|
||||||
format: options.format ?? prepared.format ?? "nut",
|
|
||||||
frameRate:
|
|
||||||
typeof options.frameRate === "number" ? options.frameRate : undefined,
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
`[goLive:playStream] demux done codec=${video?.codecName ?? "?"} ${video?.width ?? 0}x${video?.height ?? 0} fps=${video ? video.framerate_num / video.framerate_den || 30 : 30} audio=${audio?.codecName ?? "none"}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!video) throw new Error("No video stream in media");
|
|
||||||
|
|
||||||
conn.setPacketizer(video.codecName);
|
|
||||||
conn.mediaConnection.setSpeaking(true);
|
|
||||||
console.log(
|
|
||||||
`[goLive:playStream] setPacketizer(${video.codecName}) + setSpeaking done`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const w =
|
|
||||||
typeof options.width === "function"
|
|
||||||
? options.width(video)
|
|
||||||
: (options.width ?? video.width);
|
|
||||||
const h =
|
|
||||||
typeof options.height === "function"
|
|
||||||
? options.height(video)
|
|
||||||
: (options.height ?? video.height);
|
|
||||||
const fr =
|
|
||||||
typeof options.frameRate === "function"
|
|
||||||
? options.frameRate(video)
|
|
||||||
: (options.frameRate ??
|
|
||||||
(video.framerate_num / video.framerate_den || 30));
|
|
||||||
|
|
||||||
conn.mediaConnection.setVideoAttributes(true, {
|
|
||||||
width: Math.round(w),
|
|
||||||
height: Math.round(h),
|
|
||||||
fps: Math.round(fr),
|
|
||||||
});
|
|
||||||
|
|
||||||
const vStream = new VideoStream(conn);
|
|
||||||
video.stream.pipe(vStream);
|
|
||||||
|
|
||||||
// Audio: Discord's GoLive pipeline expects RTP on the audio SSRC too —
|
|
||||||
// a video-only stream (zero audio packets) shows a static tile/thumbnail
|
|
||||||
// instead of live video. Pipe opus frames from the demuxer (silence is
|
|
||||||
// injected at the encoder when the source has no audio track).
|
|
||||||
let aStream: AudioStream | undefined;
|
|
||||||
if (audio) {
|
|
||||||
aStream = new AudioStream(conn);
|
|
||||||
audio.stream.pipe(aStream);
|
|
||||||
console.log(
|
|
||||||
`[goLive:playStream] audio stream attached (${audio.codecName})`,
|
|
||||||
);
|
|
||||||
// NOTE: NO syncStream wiring here. Upstream dank sets
|
|
||||||
// `vStream.syncStream = aStream` because node-av provides real PTS from the
|
|
||||||
// NUT container, so ptsDelta() is meaningful (both streams in media time).
|
|
||||||
// Our raw-h264 demuxer synthesizes PTS per-stream from frame indexes in
|
|
||||||
// DIFFERENT timebases (video 1/fps, audio 1/48000). If audio starts late
|
|
||||||
// (ffmpeg audio init, Ogg header), ptsDelta stays positive forever →
|
|
||||||
// isAhead() → video sleeps in a loop → video freezes after ~1s. Per-stream
|
|
||||||
// sleep-PTS pacing alone keeps both at 1000ms/s, which is correct without
|
|
||||||
// a shared clock. (If real PTS is ever added, re-enable syncStream.)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
try {
|
|
||||||
prepared.command.kill("SIGTERM");
|
|
||||||
} catch {
|
|
||||||
/* already dead */
|
|
||||||
}
|
|
||||||
demuxClose();
|
|
||||||
try {
|
|
||||||
conn.mediaConnection.setSpeaking(false);
|
|
||||||
conn.mediaConnection.setVideoAttributes(false);
|
|
||||||
} catch {
|
|
||||||
/* connection already torn down */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// First-frame watchdog: if the encoder never delivers a single frame
|
|
||||||
// (dead merge input, empty stream, codec mismatch), fail fast instead of
|
|
||||||
// "playing" a black tile forever. The demuxer resolves with fallback
|
|
||||||
// metadata even when no frame ever arrives, so this timeout is the only
|
|
||||||
// place that detects "started but nothing flowing".
|
|
||||||
let firstFrameTimer: NodeJS.Timeout | null = null;
|
|
||||||
let gotFirstFrame = false;
|
|
||||||
const firstFrame = new Promise<void>((resolve, reject) => {
|
|
||||||
firstFrameTimer = setTimeout(() => {
|
|
||||||
if (!gotFirstFrame) {
|
|
||||||
cleanup();
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
"No video frames within 10s of stream start — input stream failed",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
video.stream.once("data", () => {
|
|
||||||
gotFirstFrame = true;
|
|
||||||
if (firstFrameTimer) clearTimeout(firstFrameTimer);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
let settled = false;
|
|
||||||
const settle = (fn: () => void) => () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
if (firstFrameTimer) clearTimeout(firstFrameTimer);
|
|
||||||
fn();
|
|
||||||
};
|
|
||||||
|
|
||||||
vStream.once("finish", () => {
|
|
||||||
settle(() => {
|
|
||||||
cleanup();
|
|
||||||
if (!gotFirstFrame) {
|
|
||||||
reject(new Error("Screen video stream ended without any frame"));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
vStream.once("error", () => {
|
|
||||||
settle(() => {
|
|
||||||
cleanup();
|
|
||||||
if (!gotFirstFrame) {
|
|
||||||
reject(new Error("Screen video stream errored before first frame"));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
// The stream may end without ever producing a frame (input was
|
|
||||||
// silently dead) — surface that instead of resolving "successfully".
|
|
||||||
video.stream.once("end", () => {
|
|
||||||
settle(() => {
|
|
||||||
cleanup();
|
|
||||||
if (!gotFirstFrame) {
|
|
||||||
reject(new Error("Screen video stream ended before any frame"));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
// Watchdog timeout: no frame arrived within 10s — fail fast instead of
|
|
||||||
// "playing" a black tile forever. cleanup() kills the encoder so the
|
|
||||||
// vStream finish/error handlers above still fire, but the settled guard
|
|
||||||
// ensures this rejection wins.
|
|
||||||
firstFrame.catch((err) => {
|
|
||||||
settle(() => {
|
|
||||||
cleanup();
|
|
||||||
reject(err);
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Encoders };
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/** GoLive helpers — ported from @dank074/discord-video-stream/utils.js. */
|
|
||||||
|
|
||||||
export function normalizeVideoCodec(
|
|
||||||
codec: string,
|
|
||||||
): "H264" | "H265" | "VP8" | "VP9" | "AV1" {
|
|
||||||
if (/H\.?264|AVC/i.test(codec)) return "H264";
|
|
||||||
if (/H\.?265|HEVC/i.test(codec)) return "H265";
|
|
||||||
if (/VP(8|9)/i.test(codec)) return codec.toUpperCase() as "VP8" | "VP9";
|
|
||||||
if (/AV1/i.test(codec)) return "AV1";
|
|
||||||
throw new Error(`Unknown codec: ${codec}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The available video streams are sent by the client on connection to the
|
|
||||||
* voice gateway using OpCode Identify (0); the server replies with the ssrc
|
|
||||||
* and rtxssrc for each available stream using OpCode Ready (2). RID
|
|
||||||
* distinguishes simulcast streams of the same video source — we only send one
|
|
||||||
* quality stream, so a single entry is hardcoded.
|
|
||||||
*/
|
|
||||||
export const STREAMS_SIMULCAST = [{ type: "screen", rid: "100", quality: 100 }];
|
|
||||||
|
|
||||||
export const max_int16bit = 2 ** 16;
|
|
||||||
export const max_int32bit = 2 ** 32;
|
|
||||||
|
|
||||||
export function isFiniteNonZero(n: unknown): n is number {
|
|
||||||
return typeof n === "number" && !!n && Number.isFinite(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedStreamKey {
|
|
||||||
type: "guild" | "call";
|
|
||||||
channelId: string;
|
|
||||||
guildId: string | null;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseStreamKey(streamKey: string): ParsedStreamKey {
|
|
||||||
const streamKeyArray = streamKey.split(":");
|
|
||||||
const type = streamKeyArray.shift();
|
|
||||||
if (type !== "guild" && type !== "call") {
|
|
||||||
throw new Error(`Invalid stream key type: ${type}`);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(type === "guild" && streamKeyArray.length < 3) ||
|
|
||||||
(type === "call" && streamKey.length < 2)
|
|
||||||
) {
|
|
||||||
throw new Error(`Invalid stream key: ${streamKey}`);
|
|
||||||
}
|
|
||||||
let guildId: string | null = null;
|
|
||||||
if (type === "guild") {
|
|
||||||
guildId = streamKeyArray.shift() ?? null;
|
|
||||||
}
|
|
||||||
const channelId = streamKeyArray.shift();
|
|
||||||
const userId = streamKeyArray.shift();
|
|
||||||
if (!channelId || !userId) {
|
|
||||||
throw new Error(`Invalid stream key: ${streamKey}`);
|
|
||||||
}
|
|
||||||
return { type, channelId, guildId, userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateStreamKey(
|
|
||||||
type: "guild" | "call",
|
|
||||||
guildId: string | null,
|
|
||||||
channelId: string,
|
|
||||||
userId: string,
|
|
||||||
): string {
|
|
||||||
return `${type}${type === "guild" ? `:${guildId}` : ""}:${channelId}:${userId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoiceChannelLike {
|
|
||||||
type: string;
|
|
||||||
id: string;
|
|
||||||
guildId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isVoiceChannel(channel: VoiceChannelLike): boolean {
|
|
||||||
return (
|
|
||||||
channel.type === "DM" ||
|
|
||||||
channel.type === "GROUP_DM" ||
|
|
||||||
channel.type === "GUILD_STAGE_VOICE" ||
|
|
||||||
channel.type === "GUILD_VOICE"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -83,12 +83,7 @@ export class CommandHandler {
|
|||||||
|
|
||||||
// Create domain-specific handlers with their dependencies
|
// Create domain-specific handlers with their dependencies
|
||||||
this.voiceHandler = new VoiceHandler(client, voiceController);
|
this.voiceHandler = new VoiceHandler(client, voiceController);
|
||||||
this.mediaHandler = new MediaHandler(client, () =>
|
this.mediaHandler = new MediaHandler();
|
||||||
voiceController.getStatus(),
|
|
||||||
);
|
|
||||||
// Give media handler access to disconnect/reconnect voice around screen
|
|
||||||
// share (GoLive needs its own WebRTC connection).
|
|
||||||
this.mediaHandler.setVoiceController(() => voiceController);
|
|
||||||
this.guildHandler = new GuildHandler(client);
|
this.guildHandler = new GuildHandler(client);
|
||||||
this.moderationHandler = new ModerationHandler(client);
|
this.moderationHandler = new ModerationHandler(client);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { StreamType } from "@discordjs/voice";
|
import { StreamType } from "@discordjs/voice";
|
||||||
import type { Client } from "discord.js-selfbot-v13";
|
|
||||||
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,10 +12,6 @@ import type {
|
|||||||
MediaQueueItem,
|
MediaQueueItem,
|
||||||
} from "../voice-recording/mediaTypes.js";
|
} from "../voice-recording/mediaTypes.js";
|
||||||
import { discordPlayer } from "../voice-recording/player.js";
|
import { discordPlayer } from "../voice-recording/player.js";
|
||||||
import {
|
|
||||||
ScreenShareController,
|
|
||||||
type ScreenShareVoiceStatus,
|
|
||||||
} from "../voice-recording/screenShareController.js";
|
|
||||||
import { setMediaStatusKey } from "./mediaStatusSink.js";
|
import { setMediaStatusKey } from "./mediaStatusSink.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -84,17 +79,8 @@ function buildStatusPayload(): MediaStatusPayload {
|
|||||||
|
|
||||||
export class MediaHandler {
|
export class MediaHandler {
|
||||||
private logger = createChildLogger("media-handler");
|
private logger = createChildLogger("media-handler");
|
||||||
private screenController: ScreenShareController | null = null;
|
|
||||||
private screenPlayback: { stop(): void } | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
private readonly client: Client | null = null,
|
|
||||||
private readonly getVoiceStatus: () => ScreenShareVoiceStatus = () => ({
|
|
||||||
connected: false,
|
|
||||||
activeGuildId: null,
|
|
||||||
activeChannelId: null,
|
|
||||||
}),
|
|
||||||
) {
|
|
||||||
// Register auto-advance on natural track end. advanceQueue mutates the
|
// Register auto-advance on natural track end. advanceQueue mutates the
|
||||||
// module-level currentTrackItem/queue, so we must re-publish the status
|
// module-level currentTrackItem/queue, so we must re-publish the status
|
||||||
// key afterward: otherwise the backend's Redis `media:status` cache (and
|
// key afterward: otherwise the backend's Redis `media:status` cache (and
|
||||||
@@ -108,31 +94,11 @@ export class MediaHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Give MediaHandler access to the VoiceController so screen-share can
|
|
||||||
* disconnect/reconnect the @discordjs audio connection around a GoLive
|
|
||||||
* stream (Discord allows only one voice session per user).
|
|
||||||
*/
|
|
||||||
private voiceControllerAccessor:
|
|
||||||
| (() => {
|
|
||||||
disconnectGuild(guildId: string): Promise<void>;
|
|
||||||
connect(guildId: string, channelId: string): Promise<unknown>;
|
|
||||||
getStatus(): {
|
|
||||||
activeGuildId: string | null;
|
|
||||||
activeChannelId: string | null;
|
|
||||||
};
|
|
||||||
})
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
setVoiceController(accessor: typeof this.voiceControllerAccessor): void {
|
|
||||||
this.voiceControllerAccessor = accessor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist the latest media state to Redis so the backend/frontend see queue
|
* Persist the latest media state to Redis so the backend/frontend see queue
|
||||||
* advances that happen outside a command (natural track end, screen-share
|
* advances that happen outside a command (natural track end). CommandHandler
|
||||||
* done). CommandHandler owns the Redis status-key writes for command-triggered
|
* owns the Redis status-key writes for command-triggered changes; this
|
||||||
* changes; this covers the side-effect-only path.
|
* covers the side-effect-only path.
|
||||||
*/
|
*/
|
||||||
private publishStatus(): void {
|
private publishStatus(): void {
|
||||||
try {
|
try {
|
||||||
@@ -152,7 +118,6 @@ export class MediaHandler {
|
|||||||
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||||
// Accept both `url` (canonical) and `source` (legacy FE) for resilience.
|
// Accept both `url` (canonical) and `source` (legacy FE) for resilience.
|
||||||
const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim();
|
const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim();
|
||||||
const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music";
|
|
||||||
const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
|
const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -175,74 +140,6 @@ export class MediaHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Screen share (GoLive) path — bypasses the audio queue entirely.
|
|
||||||
if (mode === "screen") {
|
|
||||||
try {
|
|
||||||
if (!this.client) {
|
|
||||||
return {
|
|
||||||
id: cmd.id,
|
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
error: "Gateway client not initialized",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!this.screenController) {
|
|
||||||
this.screenController = new ScreenShareController(
|
|
||||||
this.client,
|
|
||||||
this.getVoiceStatus,
|
|
||||||
// releaseVoice — disconnect the @discordjs/voice connection so the
|
|
||||||
// dank074 Streamer can take over (Discord: one voice session/user).
|
|
||||||
async (status) => {
|
|
||||||
const vc = this.voiceControllerAccessor?.();
|
|
||||||
const guildId = status.activeGuildId ?? null;
|
|
||||||
if (vc && guildId) {
|
|
||||||
await vc.disconnectGuild(guildId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// restoreVoice — reconnect the @discordjs audio connection after
|
|
||||||
// the stream ends so mic/listen keep working.
|
|
||||||
async (status) => {
|
|
||||||
const vc = this.voiceControllerAccessor?.();
|
|
||||||
if (vc && status.activeGuildId && status.activeChannelId) {
|
|
||||||
await vc.connect(status.activeGuildId, status.activeChannelId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const playback = await this.screenController.start(url);
|
|
||||||
this.screenPlayback = playback;
|
|
||||||
currentTrackItem = {
|
|
||||||
id: randomUUID(),
|
|
||||||
source: url,
|
|
||||||
title: url,
|
|
||||||
kind: "url",
|
|
||||||
mode: "screen",
|
|
||||||
requestedBy,
|
|
||||||
addedAt: Date.now(),
|
|
||||||
status: "playing",
|
|
||||||
};
|
|
||||||
playback.done
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error(
|
|
||||||
{ error: err instanceof Error ? err.message : String(err) },
|
|
||||||
"Screen playback promise rejected",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.screenPlayback = null;
|
|
||||||
if (currentTrackItem?.mode === "screen") {
|
|
||||||
currentTrackItem = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.logger.info({ url }, "Screen share started");
|
|
||||||
return { id: cmd.id, success: true, data: buildStatusPayload() };
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error({ error: message }, "Screen share failed to start");
|
|
||||||
return { id: cmd.id, success: false, data: null, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lightweight metadata fetch for display — the full resolve happens in playNext
|
// Lightweight metadata fetch for display — the full resolve happens in playNext
|
||||||
let title: string = url;
|
let title: string = url;
|
||||||
let duration: number | undefined;
|
let duration: number | undefined;
|
||||||
@@ -264,7 +161,7 @@ export class MediaHandler {
|
|||||||
source: url,
|
source: url,
|
||||||
title,
|
title,
|
||||||
kind: "url" as const,
|
kind: "url" as const,
|
||||||
mode,
|
mode: "music",
|
||||||
requestedBy,
|
requestedBy,
|
||||||
addedAt: Date.now(),
|
addedAt: Date.now(),
|
||||||
status: "queued",
|
status: "queued",
|
||||||
@@ -308,12 +205,6 @@ export class MediaHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||||
// Stop screen share if active — playback.done.finally clears the item.
|
|
||||||
this.screenPlayback?.stop();
|
|
||||||
this.screenPlayback = null;
|
|
||||||
if (currentTrackItem?.mode === "screen") {
|
|
||||||
currentTrackItem = null;
|
|
||||||
}
|
|
||||||
discordPlayer.stop("music");
|
discordPlayer.stop("music");
|
||||||
currentTrackItem = null;
|
currentTrackItem = null;
|
||||||
mediaQueue.length = 0; // Clear entire queue
|
mediaQueue.length = 0; // Clear entire queue
|
||||||
@@ -394,7 +285,7 @@ export class MediaHandler {
|
|||||||
// Music playback: transcode once to high-quality OggOpus (48kHz stereo,
|
// Music playback: transcode once to high-quality OggOpus (48kHz stereo,
|
||||||
// 192kbps) with volume baked into the encode. This avoids the double
|
// 192kbps) with volume baked into the encode. This avoids the double
|
||||||
// lossy encode that inlineVolume would cause and gives Discord the
|
// lossy encode that inlineVolume would cause and gives Discord the
|
||||||
// cleanest possible stream. Screen share bypasses this entirely.
|
// cleanest possible stream.
|
||||||
const transcoded = transcodeToHighQualityOgg(
|
const transcoded = transcodeToHighQualityOgg(
|
||||||
resolution.stream,
|
resolution.stream,
|
||||||
discordPlayer.getMusicVolume(),
|
discordPlayer.getMusicVolume(),
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { type ChildProcess, spawn } from "node:child_process";
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
import {
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
chmodSync,
|
|
||||||
existsSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
statSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { PassThrough, type Readable } from "node:stream";
|
import { PassThrough, type Readable } from "node:stream";
|
||||||
@@ -232,7 +223,7 @@ function buildNotInstalledError(): Error {
|
|||||||
* in account's cookies. The path is configurable via GMW_YT_COOKIES_PATH
|
* in account's cookies. The path is configurable via GMW_YT_COOKIES_PATH
|
||||||
* (default: the BWS-provided file the deploy writes to /etc/.../ytcookies.txt).
|
* (default: the BWS-provided file the deploy writes to /etc/.../ytcookies.txt).
|
||||||
* If the file doesn't exist we pass nothing and fall back to anon (YouTube
|
* If the file doesn't exist we pass nothing and fall back to anon (YouTube
|
||||||
* may 403 — screen share will fail gracefully, not crash).
|
* may 403 — playback will fail gracefully, not crash).
|
||||||
*/
|
*/
|
||||||
var _cachedCookiePath: string | null = null;
|
var _cachedCookiePath: string | null = null;
|
||||||
function buildCookieArgs(): string[] {
|
function buildCookieArgs(): string[] {
|
||||||
@@ -267,7 +258,7 @@ function buildCookieArgs(): string[] {
|
|||||||
// Never hand the ORIGINAL system file to yt-dlp: recent yt-dlp rewrites
|
// Never hand the ORIGINAL system file to yt-dlp: recent yt-dlp rewrites
|
||||||
// the cookie file on close (`--cookies` implies write-back). The system
|
// the cookie file on close (`--cookies` implies write-back). The system
|
||||||
// file is owned by another user (root/deploy) and the service user
|
// file is owned by another user (root/deploy) and the service user
|
||||||
// cannot write it → PermissionError → yt-dlp exits 1 → screen share
|
// cannot write it → PermissionError → yt-dlp exits 1 → playback
|
||||||
// fails for every attempt. Copy to a per-run temp file (like the env
|
// fails for every attempt. Copy to a per-run temp file (like the env
|
||||||
// branch above) so write-back lands somewhere we own; if the original
|
// branch above) so write-back lands somewhere we own; if the original
|
||||||
// is not readable we fall back to anonymous (YouTube may 403 → the
|
// is not readable we fall back to anonymous (YouTube may 403 → the
|
||||||
@@ -305,44 +296,6 @@ function buildCookieArgs(): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invidious instances for anon YouTube fetch (fallback when cookies 403). */
|
|
||||||
export const INVIDIOUS_INSTANCES = [
|
|
||||||
"yewtu.be",
|
|
||||||
"yewtu.nanomorph.dev",
|
|
||||||
"invidious.snopyta.org",
|
|
||||||
"invidious.kavin.rocks",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** True if url is a YouTube watch URL (youtu.be / youtube.com/watch). */
|
|
||||||
export function isYoutubeWatchUrl(url: string): boolean {
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
return (
|
|
||||||
u.hostname === "youtu.be" ||
|
|
||||||
(u.hostname === "www.youtube.com" && u.pathname === "/watch") ||
|
|
||||||
(u.hostname === "youtube.com" && u.pathname === "/watch")
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rewrite a YouTube watch URL to an invidious instance (anon, no bot-check). */
|
|
||||||
export function toInvidiousUrl(url: string, instance: string): string {
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
if (u.hostname === "youtu.be") {
|
|
||||||
const id = u.pathname.slice(1);
|
|
||||||
return `https://${instance}/watch?v=${id}`;
|
|
||||||
}
|
|
||||||
const id = u.searchParams.get("v");
|
|
||||||
if (id) return `https://${instance}/watch?v=${id}`;
|
|
||||||
return url;
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -505,114 +458,6 @@ export function resolveMediaUrl(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Download the merged video+audio media for screen share to a TEMP FILE
|
|
||||||
* first, then return the file path.
|
|
||||||
*
|
|
||||||
* Screen-share playback does NOT stream from yt-dlp stdout anymore: the
|
|
||||||
* merge feed is delivered at network speed (bursts + stalls), and a live
|
|
||||||
* pipe defeats ffmpeg's `-re` throttle (the input PTS timeline is
|
|
||||||
* unreliable), so the encoder force-duplicates held frames → ~1fps video.
|
|
||||||
* Downloading the FULL clip to a file first gives the encoder a clean,
|
|
||||||
* monotonic-PTS input, where `-re` (applied in prepareStream for string
|
|
||||||
* inputs) pacing is proven reliable.
|
|
||||||
*
|
|
||||||
* @returns absolute path of the completed media file (caller should delete
|
|
||||||
* it via cleanup after playback ends).
|
|
||||||
*/
|
|
||||||
export function downloadScreenInput(url: string): Promise<string> {
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
// Temp dir per run (world-writable like /tmp) so parallel/retry runs
|
|
||||||
// never collide on merge fragments.
|
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), "gmw-ytdlp-"));
|
|
||||||
chmodSync(tmpDir, 0o1777);
|
|
||||||
|
|
||||||
const cookieArgs = buildCookieArgs();
|
|
||||||
const args = [
|
|
||||||
"-f",
|
|
||||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
|
||||||
// File output (NOT `-o -`): yt-dlp merges DASH fragments into a real
|
|
||||||
// container with clean timestamps, which is what -re needs to pace.
|
|
||||||
"-o",
|
|
||||||
join(tmpDir, "media.%(ext)s"),
|
|
||||||
"--no-playlist",
|
|
||||||
"--no-warnings",
|
|
||||||
"--no-progress",
|
|
||||||
...cookieArgs,
|
|
||||||
url,
|
|
||||||
];
|
|
||||||
|
|
||||||
logger.info({ url }, "Downloading full media for screen share input");
|
|
||||||
|
|
||||||
const proc = spawn("yt-dlp", args, {
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
activeProcesses.add(proc);
|
|
||||||
|
|
||||||
let stderrBuf = "";
|
|
||||||
const MAX_STDERR = 4096;
|
|
||||||
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
||||||
if (stderrBuf.length < MAX_STDERR) {
|
|
||||||
stderrBuf += chunk.toString("utf8");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let settled = false;
|
|
||||||
const failOnce = (message: string) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
activeProcesses.delete(proc);
|
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
reject(new Error(message));
|
|
||||||
};
|
|
||||||
|
|
||||||
proc.on("error", (err: NodeJS.ErrnoException) => {
|
|
||||||
failOnce(`yt-dlp failed to start: ${err.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
let procFinished = false;
|
|
||||||
proc.on("close", (code) => {
|
|
||||||
procFinished = true;
|
|
||||||
activeProcesses.delete(proc);
|
|
||||||
if (code !== 0) {
|
|
||||||
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
|
||||||
failOnce(`yt-dlp download failed (exit ${code})${detail}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Find the media file yt-dlp wrote (skip .part / .ytdl temp state).
|
|
||||||
let mediaPath: string | null = null;
|
|
||||||
for (const entry of readdirSync(tmpDir)) {
|
|
||||||
if (entry.endsWith(".part") || entry.endsWith(".ytdl")) continue;
|
|
||||||
mediaPath = join(tmpDir, entry);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (mediaPath && existsSync(mediaPath) && statSync(mediaPath).size > 0) {
|
|
||||||
settled = true;
|
|
||||||
resolve(mediaPath);
|
|
||||||
} else {
|
|
||||||
failOnce("yt-dlp finished but produced no media file");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Safety net: a stalled download must not hang the gateway forever.
|
|
||||||
const timer = setTimeout(
|
|
||||||
() => {
|
|
||||||
try {
|
|
||||||
proc.kill("SIGTERM");
|
|
||||||
} catch {
|
|
||||||
/* already dead */
|
|
||||||
}
|
|
||||||
if (!procFinished) {
|
|
||||||
failOnce("yt-dlp download timed out (10 min)");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
10 * 60 * 1000,
|
|
||||||
);
|
|
||||||
proc.once("close", () => clearTimeout(timer));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract metadata (title, duration, thumbnail) from a media URL
|
* Extract metadata (title, duration, thumbnail) from a media URL
|
||||||
* without downloading the audio stream.
|
* without downloading the audio stream.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
import type { StreamType } from "@discordjs/voice";
|
import type { StreamType } from "@discordjs/voice";
|
||||||
|
|
||||||
export type MediaMode = "music" | "screen";
|
export type MediaMode = "music";
|
||||||
export type MediaSourceKind =
|
export type MediaSourceKind =
|
||||||
| "url"
|
| "url"
|
||||||
| "local"
|
| "local"
|
||||||
@@ -51,23 +51,7 @@ export interface MusicPlayer {
|
|||||||
play(source: ResolvedMediaSource): MusicPlayback;
|
play(source: ResolvedMediaSource): MusicPlayback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScreenSharePlayback {
|
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music";
|
||||||
done: Promise<void>;
|
|
||||||
stop(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenShareVoiceStatus {
|
|
||||||
connected: boolean;
|
|
||||||
activeGuildId: string | null;
|
|
||||||
activeChannelId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenShareController {
|
|
||||||
isActive(): boolean;
|
|
||||||
start(source: string): Promise<ScreenSharePlayback>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
|
||||||
|
|
||||||
export interface DiscordPlayOptions {
|
export interface DiscordPlayOptions {
|
||||||
inputType?: StreamType;
|
inputType?: StreamType;
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
import { rmSync } from "node:fs";
|
|
||||||
import { dirname } from "node:path";
|
|
||||||
import type { Client } from "discord.js-selfbot-v13";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import {
|
|
||||||
Encoders,
|
|
||||||
normalizeVideoCodec,
|
|
||||||
playStream,
|
|
||||||
prepareStream,
|
|
||||||
Streamer,
|
|
||||||
} from "../../goLive/index.js";
|
|
||||||
import {
|
|
||||||
downloadScreenInput,
|
|
||||||
INVIDIOUS_INSTANCES,
|
|
||||||
isYoutubeWatchUrl,
|
|
||||||
toInvidiousUrl,
|
|
||||||
} from "./mediaSource.js";
|
|
||||||
import type { ScreenSharePlayback } from "./mediaTypes.js";
|
|
||||||
import { discordPlayer } from "./player.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("screen-share");
|
|
||||||
|
|
||||||
export interface ScreenShareVoiceStatus {
|
|
||||||
connected: boolean;
|
|
||||||
activeGuildId: string | null;
|
|
||||||
activeChannelId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discord Go Live (screenshare) via @dank074/discord-video-stream.
|
|
||||||
*
|
|
||||||
* Pipeline:
|
|
||||||
* URL (YouTube, dll.) → yt-dlp direct video URL → ffmpeg (H264 720p30) →
|
|
||||||
* playStream({ type: "go-live" }) → Discord voice channel as Go Live.
|
|
||||||
*
|
|
||||||
* Restored from the pre-microservices implementation (commit d50ce86,
|
|
||||||
* src/media/screenShareController.ts) — the interface survived in
|
|
||||||
* mediaTypes.ts but the implementation was lost during the split.
|
|
||||||
*/
|
|
||||||
export class ScreenShareController {
|
|
||||||
private logger = createChildLogger("screen-share");
|
|
||||||
private streamer: Streamer | null = null;
|
|
||||||
private active: ScreenSharePlayback | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly client: Client,
|
|
||||||
private readonly getVoiceStatus: () => ScreenShareVoiceStatus,
|
|
||||||
/** Disconnect the @discordjs/voice connection so the Streamer can take
|
|
||||||
* over the voice channel (Discord allows only ONE voice session per user
|
|
||||||
* — two connections collide and the Streamer never gets VOICE_SERVER_UPDATE). */
|
|
||||||
private readonly releaseVoice: (
|
|
||||||
status: ScreenShareVoiceStatus,
|
|
||||||
) => void | Promise<void>,
|
|
||||||
/** Reconnect the @discordjs/voice connection after the stream ends. */
|
|
||||||
private readonly restoreVoice: (
|
|
||||||
status: ScreenShareVoiceStatus,
|
|
||||||
) => void | Promise<void>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
isActive(): boolean {
|
|
||||||
return this.active !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the screen-share input with retry (downloads the FULL media to a
|
|
||||||
* temp file; returns the file path).
|
|
||||||
*
|
|
||||||
* Transient YouTube 403s kill a download BEFORE completion; we validate
|
|
||||||
* the finished file and retry with a FRESH yt-dlp run (signed DASH URLs
|
|
||||||
* expire quickly). Downloading to a file (instead of streaming the merge
|
|
||||||
* pipe) gives the encoder a monotonic-PTS input, so ffmpeg `-re` pacing
|
|
||||||
* in prepareStream actually works (it does NOT on live pipes — the root
|
|
||||||
* of the ~1fps video).
|
|
||||||
*/
|
|
||||||
private async resolveInputWithRetry(source: string): Promise<string> {
|
|
||||||
const MAX_ATTEMPTS = 3;
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
// YouTube may 403 even with account cookies (IP-bound session / bot check
|
|
||||||
// on VPS IP). When the source is a YouTube URL and cookies fail, fall back
|
|
||||||
// to anon Invidious mirror instances — no auth needed.
|
|
||||||
const isYt = isYoutubeWatchUrl(source);
|
|
||||||
let invidiousIdx = 0;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
||||||
// On a 403 against YouTube, try the next Invidious instance for this attempt.
|
|
||||||
if (
|
|
||||||
isYt &&
|
|
||||||
lastError &&
|
|
||||||
/403|bot|Sign in|not a bot|access denied|permission|EACCES|cookie/i.test(
|
|
||||||
lastError.message,
|
|
||||||
) &&
|
|
||||||
invidiousIdx < INVIDIOUS_INSTANCES.length
|
|
||||||
) {
|
|
||||||
const inst = INVIDIOUS_INSTANCES[invidiousIdx];
|
|
||||||
this.logger.warn(
|
|
||||||
{ attempt, instance: inst, error: lastError.message },
|
|
||||||
"YouTube blocked (403); falling back to Invidious mirror",
|
|
||||||
);
|
|
||||||
source = toInvidiousUrl(source, inst);
|
|
||||||
invidiousIdx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mediaPath = await downloadScreenInput(source);
|
|
||||||
this.logger.info(
|
|
||||||
{ mediaPath, attempt },
|
|
||||||
"Screen input downloaded to file",
|
|
||||||
);
|
|
||||||
return mediaPath;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
this.logger.warn(
|
|
||||||
{
|
|
||||||
attempt,
|
|
||||||
maxAttempts: MAX_ATTEMPTS,
|
|
||||||
error: lastError.message,
|
|
||||||
},
|
|
||||||
"Screen input download failed; retrying with fresh yt-dlp",
|
|
||||||
);
|
|
||||||
if (attempt < MAX_ATTEMPTS) {
|
|
||||||
await new Promise((r) => setTimeout(r, 1500 * attempt));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw (
|
|
||||||
lastError ??
|
|
||||||
new Error("Screen input resolution failed after multiple attempts")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(source: string): Promise<ScreenSharePlayback> {
|
|
||||||
const status = this.getVoiceStatus();
|
|
||||||
if (!status.connected || !status.activeGuildId || !status.activeChannelId) {
|
|
||||||
throw new Error("Connect to a voice channel before sharing screen");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.active || discordPlayer.getOwner() !== "none") {
|
|
||||||
throw new Error("Another media mode is active");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const input = await this.resolveInputWithRetry(source);
|
|
||||||
if (!this.streamer) {
|
|
||||||
this.streamer = new Streamer(this.client);
|
|
||||||
}
|
|
||||||
|
|
||||||
const guild = this.client.guilds.cache.get(status.activeGuildId);
|
|
||||||
const channel = guild?.channels.cache.get(status.activeChannelId);
|
|
||||||
if (
|
|
||||||
!channel ||
|
|
||||||
(channel.type !== "GUILD_VOICE" && channel.type !== "GUILD_STAGE_VOICE")
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Voice channel ${status.activeChannelId} not found for screen share`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free the @discordjs/voice connection BEFORE the Streamer joins, so
|
|
||||||
// the user has only one voice session (Discord requirement).
|
|
||||||
await this.releaseVoice(status);
|
|
||||||
|
|
||||||
await Promise.race([
|
|
||||||
this.streamer.joinVoiceChannel(channel),
|
|
||||||
new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() =>
|
|
||||||
reject(
|
|
||||||
new Error("Timed out joining voice channel for screen share"),
|
|
||||||
),
|
|
||||||
15000,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const prepared = prepareStream(input, {
|
|
||||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
frameRate: 30,
|
|
||||||
bitrateVideo: 2500,
|
|
||||||
bitrateVideoMax: 4000,
|
|
||||||
// GoLive with audio: the encoder muxes to NUT (video h264 + opus
|
|
||||||
// audio) so the audio SSRC carries RTP too. Discord's GoLive
|
|
||||||
// pipeline expects audio — a video-only stream shows a static
|
|
||||||
// tile/thumbnail instead of live video. When the source has no
|
|
||||||
// audio track, the encoder's `-map 0:a:0?` yields no audio stream
|
|
||||||
// and the demuxer simply reports none (video still flows).
|
|
||||||
includeAudio: true,
|
|
||||||
videoCodec: normalizeVideoCodec("H264"),
|
|
||||||
});
|
|
||||||
const { command } = prepared;
|
|
||||||
|
|
||||||
// The downloaded temp media lives in a per-run tmpdir; remove it once
|
|
||||||
// playback is done (natural end, failure, or user stop). The tmpdir is
|
|
||||||
// the parent of the media file, so deleting it removes the file too.
|
|
||||||
const cleanupTempMedia = () => {
|
|
||||||
try {
|
|
||||||
if (typeof input === "string") {
|
|
||||||
rmSync(dirname(input), { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* best-effort — tmp dirs are world-writable, leak is bounded */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let stopped = false;
|
|
||||||
// Restore the @discordjs/voice connection after the stream ends (both
|
|
||||||
// natural end and failure), so the user can keep using audio/mic.
|
|
||||||
const restoreAfter = () => {
|
|
||||||
if (!stopped) {
|
|
||||||
stopped = true;
|
|
||||||
try {
|
|
||||||
command.kill("SIGTERM");
|
|
||||||
} catch {
|
|
||||||
/* already dead */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleanupTempMedia();
|
|
||||||
try {
|
|
||||||
this.streamer?.voiceConnection?.stop();
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
if (this.restoreVoice) {
|
|
||||||
// Best-effort restore after a short delay. Discord often needs the
|
|
||||||
// Streamer's session fully torn down before @discordjs/voice can
|
|
||||||
// re-join; if that races, the reconnect times out — the FE shows
|
|
||||||
// disconnected and the user just clicks Connect again. This is an
|
|
||||||
// accepted UX tradeoff for GoLive (single voice session per user).
|
|
||||||
setTimeout(() => {
|
|
||||||
Promise.resolve(this.restoreVoice(status)).catch((err) => {
|
|
||||||
this.logger.warn(
|
|
||||||
{ error: err instanceof Error ? err.message : String(err) },
|
|
||||||
"Failed to restore voice connection after screen share (user can reconnect manually)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const done = playStream(prepared, this.streamer, {
|
|
||||||
type: "go-live",
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
frameRate: 30,
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
// Never let a stream failure become an unhandledRejection — that
|
|
||||||
// crashed the whole gateway. Log + surface via the done promise.
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(
|
|
||||||
{ error: message, source },
|
|
||||||
"Screen stream failed during playback",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
restoreAfter();
|
|
||||||
this.active = null;
|
|
||||||
});
|
|
||||||
this.active = {
|
|
||||||
done,
|
|
||||||
stop: () => {
|
|
||||||
if (stopped) return;
|
|
||||||
stopped = true;
|
|
||||||
try {
|
|
||||||
command.kill("SIGTERM");
|
|
||||||
} catch {
|
|
||||||
/* already dead */
|
|
||||||
}
|
|
||||||
cleanupTempMedia();
|
|
||||||
// Leave the voice channel the Streamer joined (its own connection).
|
|
||||||
try {
|
|
||||||
this.streamer?.voiceConnection?.stop();
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
this.active = null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info({ source }, "Screen share started");
|
|
||||||
return this.active;
|
|
||||||
} catch (error) {
|
|
||||||
this.active = null;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error({ error: message, source }, "Screen stream failed");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { PassThrough } from "node:stream";
|
|
||||||
import { demux } from "../src/goLive/Demuxer.js";
|
|
||||||
|
|
||||||
/** Same resolution as Demuxer.resolveBin: env override → Nix store scan. */
|
|
||||||
function resolveFfmpeg(): string | null {
|
|
||||||
const override = process.env.FFMPEG_PATH;
|
|
||||||
if (override && existsSync(override)) return override;
|
|
||||||
const store = "/nix/store";
|
|
||||||
if (existsSync(store)) {
|
|
||||||
for (const entry of readdirSync(store)) {
|
|
||||||
if (!entry.includes("ffmpeg-headless-")) continue;
|
|
||||||
const candidate = join(store, entry, "bin", "ffmpeg");
|
|
||||||
if (existsSync(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return existsSync("/usr/bin/ffmpeg") ? "/usr/bin/ffmpeg" : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ogg Opus byte stream built by hand: OpusHead (19B) + a few 20ms opus
|
|
||||||
* frames, wrapped in valid Ogg pages (CRC 0 — the parser ignores CRC).
|
|
||||||
*/
|
|
||||||
function buildOggOpusBytes(frames: number): Buffer {
|
|
||||||
const opusHead = Buffer.from([
|
|
||||||
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
|
|
||||||
0x01, 0x02, 0x38, 0x01, 0x80, 0xbb, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0x00, // version 1, 2ch, 48000
|
|
||||||
]);
|
|
||||||
// A minimal valid opus data packet: TOC 0xFC (48kHz stereo 20ms) + payload
|
|
||||||
const dataPacket = Buffer.alloc(20, 0);
|
|
||||||
dataPacket[0] = 0xfc;
|
|
||||||
const makePage = (
|
|
||||||
seq: number,
|
|
||||||
headerType: number,
|
|
||||||
serial: number,
|
|
||||||
packets: Buffer[],
|
|
||||||
): Buffer => {
|
|
||||||
const segmentTable: number[] = [];
|
|
||||||
const payloadParts: Buffer[] = [];
|
|
||||||
for (const p of packets) {
|
|
||||||
let remaining = p.length;
|
|
||||||
let off = 0;
|
|
||||||
do {
|
|
||||||
const chunk = Math.min(255, remaining);
|
|
||||||
segmentTable.push(chunk);
|
|
||||||
payloadParts.push(p.subarray(off, off + chunk));
|
|
||||||
off += chunk;
|
|
||||||
remaining -= chunk;
|
|
||||||
} while (remaining > 0);
|
|
||||||
}
|
|
||||||
const payload = Buffer.concat(payloadParts);
|
|
||||||
const header = Buffer.alloc(27 + segmentTable.length);
|
|
||||||
header.write("OggS", 0, "latin1");
|
|
||||||
header[4] = 0; // version
|
|
||||||
header[5] = headerType;
|
|
||||||
header.writeUInt32LE(0, 6); // granule (unused)
|
|
||||||
header.writeUInt32LE(0, 10);
|
|
||||||
header.writeUInt32LE(serial, 14);
|
|
||||||
header.writeUInt32LE(seq, 18);
|
|
||||||
header.writeUInt32LE(0, 22); // crc (ignored)
|
|
||||||
header[26] = segmentTable.length;
|
|
||||||
for (let i = 0; i < segmentTable.length; i++) header[27 + i] = segmentTable[i];
|
|
||||||
return Buffer.concat([header, payload]);
|
|
||||||
};
|
|
||||||
const pages: Buffer[] = [];
|
|
||||||
const serial = 0x1234;
|
|
||||||
let seq = 0;
|
|
||||||
// Page 0: BOS + OpusHead (19 bytes, single lacing)
|
|
||||||
pages.push(makePage(seq++, 0x02, serial, [opusHead]));
|
|
||||||
// Pages 1+: data packets, a few per page
|
|
||||||
const perPage = 3;
|
|
||||||
for (let i = 0; i < frames; i += perPage) {
|
|
||||||
const pkts = [];
|
|
||||||
for (let j = 0; j < perPage && i + j < frames; j++) pkts.push(dataPacket);
|
|
||||||
pages.push(makePage(seq++, 0x00, serial, pkts));
|
|
||||||
}
|
|
||||||
return Buffer.concat(pages);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Demuxer NUT path with audio", () => {
|
|
||||||
it("emits video access units AND parsed opus audio frames", async () => {
|
|
||||||
// Real NUT file (video h264 + opus) produced by ffmpeg — generated once
|
|
||||||
// in this test via ffmpeg, skipped if ffmpeg is unavailable.
|
|
||||||
const ffmpeg = resolveFfmpeg();
|
|
||||||
if (!ffmpeg) {
|
|
||||||
console.warn("ffmpeg not found — skipping NUT integration case");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "gmw-nut-test-"));
|
|
||||||
const inWebm = join(dir, "in.webm");
|
|
||||||
const inNut = join(dir, "in.nut");
|
|
||||||
try {
|
|
||||||
// Build a tiny webm (vpx + opus) then remux to NUT h264+opus — mirrors
|
|
||||||
// prepareStream(includeAudio) output.
|
|
||||||
const { spawnSync } = await import("node:child_process");
|
|
||||||
const gen = spawnSync(
|
|
||||||
ffmpeg,
|
|
||||||
[
|
|
||||||
"-hide_banner", "-loglevel", "error",
|
|
||||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=3",
|
|
||||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
|
|
||||||
"-c:v", "libvpx-vp9", "-b:v", "100k", "-pix_fmt", "yuv420p",
|
|
||||||
"-c:a", "libopus", "-b:a", "48k", "-f", "webm", "-y", inWebm,
|
|
||||||
],
|
|
||||||
{ timeout: 20000 },
|
|
||||||
);
|
|
||||||
if (gen.status !== 0) {
|
|
||||||
console.warn("ffmpeg webm gen failed — skipping", gen.stderr?.toString().slice(0, 200));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const enc = spawnSync(
|
|
||||||
ffmpeg,
|
|
||||||
[
|
|
||||||
"-hide_banner", "-loglevel", "error", "-i", inWebm,
|
|
||||||
"-map", "0:v:0", "-c:v", "libx264", "-profile:v", "baseline",
|
|
||||||
"-x264-params", "repeat-headers=1", "-preset", "superfast",
|
|
||||||
"-pix_fmt", "yuv420p", "-g", "10", "-forced-idr", "1",
|
|
||||||
"-map", "0:a:0?", "-c:a", "libopus", "-b:a", "48k", "-ar", "48000", "-ac", "2",
|
|
||||||
"-f", "nut", "-y", inNut,
|
|
||||||
],
|
|
||||||
{ timeout: 20000 },
|
|
||||||
);
|
|
||||||
if (enc.status !== 0) {
|
|
||||||
console.warn("ffmpeg nut gen failed — skipping", enc.stderr?.toString().slice(0, 200));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { readFileSync } = await import("node:fs");
|
|
||||||
// demux() takes string | PassThrough — wrap the file read. Drip the
|
|
||||||
// bytes in quickly (well within demux's 1.5s metadata window) so ffmpeg
|
|
||||||
// prints both stream lines before demux() resolves.
|
|
||||||
const input = new PassThrough();
|
|
||||||
const nutBytes = readFileSync(inNut);
|
|
||||||
const CHUNK = Math.max(1024, Math.floor(nutBytes.length / 20));
|
|
||||||
let off = 0;
|
|
||||||
const drip = setInterval(() => {
|
|
||||||
if (off >= nutBytes.length) {
|
|
||||||
clearInterval(drip);
|
|
||||||
input.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.write(nutBytes.subarray(off, off + CHUNK));
|
|
||||||
off += CHUNK;
|
|
||||||
}, 10);
|
|
||||||
const { video, audio, close } = await demux(input, {
|
|
||||||
format: "nut",
|
|
||||||
frameRate: 10,
|
|
||||||
});
|
|
||||||
const vFrames: number[] = [];
|
|
||||||
const aFrames: number[] = [];
|
|
||||||
video?.stream.on("data", (f: { data: Buffer | null; flags: number }) => {
|
|
||||||
if (f.data) vFrames.push(f.data.length);
|
|
||||||
});
|
|
||||||
audio?.stream.on("data", (f: { data: Buffer | null; duration: number }) => {
|
|
||||||
if (f.data) aFrames.push(f.duration);
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
video?.stream.on("end", resolve);
|
|
||||||
setTimeout(resolve, 6000);
|
|
||||||
});
|
|
||||||
close();
|
|
||||||
expect(vFrames.length).toBeGreaterThan(0);
|
|
||||||
// ~10fps × 1s of video → at least 5 access units
|
|
||||||
expect(vFrames.length).toBeGreaterThanOrEqual(5);
|
|
||||||
// ~50 opus frames/sec of audio
|
|
||||||
expect(aFrames.length).toBeGreaterThan(10);
|
|
||||||
// opus frames are 20ms (duration 960 @ 48kHz)
|
|
||||||
expect(aFrames[0]).toBe(960);
|
|
||||||
} finally {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
it("parses a hand-built Ogg Opus stream into frames", async () => {
|
|
||||||
// Feed the OGG bytes via the demux's audio path is not directly
|
|
||||||
// exposed — instead verify the parser contract through the NUT path is
|
|
||||||
// covered above; here we sanity-check the byte layout our parser reads.
|
|
||||||
const bytes = buildOggOpusBytes(7);
|
|
||||||
expect(bytes.subarray(0, 4).toString("latin1")).toBe("OggS");
|
|
||||||
// 7 frames + header across pages
|
|
||||||
expect(bytes.includes(Buffer.from("OpusHead"))).toBe(true);
|
|
||||||
expect(bytes.subarray(28, 36).toString("latin1")).toBe("OpusHead");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* goLive port smoke tests — verify the TS layer (no native binding needed
|
|
||||||
* for these; native is covered by the C++/node test-packetizer.js).
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { H264Helpers } from "../src/goLive/AnnexBHelper.js";
|
|
||||||
import { AVCodecID } from "../src/goLive/Demuxer.js";
|
|
||||||
import {
|
|
||||||
BaseMediaStream,
|
|
||||||
CodecPayloadType,
|
|
||||||
Encoders,
|
|
||||||
normalizeVideoCodec,
|
|
||||||
} from "../src/goLive/index.js";
|
|
||||||
import { rewriteSPSVUI } from "../src/goLive/SPSVUIRewriter.js";
|
|
||||||
|
|
||||||
describe("goLive port: codec + encoders", () => {
|
|
||||||
it("normalizeVideoCodec maps aliases to canonical names", () => {
|
|
||||||
expect(normalizeVideoCodec("H.264")).toBe("H264");
|
|
||||||
expect(normalizeVideoCodec("AVC")).toBe("H264");
|
|
||||||
expect(normalizeVideoCodec("h265")).toBe("H265");
|
|
||||||
expect(normalizeVideoCodec("vp8")).toBe("VP8");
|
|
||||||
expect(normalizeVideoCodec("av1")).toBe("AV1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("software encoder exposes x264 libx264 baseline zerolatency", () => {
|
|
||||||
const enc = Encoders.software()();
|
|
||||||
expect(enc.H264.name).toBe("libx264");
|
|
||||||
expect(enc.H264.options).toContain("-preset superfast");
|
|
||||||
expect(enc.H264.options).toContain("-tune zerolatency");
|
|
||||||
// Baseline profile is REQUIRED to match the SDP's profile-level-id=42e01f
|
|
||||||
// (constrained baseline) — High-profile bitstreams fail to decode → black
|
|
||||||
expect(enc.H264.options).toContain("-profile:v baseline");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("CodecPayloadType has opus + H264 entries", () => {
|
|
||||||
expect(CodecPayloadType.opus).toBeDefined();
|
|
||||||
expect(CodecPayloadType.H264).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("goLive port: annexb + sps rewriter", () => {
|
|
||||||
it("H264Helpers detects NAL unit types", () => {
|
|
||||||
const nal = Buffer.from([0x67, 0x42, 0x00, 0x1e]); // SPS
|
|
||||||
expect(H264Helpers.getUnitType(nal)).toBe(7); // SPS type
|
|
||||||
expect(H264Helpers.getUnitType(Buffer.from([0x65, 0x88]))).toBe(5); // IDR
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rewriteSPSVUI returns a buffer for valid SPS", () => {
|
|
||||||
const sps = Buffer.from([
|
|
||||||
0x67, 0x42, 0x00, 0x1e, 0x96, 0x54, 0x05, 0x01, 0xec, 0x80,
|
|
||||||
]);
|
|
||||||
expect(() => rewriteSPSVUI(sps)).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("goLive port: streams", () => {
|
|
||||||
it("BaseMediaStream accepts plain frame objects", () => {
|
|
||||||
// BaseMediaStream is abstract — use a concrete subclass that no-ops the
|
|
||||||
// packetizer hook.
|
|
||||||
class TestStream extends BaseMediaStream {
|
|
||||||
async _sendFrame(_frame: Buffer, _frametime: number): Promise<void> {
|
|
||||||
/* no-op */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const stream = new TestStream("video");
|
|
||||||
const frame = {
|
|
||||||
data: Buffer.from([1, 2, 3]),
|
|
||||||
pts: 0,
|
|
||||||
duration: 40,
|
|
||||||
timeBase: { num: 1, den: 48000 },
|
|
||||||
flags: 0,
|
|
||||||
streamIndex: 0,
|
|
||||||
free: () => {},
|
|
||||||
};
|
|
||||||
expect(() => stream.write(frame)).not.toThrow();
|
|
||||||
stream.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("goLive port: demuxer codec ids", () => {
|
|
||||||
it("maps H264/HEVC/opus AVCodecID values", () => {
|
|
||||||
expect(AVCodecID.AV_CODEC_ID_H264).toBe(27);
|
|
||||||
expect(AVCodecID.AV_CODEC_ID_HEVC).toBe(173);
|
|
||||||
expect(AVCodecID.AV_CODEC_ID_OPUS).toBe(86019);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("goLive port: prepareStream option merge", () => {
|
|
||||||
it("merges default options into the descriptor (no ffmpeg spawn)", () => {
|
|
||||||
// Import the merge logic directly via the module; prepareStream spawns
|
|
||||||
// ffmpeg so we verify the descriptors it would build by checking the
|
|
||||||
// encoder + option functions that prepareStream uses.
|
|
||||||
const enc = Encoders.software()();
|
|
||||||
expect(enc.H264.options).toContain("-forced-idr 1");
|
|
||||||
expect(normalizeVideoCodec("H264")).toBe("H264");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
// Regression test: demux must emit frames from a LIVE stream that never
|
|
||||||
// ends (the NUT/H264 merge output during playback). The old implementation
|
|
||||||
// spooled the whole stream to a file first → deadlocked forever → 0 frames.
|
|
||||||
// v2: also validates ACCESS-UNIT grouping — each emitted frame must be a
|
|
||||||
// complete picture (parameter sets + slice), never a bare SPS/PPS/SEI NAL,
|
|
||||||
// and must be timestamped at the video frame rate (RTP +clockRate/fps).
|
|
||||||
// Run: npx tsx tests/golive-demux-live-e2e.ts [ffmpeg-path]
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { PassThrough } from "node:stream";
|
|
||||||
import { demux } from "../src/goLive/Demuxer.js";
|
|
||||||
|
|
||||||
const FFMPEG = process.argv[2] ?? "ffmpeg";
|
|
||||||
|
|
||||||
// 1) Generate a 2s H264 test clip to a temp file
|
|
||||||
const clip = "/tmp/golive-live-test.h264";
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const p = spawn(
|
|
||||||
FFMPEG,
|
|
||||||
[
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-i",
|
|
||||||
"testsrc=size=640x360:rate=30:duration=2",
|
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-preset",
|
|
||||||
"ultrafast",
|
|
||||||
"-pix_fmt",
|
|
||||||
"yuv420p",
|
|
||||||
"-f",
|
|
||||||
"h264",
|
|
||||||
clip,
|
|
||||||
],
|
|
||||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
|
||||||
);
|
|
||||||
let err = "";
|
|
||||||
p.stderr?.on("data", (d: Buffer) => (err += d.toString()));
|
|
||||||
p.on("close", (code) => (code === 0 ? resolve() : reject(new Error(err))));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) Feed the clip through a PassThrough but DON'T end it (live semantics),
|
|
||||||
// with a small pause after the first chunk so demux has time to emit.
|
|
||||||
const input = new PassThrough();
|
|
||||||
const demuxPromise = demux(input, { format: "h264", frameRate: 30 });
|
|
||||||
const { video, close } = await demuxPromise;
|
|
||||||
if (!video) {
|
|
||||||
console.error("FAIL: demux returned no video stream");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emitted {
|
|
||||||
data: Buffer;
|
|
||||||
duration: number;
|
|
||||||
timeBase: { num: number; den: number };
|
|
||||||
flags: number;
|
|
||||||
}
|
|
||||||
const frames: Emitted[] = [];
|
|
||||||
video.stream.on("data", (frame: Emitted) => {
|
|
||||||
frames.push(frame);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fs = await import("node:fs");
|
|
||||||
const buf = fs.readFileSync(clip);
|
|
||||||
const chunkSize = 16384;
|
|
||||||
for (let i = 0; i < buf.length; i += chunkSize) {
|
|
||||||
input.write(buf.subarray(i, i + chunkSize));
|
|
||||||
if (i === 0) await new Promise((r) => setTimeout(r, 1500));
|
|
||||||
}
|
|
||||||
// Stream still open — if the old spool logic was here we'd never emit.
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
|
||||||
|
|
||||||
// 3) Validate access-unit structure
|
|
||||||
const nalTypes = (frame: Buffer): number[] => {
|
|
||||||
const out: number[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < frame.length - 3) {
|
|
||||||
if (frame[i] === 0 && frame[i + 1] === 0 && frame[i + 2] === 1) {
|
|
||||||
const start = i;
|
|
||||||
let j = i + 3;
|
|
||||||
if (frame[j - 4] === 0 && j >= 4) {
|
|
||||||
// 4-byte start code already consumed by i pointing at the 3-byte tail
|
|
||||||
}
|
|
||||||
while (j < frame.length - 3) {
|
|
||||||
if (frame[j] === 0 && frame[j + 1] === 0 && frame[j + 2] === 1) break;
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
const nal = frame.subarray(start + 3, j);
|
|
||||||
if (nal.length > 0) out.push(nal[0] & 0x1f);
|
|
||||||
i = j;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
let bareParamSetFrames = 0;
|
|
||||||
let framesWithoutSlice = 0;
|
|
||||||
let keyframesWithParamSets = 0;
|
|
||||||
let keyframesWithoutParamSets = 0;
|
|
||||||
for (const f of frames) {
|
|
||||||
const types = nalTypes(f.data);
|
|
||||||
const hasSlice = types.some((t) => t === 1 || t === 5);
|
|
||||||
const hasParams = types.some((t) => t === 7 || t === 8);
|
|
||||||
const isKey = (f.flags & 1) !== 0;
|
|
||||||
if (!hasSlice) framesWithoutSlice++;
|
|
||||||
if (
|
|
||||||
types.length === 1 &&
|
|
||||||
(types[0] === 7 || types[0] === 8 || types[0] === 6)
|
|
||||||
) {
|
|
||||||
bareParamSetFrames++;
|
|
||||||
}
|
|
||||||
if (isKey && hasParams) keyframesWithParamSets++;
|
|
||||||
if (isKey && !hasParams) keyframesWithoutParamSets++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`metadata: ${video.codecName} ${video.width}x${video.height} fps=${video.framerate_num}/${video.framerate_den}`,
|
|
||||||
);
|
|
||||||
console.log(`frames while stream OPEN (not ended): ${frames.length}`);
|
|
||||||
console.log(
|
|
||||||
`frames w/o slice NAL: ${framesWithoutSlice}, bare param-set frames: ${bareParamSetFrames}`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`keyframes with SPS/PPS: ${keyframesWithParamSets}, without: ${keyframesWithoutParamSets}`,
|
|
||||||
);
|
|
||||||
if (frames.length === 0) {
|
|
||||||
console.error("FAIL: no frames emitted while input still open (deadlock)");
|
|
||||||
close();
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (bareParamSetFrames > 0 || framesWithoutSlice > 0) {
|
|
||||||
console.error(
|
|
||||||
"FAIL: demux emitted bare parameter-set frames (must group into access units)",
|
|
||||||
);
|
|
||||||
close();
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (frames.some((f) => f.duration !== 1 || f.timeBase.den !== 30)) {
|
|
||||||
console.error(
|
|
||||||
"FAIL: frame duration/timeBase not 1/30 (RTP timestamp advance wrong)",
|
|
||||||
);
|
|
||||||
close();
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
input.end();
|
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
|
||||||
close();
|
|
||||||
console.log("PASS: live stream demux works + access units grouped correctly");
|
|
||||||
process.exit(0);
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
// 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);
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Screen share input resolution tests
|
|
||||||
//
|
|
||||||
// downloadScreenInput downloads the FULL merged video+audio media to a temp
|
|
||||||
// file first (yt-dlp `-o <tmpdir>/media.%(ext)s`), then returns the file
|
|
||||||
// path. Feeding a FILE path (not a live stdout pipe) to prepareStream is
|
|
||||||
// what makes ffmpeg `-re` pacing reliable — a pipe has unreliable PTS and
|
|
||||||
// caused the ~1fps force-duplication symptom.
|
|
||||||
//
|
|
||||||
// yt-dlp is faked via a PATH shim script so the test does not hit the
|
|
||||||
// network or need real binaries.
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { downloadScreenInput } from "../src/modules/voice-recording/mediaSource.js";
|
|
||||||
|
|
||||||
// ─── fake bin dir ──────────────────────────────────────────────────────────────
|
|
||||||
let fakeBinDir: string | null = null;
|
|
||||||
const realPath = process.env.PATH;
|
|
||||||
|
|
||||||
// Bash shim: find the -o pattern, substitute the extension, write 4096 bytes.
|
|
||||||
// Escaped as a separate string to keep the TS template literal simple.
|
|
||||||
const ytShimBody = `prev=""
|
|
||||||
out=""
|
|
||||||
for a in "$@"; do
|
|
||||||
if [ "$prev" = "-o" ]; then out="$a"; fi
|
|
||||||
prev="$a"
|
|
||||||
done
|
|
||||||
if [ "$GMW_FAKE_YTDLP_FAIL" = "1" ]; then
|
|
||||||
echo "ERROR: [youtube] ...: 403 Forbidden (access denied)" >&2
|
|
||||||
exit 8
|
|
||||||
fi
|
|
||||||
target=$(printf '%s' "$out" | sed 's/%(ext)s/.mp4/')
|
|
||||||
head -c 4096 /dev/urandom > "$target"
|
|
||||||
exit 0
|
|
||||||
`;
|
|
||||||
const ytShim = `#!/usr/bin/env bash\n${ytShimBody}`;
|
|
||||||
|
|
||||||
const ytShimDump = `#!/usr/bin/env bash
|
|
||||||
printf '%s\\n' "$*" >> "$GMW_FAKE_YTDLP_DUMP_ARGS"
|
|
||||||
${ytShimBody}
|
|
||||||
`;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
|
||||||
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
|
|
||||||
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
|
|
||||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (fakeBinDir) {
|
|
||||||
rmSync(fakeBinDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
process.env.PATH = realPath;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── tests ─────────────────────────────────────────────────────────────────────
|
|
||||||
describe("downloadScreenInput", () => {
|
|
||||||
it("downloads media to a temp file and returns its path", async () => {
|
|
||||||
const mediaPath = await downloadScreenInput("https://youtu.be/abc");
|
|
||||||
expect(typeof mediaPath).toBe("string");
|
|
||||||
expect(mediaPath).toMatch(/gmw-ytdlp-/);
|
|
||||||
const size = readFileSync(mediaPath).length;
|
|
||||||
expect(size).toBeGreaterThan(0);
|
|
||||||
// The fake yt-dlp writes 4096 bytes → the file must deliver them.
|
|
||||||
expect(size).toBe(4096);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when yt-dlp fails (transient 403) so the controller retries", async () => {
|
|
||||||
process.env.GMW_FAKE_YTDLP_FAIL = "1";
|
|
||||||
try {
|
|
||||||
await expect(downloadScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
|
||||||
/403|exit 8|failed/i,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
delete process.env.GMW_FAKE_YTDLP_FAIL;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes a file -o pattern (NOT `-o -`) to yt-dlp", async () => {
|
|
||||||
const argsDump = join(
|
|
||||||
tmpdir(),
|
|
||||||
`gmw-ytargs-${process.pid}-${Date.now()}.txt`,
|
|
||||||
);
|
|
||||||
process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump;
|
|
||||||
const realPath2 = process.env.PATH;
|
|
||||||
const dir = fakeBinDir as unknown as string;
|
|
||||||
const existing = join(dir, "yt-dlp");
|
|
||||||
writeFileSync(existing, ytShimDump);
|
|
||||||
chmodSync(existing, 0o755);
|
|
||||||
try {
|
|
||||||
const mediaPath = await downloadScreenInput("https://youtu.be/abc");
|
|
||||||
expect(readFileSync(mediaPath).length).toBeGreaterThan(0);
|
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
|
||||||
const args = readFileSync(argsDump, "utf8").trim();
|
|
||||||
expect(args).not.toContain("-o -");
|
|
||||||
expect(args).toContain("-o ");
|
|
||||||
expect(args).toMatch(/gmw-ytdlp-/);
|
|
||||||
} finally {
|
|
||||||
delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS;
|
|
||||||
rmSync(argsDump, { force: true });
|
|
||||||
process.env.PATH = realPath2;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("copies the on-disk cookies to a temp file instead of handing yt-dlp the original path", async () => {
|
|
||||||
// Regression: recent yt-dlp rewrites `--cookies` file on close. If we hand
|
|
||||||
// it the original system file (root-owned, not writable by the service
|
|
||||||
// user), save-back throws PermissionError → exit 1 → screen share fails.
|
|
||||||
// The copy lives in tmpdir where the service user owns it.
|
|
||||||
const cookieDir = mkdtempSync(join(tmpdir(), "gmw-fake-cookies-"));
|
|
||||||
const cookiePath = join(cookieDir, "ytcookies.txt");
|
|
||||||
writeFileSync(
|
|
||||||
cookiePath,
|
|
||||||
"# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tLOGIN_INFO\tabc123\n",
|
|
||||||
);
|
|
||||||
const argsDump = join(
|
|
||||||
tmpdir(),
|
|
||||||
`gmw-ytargs-${process.pid}-${Date.now()}.txt`,
|
|
||||||
);
|
|
||||||
process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump;
|
|
||||||
process.env.GMW_YT_COOKIES_PATH = cookiePath;
|
|
||||||
const realPath2 = process.env.PATH;
|
|
||||||
const dir = fakeBinDir as unknown as string;
|
|
||||||
const existing = join(dir, "yt-dlp");
|
|
||||||
writeFileSync(existing, ytShimDump);
|
|
||||||
chmodSync(existing, 0o755);
|
|
||||||
try {
|
|
||||||
await downloadScreenInput("https://youtu.be/abc");
|
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
|
||||||
const args = readFileSync(argsDump, "utf8").trim();
|
|
||||||
expect(args).toContain("--cookies");
|
|
||||||
const cookieArg = args
|
|
||||||
.split(/\s+/)
|
|
||||||
.at(args.split(/\s+/).indexOf("--cookies") + 1);
|
|
||||||
expect(cookieArg).toBeDefined();
|
|
||||||
expect(cookieArg).not.toBe(cookiePath); // never the original system file
|
|
||||||
expect(cookieArg).toMatch(/gmw-ytcookies\.\d+\.txt/); // per-process temp copy
|
|
||||||
expect(cookieArg).not.toMatch(/^\/etc\//);
|
|
||||||
} finally {
|
|
||||||
delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS;
|
|
||||||
delete process.env.GMW_YT_COOKIES_PATH;
|
|
||||||
rmSync(argsDump, { force: true });
|
|
||||||
rmSync(cookieDir, { recursive: true, force: true });
|
|
||||||
process.env.PATH = realPath2;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user