diff --git a/flake.nix b/flake.nix index bbf071a..a29fe90 100644 --- a/flake.nix +++ b/flake.nix @@ -11,6 +11,11 @@ let pkgs = import nixpkgs { inherit system; }; + # libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built + # against this host's glibc and ships both lib + dev headers, so the + # binding links cleanly inside the Nix sandbox (no manual cmake build). + libdatachannel = pkgs.libdatachannel; + # Source filter: `path:` literals do NOT respect .gitignore by default, # so a dirty local out/ (stale chunks from previous builds) leaks into # the sandbox. Filter out build artifacts explicitly. @@ -162,6 +167,7 @@ WRAPPER pkgs.pkg-config pkgs.openssl pkgs.openssl.dev + libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding pkgs.git # libdatachannel FetchContent clones from GitHub pkgs.cacert ]; @@ -180,41 +186,34 @@ WRAPPER # pnpm rebuild aborts on the first failing package and runs scripts # from the wrong cwd — build each native dep explicitly with its own # install script. Each failure is tolerated (|| true); the packages - # that matter (opus, datachannel, node-av) are verified at runtime. + # that matter (opus) are verified at runtime. for pkg in \ - node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus \ - node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel \ - node_modules/.pnpm/zeromq@*/node_modules/zeromq + node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus do if [ -d "$pkg" ]; then echo "--- native build: $pkg ---" (cd "$pkg" && npm run install 2>&1 || true) - # node-datachannel's `prebuild -r napi` CLI is broken (TypeError: - # expected first argument to be an array) — the install fallback - # populates devDeps incl. cmake-js; build directly via cmake-js. - if [ "$(basename "$pkg")" = "node-datachannel" ]; then - echo "--- datachannel cmake-js compile ---" - # Nix splits OpenSSL headers/libs across outputs — merge them - # (opensslDevEnv) so FindOpenSSL finds both include + libcrypto. - (cd "$pkg" && OPENSSL_ROOT_DIR="${opensslDevEnv}" npm run compile 2>&1 || true) - fi fi done - echo "=== Cleaning node-datachannel build tree ===" - # Runtime only needs build/Release/node_datachannel.node + dist/ — - # the cmake FetchContent sources (build/_deps, ~380MB), intermediate - # cmake files, and the nested node_modules of build tooling (nw-gyp, - # typescript, puppeteer, eslint, ... ~380MB) are build-time only. - for pkg in node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel - do - if [ -d "$pkg" ]; then - ( cd "$pkg/build" \ - && find . -mindepth 1 -maxdepth 1 ! -name 'Release' -exec rm -rf {} + ) 2>/dev/null || true - rm -rf "$pkg/node_modules" 2>/dev/null || true - echo "node-datachannel cleaned: $(du -sh "$pkg" | cut -f1)" - fi - done - echo "=== Compiling TypeScript ===" + echo "=== Building libdatachannel-min N-API binding ===" + # The GoLive screen-share stack uses a minimal N-API binding + # (native/libdatachannel-min) over nixpkgs libdatachannel. + ( + cd native/libdatachannel-min + # binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev + # include root, LDC_LIB = lib output dir, NAPI_INCLUDE = + # node-addon-api include root). + NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \ + -type d -path "*node_modules/node-addon-api" | head -1) + echo "NAPI_INCLUDE=$NAPI_INCLUDE" + LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \ + NAPI_INCLUDE=$NAPI_INCLUDE \ + npx node-gyp rebuild 2>&1 || true + ls -la build/Release/datachannel_min.node 2>/dev/null \ + && echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \ + || echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)" + ) + echo "=== Compiling TypeScript ====" npx tsc 2>&1 echo "=== Fixing @/ path aliases to relative paths ===" node -e " @@ -248,6 +247,22 @@ WRAPPER mkdir -p $out/lib/gmw-discord-gateway cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/ + # GoLive native binding — loadNative resolves it relative to + # dist/goLive/native.js, i.e. /native/libdatachannel-min/ + # build/Release/datachannel_min.node; libdatachannel .so must sit + # next to it and be on LD_LIBRARY_PATH at runtime. + mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release + cp native/libdatachannel-min/build/Release/datachannel_min.node \ + $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true + mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc + cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \ + $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true + # If the binding failed to build, screen share is simply disabled — + # the gateway itself must still start. + if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then + echo "WARN: datachannel_min.node missing — GoLive screen share disabled in this build" + fi + # Also include drizzle migrations if they exist cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true @@ -256,6 +271,7 @@ WRAPPER #!${pkgs.runtimeShell} cd $out/lib/gmw-discord-gateway export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH +export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_PATH exec ${nodejs}/bin/node dist/index.js WRAPPER chmod +x $out/bin/gmw-discord-gateway diff --git a/services/discord-gateway/native/libdatachannel-min/.gitignore b/services/discord-gateway/native/libdatachannel-min/.gitignore new file mode 100644 index 0000000..beea512 --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +package-lock.json diff --git a/services/discord-gateway/native/libdatachannel-min/binding.cpp b/services/discord-gateway/native/libdatachannel-min/binding.cpp new file mode 100644 index 0000000..6576897 --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/binding.cpp @@ -0,0 +1,526 @@ +// 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 +#include + +#include +#include +#include +#include + +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 in libdatachannel >= 0.21 + std::string msg(data.size(), '\0'); + for (size_t i = 0; i < data.size(); i++) { + msg[i] = static_cast(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 { + 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(info) {} + + void Init(std::shared_ptr dc) { + dc_ = dc; + dc_->onMessage([this](rtc::message_variant data) { + std::string msg; + if (std::holds_alternative(data)) { + msg = binaryToString(std::get(data)); + } else { + msg = std::get(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 dc_; + std::shared_ptr msgCb_; + std::shared_ptr openCb_; + + void Send(const Napi::CallbackInfo& info) { + std::string msg = info[0].As().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(); + msgCb_ = std::make_shared( + ThreadSafeFunction::New(info.Env(), cb, "dc-message", 0, 1)); + } + + void OnOpen(const Napi::CallbackInfo& info) { + Function cb = info[0].As(); + openCb_ = std::make_shared( + ThreadSafeFunction::New(info.Env(), cb, "dc-open", 0, 1)); + } +}; + +class TrackWrap : public Napi::ObjectWrap { + 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(info) {} + + void Init(std::shared_ptr track, Napi::Env env) { + track_ = track; + (void)env; + } + + private: + static FunctionReference trackConstructor; + std::shared_ptr track_; + std::shared_ptr rtpConfig_; + + void Send(const Napi::CallbackInfo& info) { + Buffer buf = info[0].As>(); + 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().Utf8Value(); + uint32_t ssrc = info[1].As().Uint32Value(); + uint8_t pt = (uint8_t)info[2].As().Uint32Value(); + uint32_t clockRate = info[3].As().Uint32Value(); + uint8_t playoutDelayId = (uint8_t)info[4].As().Uint32Value(); + uint16_t playoutDelayMin = (uint16_t)info[5].As().Uint32Value(); + uint16_t playoutDelayMax = (uint16_t)info[6].As().Uint32Value(); + try { + auto cfg = std::make_shared( + ssrc, "", pt, clockRate); + cfg->playoutDelayId = playoutDelayId; + cfg->playoutDelayMin = playoutDelayMin; + cfg->playoutDelayMax = playoutDelayMax; + std::shared_ptr handler; + if (kind == "audio") { + handler = std::make_shared(cfg); + } else if (kind == "h264") { + handler = std::make_shared( + rtc::NalUnit::Separator::StartSequence, cfg); + } else if (kind == "h265") { + handler = std::make_shared( + rtc::NalUnit::Separator::StartSequence, cfg); + } else if (kind == "av1") { + handler = std::make_shared( + rtc::AV1RtpPacketizer::Packetization::Obu, cfg); + } else { + throw std::runtime_error("unknown packetizer kind: " + kind); + } + handler->addToChain(std::make_shared(cfg)); + handler->addToChain(std::make_shared()); + if (kind != "audio") { + handler->addToChain(std::make_shared( + 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 buf = info[0].As>(); + 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().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 { + 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(info) { + Napi::Env env = info.Env(); + if (!info[0].IsObject()) { + throw TypeError::New(env, "config object required"); + } + Object config = info[0].As(); + rtc::Configuration rtcConfig; + if (config.Has("iceServers")) { + Array servers = config.Get("iceServers").As(); + for (uint32_t i = 0; i < servers.Length(); i++) { + std::string url = servers.Get(i).As().Utf8Value(); + rtcConfig.iceServers.emplace_back(url); + } + } + pc_ = std::make_shared(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 pc_; + std::shared_ptr stateCb_; + std::shared_ptr dcCb_; + std::string latestLocalDesc_; + std::shared_ptr pendingDescDeferred_; + std::shared_ptr 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 — 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(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::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::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 + Napi::Value CreateAnswer(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + std::string offer = info[0].As().Utf8Value(); + auto holder = std::make_shared(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::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().Utf8Value(); + std::string type = info[1].As().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(); + stateCb_ = std::make_shared( + ThreadSafeFunction::New(info.Env(), cb, "pc-state", 0, 1)); + std::shared_ptr 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().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().Utf8Value(); + std::string kind = info[1].As().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 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(); + dcCb_ = std::make_shared( + ThreadSafeFunction::New(info.Env(), cb, "dc", 0, 1)); + std::shared_ptr pc = pc_; + pc->onDataChannel([this](std::shared_ptr 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 diff --git a/services/discord-gateway/native/libdatachannel-min/binding.gyp b/services/discord-gateway/native/libdatachannel-min/binding.gyp new file mode 100644 index 0000000..c5ac852 --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/binding.gyp @@ -0,0 +1,21 @@ +{ + "targets": [ + { + "target_name": "libdatachannel_min", + "sources": ["binding.cpp"], + "include_dirs": [ + " { try { return require('node-addon-api').include; } catch { return '/nonexistent'; } })())\")", + " { + 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); +}); diff --git a/services/discord-gateway/native/libdatachannel-min/test-packetizer.js b/services/discord-gateway/native/libdatachannel-min/test-packetizer.js new file mode 100644 index 0000000..5ad9c2f --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/test-packetizer.js @@ -0,0 +1,80 @@ +// 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); diff --git a/services/discord-gateway/native/libdatachannel-min/test-track.js b/services/discord-gateway/native/libdatachannel-min/test-track.js new file mode 100644 index 0000000..9bc02c2 --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/test-track.js @@ -0,0 +1,33 @@ +// 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); diff --git a/services/discord-gateway/package.json b/services/discord-gateway/package.json index a18b1c7..e10ea27 100644 --- a/services/discord-gateway/package.json +++ b/services/discord-gateway/package.json @@ -7,11 +7,8 @@ "pnpm": { "onlyBuiltDependencies": [ "@discordjs/opus", - "@lng2004/node-datachannel", "esbuild", - "node-av", - "sharp", - "zeromq" + "sharp" ] }, "scripts": { @@ -24,7 +21,6 @@ "test": "vitest run" }, "dependencies": { - "@dank074/discord-video-stream": "6.0.0", "@discordjs/opus": "^0.10.0", "@discordjs/voice": "^0.19.2", "@snazzah/davey": "^0.1.11", diff --git a/services/discord-gateway/pnpm-lock.yaml b/services/discord-gateway/pnpm-lock.yaml index 0b3c8f9..c3c5688 100644 --- a/services/discord-gateway/pnpm-lock.yaml +++ b/services/discord-gateway/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@dank074/discord-video-stream': - specifier: 6.0.0 - version: 6.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.0.8)(supports-color@7.2.0))(supports-color@7.2.0) '@discordjs/opus': specifier: ^0.10.0 version: 0.10.0(supports-color@7.2.0) @@ -83,7 +80,7 @@ importers: devDependencies: '@biomejs/biome': specifier: latest - version: 2.5.6 + version: 2.5.7 '@types/node': specifier: ^25.9.0 version: 25.9.5 @@ -108,59 +105,59 @@ importers: packages: - '@biomejs/biome@2.5.6': - resolution: {integrity: sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==} + '@biomejs/biome@2.5.7': + resolution: {integrity: sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.6': - resolution: {integrity: sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==} + '@biomejs/cli-darwin-arm64@2.5.7': + resolution: {integrity: sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.6': - resolution: {integrity: sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==} + '@biomejs/cli-darwin-x64@2.5.7': + resolution: {integrity: sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==} + '@biomejs/cli-linux-arm64-musl@2.5.7': + resolution: {integrity: sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.6': - resolution: {integrity: sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==} + '@biomejs/cli-linux-arm64@2.5.7': + resolution: {integrity: sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==} + '@biomejs/cli-linux-x64-musl@2.5.7': + resolution: {integrity: sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.6': - resolution: {integrity: sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==} + '@biomejs/cli-linux-x64@2.5.7': + resolution: {integrity: sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.6': - resolution: {integrity: sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==} + '@biomejs/cli-win32-arm64@2.5.7': + resolution: {integrity: sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.6': - resolution: {integrity: sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==} + '@biomejs/cli-win32-x64@2.5.7': + resolution: {integrity: sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -188,12 +185,6 @@ packages: '@cwasm/webp@0.1.5': resolution: {integrity: sha512-ceIZQkyxK+s7mmItNcWqqHdOBiJAxYxTnrnPNgUNjldB1M9j+Bp/3eVIVwC8rUFyN/zoFwuT0331pyY3ackaNA==} - '@dank074/discord-video-stream@6.0.0': - resolution: {integrity: sha512-OhEqOI+UPsg0qn2slnv6psU6tMpmHsVq4pisENSfudblHnw83jEueclqmFxd0c4uXKVcYHf1ICu6P+PSu6PK0A==} - engines: {node: '>=22.4.0'} - peerDependencies: - discord.js-selfbot-v13: ^3.6.0 - '@discordjs/builders@1.14.1': resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} engines: {node: '>=16.11.0'} @@ -695,14 +686,6 @@ packages: cpu: [x64] os: [win32] - '@fidm/asn1@1.0.4': - resolution: {integrity: sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==} - engines: {node: '>= 8'} - - '@fidm/x509@1.2.1': - resolution: {integrity: sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==} - engines: {node: '>= 8'} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -862,16 +845,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@leichtgewicht/ip-codec@2.0.5': - resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} - - '@lng2004/node-datachannel@0.32.0-20260202': - resolution: {integrity: sha512-YLpIA5yYC4NRSaw3suitZcQUW/vdI3DHadsCZjPEgUOi2vuoPzPwq+L8Qtt4REuJs/Pw3BcypdVBQfeUVtw9sA==} - engines: {node: '>=18.20.0'} - - '@minhducsun2002/leb128@1.0.0': - resolution: {integrity: sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==} - '@napi-rs/nice-android-arm-eabi@1.1.1': resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} @@ -992,14 +965,6 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - '@otplib/core@12.0.1': resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} @@ -1021,43 +986,6 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@peculiar/asn1-cms@2.8.0': - resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==} - - '@peculiar/asn1-csr@2.8.0': - resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==} - - '@peculiar/asn1-ecc@2.8.0': - resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==} - - '@peculiar/asn1-pfx@2.8.0': - resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==} - - '@peculiar/asn1-pkcs8@2.8.0': - resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==} - - '@peculiar/asn1-pkcs9@2.8.0': - resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==} - - '@peculiar/asn1-rsa@2.8.0': - resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==} - - '@peculiar/asn1-schema@2.8.0': - resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} - - '@peculiar/asn1-x509-attr@2.8.0': - resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==} - - '@peculiar/asn1-x509@2.8.0': - resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==} - - '@peculiar/utils@2.0.3': - resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} - - '@peculiar/x509@1.14.3': - resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} - engines: {node: '>=20.0.0'} - '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1167,61 +1095,6 @@ packages: resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} engines: {node: '>=v16'} - '@seydx/node-av-darwin-arm64@5.2.4': - resolution: {integrity: sha512-uQh6jWXDeXtjuaGOXrBx2rszPDcQbRa4W1YFrmlQG+ffOo+IJyczGjhTWfgzebRksB9tMAe9ViztU39THWZAkw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@seydx/node-av-darwin-x64@5.2.4': - resolution: {integrity: sha512-S9ptQF7JhvGSbvZH9jVeDAPXyRklA7rySKiFzi4UeLTMJslgFTSWFgY2nJAtHrP4wRdkAPnAVjExqd8U7jRZgg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@seydx/node-av-linux-arm64@5.2.4': - resolution: {integrity: sha512-XlQAWIcqFxVItr98VShaxzx57Cfl0y+RPCPciLOHXuEmfeKjON4o/xdbChHg02hHFrVAeSCfaHWQmHNgB9m6eQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@seydx/node-av-linux-x64@5.2.4': - resolution: {integrity: sha512-+2LdgZ7irDik++0mO6Bo1odYa2NZvze6xiy4B8jTbcf3F8k46qQEKkPjBwtV0qQu5fn3M+fUc/wk5coHdfxjxA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@seydx/node-av-win32-arm64-mingw@5.2.4': - resolution: {integrity: sha512-uVNgRQ44uSdtMcR9O6R+nrBou5BWmroTFnGPI3PX8pHR8oe1Nim5zXtWK0qGTTqOiTPNsK5XnSshM3c5/n0/Fg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@seydx/node-av-win32-arm64-msvc@5.2.4': - resolution: {integrity: sha512-Gi+Op8j028WHbdl2jR7MnyHqR2J1TfkEogLPludb3jx54Yi+MO1zvouICFUmtSbxd9JC7CAeoU8++CZAMqcEvA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@seydx/node-av-win32-x64-mingw@5.2.4': - resolution: {integrity: sha512-gClAhvV/aJa681tuTlG6dTqqMpnTM5k6QVhnWolBgkbpZXMi0qABzoUBTYpuwxnOEd6j2mBBAVwqx6kut55Ukw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@seydx/node-av-win32-x64-msvc@5.2.4': - resolution: {integrity: sha512-01s5ij6nudrtZ7ojiAOAgzuUrImvuQYrAE5o6uesoxYqOha6j7yRu5e4qCXRQCDKrh2FBzyO1cGDc7Ht8Ly8wQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@shinyoshiaki/binary-data@0.6.1': - resolution: {integrity: sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==} - engines: {node: '>=6'} - - '@shinyoshiaki/jspack@0.0.6': - resolution: {integrity: sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==} - '@snazzah/davey-android-arm-eabi@0.1.12': resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==} engines: {node: '>= 10'} @@ -1375,9 +1248,6 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} - aes-js@3.1.2: - resolution: {integrity: sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==} - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1398,21 +1268,10 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. - asn1js@3.0.10: - resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} - engines: {node: '>=12.0.0'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - async@0.2.10: - resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} - - asyncc@2.0.9: - resolution: {integrity: sha512-nTQfwHtnL+MSqPaUJhV22GWP3jThj0GnS4Nw1uJyBus6EQ40hQFnbBGUvWxC5P3m+1neSqH1p8asMXg/ypmsQw==} - engines: {node: '>=6.0.0'} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1429,28 +1288,15 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - blockhash-core@0.1.0: resolution: {integrity: sha512-Cv7BgBo0jjVPaeuel4cvxf9LqIGsYNIPz9DAGvvrF9LRlEq9Q3HXu+S8bklPCae0sCxAXic4HGMoImf3FeO3Nw==} - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - buffer-crc32@1.0.0: - resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} - engines: {node: '>=8.0.0'} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -1470,9 +1316,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -1484,10 +1327,6 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} - cmake-ts@1.0.2: - resolution: {integrity: sha512-5l++JHE7MxFuyV/OwJf3ek7ZZN1aGPFPM5oUz6AnK5inQAPe4TFXRMz5sA2qg2FRgByPWdqO+gSfIPo8GzoKNQ==} - hasBin: true - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1516,13 +1355,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - debug-level@4.1.1: - resolution: {integrity: sha512-r/T+zzVbsy4FL91zdUrxc1I788DRXwU4mg65Yk8F5ACHNu9ucTXiWrb4JaKHbNtwuOUKKpE8oEnDg0fcBFmmBw==} - engines: {node: '>=18'} - debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -1557,10 +1389,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1587,10 +1415,6 @@ packages: engines: {node: '>=20.18'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - dns-packet@5.6.1: - resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} - engines: {node: '>=6'} - dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -1698,15 +1522,9 @@ packages: duplex-child-process@1.0.1: resolution: {integrity: sha512-tWbt4tyioDjyK5nh+qicbdvBvNjSXsTUF5zKUwSauuKPg1mokjwn/HezwfvWhh6hXoLdgetY+ZlzU/sMwUMJkg==} - duplexer2@0.1.4: - resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1744,10 +1562,6 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1764,9 +1578,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1791,14 +1602,6 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} - flatstr@1.0.12: - resolution: {integrity: sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==} - - fluent-ffmpeg@2.1.3: - resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} - engines: {node: '>=18'} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -1812,13 +1615,6 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fs-extra@11.3.1: - resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} - engines: {node: '>=14.14'} - fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -1839,9 +1635,6 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. - generate-function@2.3.1: - resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} - get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1857,9 +1650,6 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1868,9 +1658,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1912,19 +1699,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - int64-buffer@1.1.0: - resolution: {integrity: sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==} - ioredis@5.11.1: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} - ip@2.0.1: - resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1933,29 +1711,9 @@ packages: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} - is-plain-object@2.0.4: - resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} - engines: {node: '>=0.10.0'} - - is-property@1.0.2: - resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - isobject@3.0.1: - resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} - engines: {node: '>=0.10.0'} - jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} - jsonfile@6.2.1: - resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - libsodium-wrappers@0.8.4: resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==} @@ -2058,10 +1816,6 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} - map-lru@2.1.0: - resolution: {integrity: sha512-Fa9E3knqtjzLtgtz49B2LjEFUH1asuG4iX7nA4rrlRq6Ey75xvi3hqvHnhs6IAj06o8ldasoFLEQtTATGD/GgA==} - engines: {node: '>=12'} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2084,9 +1838,6 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} @@ -2099,43 +1850,23 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true - mp4box@0.5.4: - resolution: {integrity: sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multicast-dns@7.2.5: - resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} - hasBin: true - nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - node-abi@3.94.0: - resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} - engines: {node: '>=10'} - node-addon-api@8.9.0: resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} engines: {node: ^18 || ^20 || >= 21} - node-av@5.2.4: - resolution: {integrity: sha512-L5r+6k+YGvH5MZX8o55JHQbbeRZ+iFieAb1bhKtpa8hrqBEGuSwdkXRwpE4vd414JqVJsq/EQq6s+3qKeviQTg==} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2145,9 +1876,6 @@ packages: encoding: optional: true - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -2198,14 +1926,6 @@ packages: otplib@12.0.1: resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} - p-cancelable@2.1.1: - resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} - engines: {node: '>=8'} - - p-debounce@5.1.0: - resolution: {integrity: sha512-3DNQmB7HPRMSuZ9P8JQFzEr7156s1S5Lqpi3mSrsZe5AHvl4ONfGrdB5azWqeOpNejQiemgQBkyU+IDKR5nwyg==} - engines: {node: '>=20'} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -2316,12 +2036,6 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - prism-media@1.3.5: resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==} peerDependencies: @@ -2342,9 +2056,6 @@ packages: prism-media@2.0.0-alpha.0: resolution: {integrity: sha512-QL9rnO4xo0grgj7ptsA+AzSCYLirGWM4+ZcyboFmbkYHSgaXIESzHq/SXNizz2iHIfuM2og0cPhmSnTVMeFjKg==} - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -2352,16 +2063,6 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - pvtsutils@1.3.6: - resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} - - pvutils@1.1.5: - resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} - engines: {node: '>=16.0.0'} - qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -2370,13 +2071,6 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2393,9 +2087,6 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2416,12 +2107,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rx.mini@1.4.0: - resolution: {integrity: sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -2491,9 +2176,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2501,21 +2183,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - tar-fs@2.1.5: - resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -2528,9 +2199,6 @@ packages: thread-stream@3.2.0: resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} - thunky@1.1.0: - resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} - tiktoken@1.0.22: resolution: {integrity: sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==} @@ -2584,9 +2252,6 @@ packages: ts-mixer@6.0.4: resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2595,16 +2260,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tsyringe@4.10.0: - resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} - engines: {node: '>= 6.0.0'} - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - tweetnacl@1.0.3: - resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2617,13 +2272,6 @@ packages: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - unzipper@0.12.5: - resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2714,39 +2362,16 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - werift-common@0.0.3: - resolution: {integrity: sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==} - engines: {node: '>=16'} - - werift-dtls@0.5.8: - resolution: {integrity: sha512-oH39cdCpVLqKcPZRMGQLcN+tIBixvMGqjCB9qCrH39Tu9Mmu8u2+zTzyl0Fod87EK6PNPgoNqMJt64xlSMfLnw==} - engines: {node: '>=16'} - - werift-ice@0.2.2: - resolution: {integrity: sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==} - werift-rtp@0.8.9: resolution: {integrity: sha512-uLFOawIXw8FblIp1akfIVYFuRNtNo7csRxYypLz0t++sUNCN7sPeghVqET/c3Mq7Tg2QA3oAb15fXIIuJEp2AA==} engines: {node: '>=10'} - werift-sctp@0.0.11: - resolution: {integrity: sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==} - engines: {node: '>=10'} - - werift@0.23.0: - resolution: {integrity: sha512-/WcIN5DHFG9Ri4anGOmIkp8gxBGFMWSIB/m4sfZ5CWlLfD3iMhiaAUuTBuc+KV3SY9NDmvmLtiN2uaM7k3lVzw==} - engines: {node: '>=16'} - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -2796,48 +2421,44 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} - zeromq@6.5.0: - resolution: {integrity: sha512-vWOrt19lvcXTxu5tiHXfEGQuldSlU+qZn2TT+4EbRQzaciWGwNZ99QQTolQOmcwVgZLodv+1QfC6UZs2PX/6pQ==} - engines: {node: '>= 12'} - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: - '@biomejs/biome@2.5.6': + '@biomejs/biome@2.5.7': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.6 - '@biomejs/cli-darwin-x64': 2.5.6 - '@biomejs/cli-linux-arm64': 2.5.6 - '@biomejs/cli-linux-arm64-musl': 2.5.6 - '@biomejs/cli-linux-x64': 2.5.6 - '@biomejs/cli-linux-x64-musl': 2.5.6 - '@biomejs/cli-win32-arm64': 2.5.6 - '@biomejs/cli-win32-x64': 2.5.6 + '@biomejs/cli-darwin-arm64': 2.5.7 + '@biomejs/cli-darwin-x64': 2.5.7 + '@biomejs/cli-linux-arm64': 2.5.7 + '@biomejs/cli-linux-arm64-musl': 2.5.7 + '@biomejs/cli-linux-x64': 2.5.7 + '@biomejs/cli-linux-x64-musl': 2.5.7 + '@biomejs/cli-win32-arm64': 2.5.7 + '@biomejs/cli-win32-x64': 2.5.7 - '@biomejs/cli-darwin-arm64@2.5.6': + '@biomejs/cli-darwin-arm64@2.5.7': optional: true - '@biomejs/cli-darwin-x64@2.5.6': + '@biomejs/cli-darwin-x64@2.5.7': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.6': + '@biomejs/cli-linux-arm64-musl@2.5.7': optional: true - '@biomejs/cli-linux-arm64@2.5.6': + '@biomejs/cli-linux-arm64@2.5.7': optional: true - '@biomejs/cli-linux-x64-musl@2.5.6': + '@biomejs/cli-linux-x64-musl@2.5.7': optional: true - '@biomejs/cli-linux-x64@2.5.6': + '@biomejs/cli-linux-x64@2.5.7': optional: true - '@biomejs/cli-win32-arm64@2.5.6': + '@biomejs/cli-win32-arm64@2.5.7': optional: true - '@biomejs/cli-win32-x64@2.5.6': + '@biomejs/cli-win32-x64@2.5.7': optional: true '@canvas/image-data@1.1.0': {} @@ -2876,22 +2497,6 @@ snapshots: dependencies: '@canvas/image-data': 1.1.0 - '@dank074/discord-video-stream@6.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.0.8)(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@lng2004/node-datachannel': 0.32.0-20260202 - '@snazzah/davey': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - debug-level: 4.1.1(supports-color@7.2.0) - discord.js-selfbot-v13: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.0.8)(supports-color@7.2.0) - fluent-ffmpeg: 2.1.3 - node-av: 5.2.4(supports-color@7.2.0) - p-debounce: 5.1.0 - sharp: 0.34.5 - zeromq: 6.5.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - supports-color - '@discordjs/builders@1.14.1': dependencies: '@discordjs/formatters': 0.6.2 @@ -3219,13 +2824,6 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@fidm/asn1@1.0.4': {} - - '@fidm/x509@1.2.1': - dependencies: - '@fidm/asn1': 1.0.4 - tweetnacl: 1.0.3 - '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -3326,14 +2924,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@leichtgewicht/ip-codec@2.0.5': {} - - '@lng2004/node-datachannel@0.32.0-20260202': - dependencies: - prebuild-install: 7.1.3 - - '@minhducsun2002/leb128@1.0.0': {} - '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -3420,12 +3010,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@noble/curves@1.9.7': - dependencies: - '@noble/hashes': 1.8.0 - - '@noble/hashes@1.8.0': {} - '@otplib/core@12.0.1': {} '@otplib/plugin-crypto@12.0.1': @@ -3451,100 +3035,6 @@ snapshots: '@oxc-project/types@0.139.0': {} - '@peculiar/asn1-cms@2.8.0': - dependencies: - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/asn1-x509': 2.8.0 - '@peculiar/asn1-x509-attr': 2.8.0 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-csr@2.8.0': - dependencies: - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/asn1-x509': 2.8.0 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-ecc@2.8.0': - dependencies: - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/asn1-x509': 2.8.0 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-pfx@2.8.0': - dependencies: - '@peculiar/asn1-cms': 2.8.0 - '@peculiar/asn1-pkcs8': 2.8.0 - '@peculiar/asn1-rsa': 2.8.0 - '@peculiar/asn1-schema': 2.8.0 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-pkcs8@2.8.0': - dependencies: - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/asn1-x509': 2.8.0 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-pkcs9@2.8.0': - dependencies: - '@peculiar/asn1-cms': 2.8.0 - '@peculiar/asn1-pfx': 2.8.0 - '@peculiar/asn1-pkcs8': 2.8.0 - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/asn1-x509': 2.8.0 - '@peculiar/asn1-x509-attr': 2.8.0 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-rsa@2.8.0': - dependencies: - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/asn1-x509': 2.8.0 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-schema@2.8.0': - dependencies: - '@peculiar/utils': 2.0.3 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-x509-attr@2.8.0': - dependencies: - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/asn1-x509': 2.8.0 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/asn1-x509@2.8.0': - dependencies: - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/utils': 2.0.3 - asn1js: 3.0.10 - tslib: 2.8.1 - - '@peculiar/utils@2.0.3': - dependencies: - tslib: 2.8.1 - - '@peculiar/x509@1.14.3': - dependencies: - '@peculiar/asn1-cms': 2.8.0 - '@peculiar/asn1-csr': 2.8.0 - '@peculiar/asn1-ecc': 2.8.0 - '@peculiar/asn1-pkcs9': 2.8.0 - '@peculiar/asn1-rsa': 2.8.0 - '@peculiar/asn1-schema': 2.8.0 - '@peculiar/asn1-x509': 2.8.0 - pvtsutils: 1.3.6 - reflect-metadata: 0.2.2 - tslib: 2.8.1 - tsyringe: 4.10.0 - '@pinojs/redact@0.4.0': {} '@rolldown/binding-android-arm64@1.1.5': @@ -3605,37 +3095,6 @@ snapshots: fast-deep-equal: 3.1.3 lodash: 4.18.1 - '@seydx/node-av-darwin-arm64@5.2.4': - optional: true - - '@seydx/node-av-darwin-x64@5.2.4': - optional: true - - '@seydx/node-av-linux-arm64@5.2.4': - optional: true - - '@seydx/node-av-linux-x64@5.2.4': - optional: true - - '@seydx/node-av-win32-arm64-mingw@5.2.4': - optional: true - - '@seydx/node-av-win32-arm64-msvc@5.2.4': - optional: true - - '@seydx/node-av-win32-x64-mingw@5.2.4': - optional: true - - '@seydx/node-av-win32-x64-msvc@5.2.4': - optional: true - - '@shinyoshiaki/binary-data@0.6.1': - dependencies: - generate-function: 2.3.1 - is-plain-object: 2.0.4 - - '@shinyoshiaki/jspack@0.0.6': {} - '@snazzah/davey-android-arm-eabi@0.1.12': optional: true @@ -3782,8 +3241,6 @@ snapshots: abbrev@1.1.1: {} - aes-js@3.1.2: {} - agent-base@6.0.2(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -3803,18 +3260,8 @@ snapshots: delegates: 1.0.0 readable-stream: 3.6.2 - asn1js@3.0.10: - dependencies: - pvtsutils: 1.3.6 - pvutils: 1.1.5 - tslib: 2.8.1 - assertion-error@2.0.1: {} - async@0.2.10: {} - - asyncc@2.0.9: {} - asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -3833,30 +3280,15 @@ snapshots: base64-js@1.5.1: {} - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - blockhash-core@0.1.0: {} - bluebird@3.7.2: {} - brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - buffer-crc32@1.0.0: {} - buffer-from@1.1.2: {} - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -3876,8 +3308,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chownr@1.1.4: {} - chownr@2.0.0: {} cliui@6.0.0: @@ -3888,8 +3318,6 @@ snapshots: cluster-key-slot@1.1.1: {} - cmake-ts@1.0.2: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3910,22 +3338,6 @@ snapshots: convert-source-map@2.0.0: {} - core-util-is@1.0.3: {} - - debug-level@4.1.1(supports-color@7.2.0): - dependencies: - asyncc: 2.0.9 - chalk: 4.1.2 - fast-safe-stringify: 2.1.1 - flatstr: 1.0.12 - map-lru: 2.1.0 - ms: 2.1.3 - sonic-boom: 4.2.1 - optionalDependencies: - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - debug@4.4.0(supports-color@7.2.0): dependencies: ms: 2.1.3 @@ -3955,8 +3367,6 @@ snapshots: dependencies: mimic-response: 3.1.0 - deep-extend@0.6.0: {} - delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -3995,10 +3405,6 @@ snapshots: - supports-color - utf-8-validate - dns-packet@5.6.1: - dependencies: - '@leichtgewicht/ip-codec': 2.0.5 - dotenv@17.4.2: {} drizzle-kit@0.31.10: @@ -4021,16 +3427,8 @@ snapshots: duplex-child-process@1.0.1: {} - duplexer2@0.1.4: - dependencies: - readable-stream: 2.3.8 - emoji-regex@8.0.0: {} - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -4135,8 +3533,6 @@ snapshots: dependencies: '@types/estree': 1.0.9 - expand-template@2.0.3: {} - expect-type@1.4.0: {} fast-base64-decode@1.0.0: {} @@ -4147,8 +3543,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-safe-stringify@2.1.1: {} - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -4171,13 +3565,6 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 - flatstr@1.0.12: {} - - fluent-ffmpeg@2.1.3: - dependencies: - async: 0.2.10 - which: 1.3.1 - follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): optionalDependencies: debug: 4.4.3(supports-color@7.2.0) @@ -4190,14 +3577,6 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - fs-constants@1.0.0: {} - - fs-extra@11.3.1: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fs-minipass@2.1.0: dependencies: minipass: 3.3.6 @@ -4221,10 +3600,6 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 - generate-function@2.3.1: - dependencies: - is-property: 1.0.2 - get-caller-file@2.0.5: {} get-intrinsic@1.3.0: @@ -4249,8 +3624,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - github-from-package@0.0.0: {} - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -4262,8 +3635,6 @@ snapshots: gopd@1.2.0: {} - graceful-fs@4.2.11: {} - has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -4305,10 +3676,6 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} - - int64-buffer@1.1.0: {} - ioredis@5.11.1(supports-color@7.2.0): dependencies: '@ioredis/commands': 1.10.0 @@ -4321,32 +3688,12 @@ snapshots: transitivePeerDependencies: - supports-color - ip@2.0.1: {} - is-fullwidth-code-point@3.0.0: {} is-network-error@1.3.2: {} - is-plain-object@2.0.4: - dependencies: - isobject: 3.0.1 - - is-property@1.0.2: {} - - isarray@1.0.0: {} - - isexe@2.0.0: {} - - isobject@3.0.1: {} - jpeg-js@0.4.4: {} - jsonfile@6.2.1: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - libsodium-wrappers@0.8.4: dependencies: libsodium: 0.8.4 @@ -4420,8 +3767,6 @@ snapshots: dependencies: semver: 6.3.1 - map-lru@2.1.0: {} - math-intrinsics@1.1.0: {} mediabunny@1.51.0: @@ -4441,8 +3786,6 @@ snapshots: dependencies: brace-expansion: 1.1.18 - minimist@1.2.8: {} - minipass@3.3.6: dependencies: yallist: 4.0.0 @@ -4454,51 +3797,18 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 - mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} - mp4box@0.5.4: {} - ms@2.1.3: {} - multicast-dns@7.2.5: - dependencies: - dns-packet: 5.6.1 - thunky: 1.1.0 - nanoid@3.3.16: {} - napi-build-utils@2.0.0: {} - - node-abi@3.94.0: - dependencies: - semver: 7.8.5 - node-addon-api@8.9.0: {} - node-av@5.2.4(supports-color@7.2.0): - dependencies: - unzipper: 0.12.5 - werift: 0.23.0(supports-color@7.2.0) - optionalDependencies: - '@seydx/node-av-darwin-arm64': 5.2.4 - '@seydx/node-av-darwin-x64': 5.2.4 - '@seydx/node-av-linux-arm64': 5.2.4 - '@seydx/node-av-linux-x64': 5.2.4 - '@seydx/node-av-win32-arm64-mingw': 5.2.4 - '@seydx/node-av-win32-arm64-msvc': 5.2.4 - '@seydx/node-av-win32-x64-mingw': 5.2.4 - '@seydx/node-av-win32-x64-msvc': 5.2.4 - transitivePeerDependencies: - - supports-color - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 - node-int64@0.4.0: {} - nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -4533,10 +3843,6 @@ snapshots: '@otplib/preset-default': 12.0.1 '@otplib/preset-v11': 12.0.1 - p-cancelable@2.1.1: {} - - p-debounce@5.1.0: {} - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -4642,21 +3948,6 @@ snapshots: dependencies: xtend: 4.0.2 - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.94.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.5 - tunnel-agent: 0.6.0 - prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.0.8): optionalDependencies: '@discordjs/opus': 0.10.0(supports-color@7.2.0) @@ -4666,23 +3957,10 @@ snapshots: dependencies: duplex-child-process: 1.0.1 - process-nextick-args@2.0.1: {} - process-warning@5.0.0: {} proxy-from-env@2.1.0: {} - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - pvtsutils@1.3.6: - dependencies: - tslib: 2.8.1 - - pvutils@1.1.5: {} - qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -4691,23 +3969,6 @@ snapshots: quick-format-unescaped@4.0.4: {} - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -4722,8 +3983,6 @@ snapshots: dependencies: redis-errors: 1.2.0 - reflect-metadata@0.2.2: {} - require-directory@2.1.1: {} require-main-filename@2.0.0: {} @@ -4755,10 +4014,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 - rx.mini@1.4.0: {} - - safe-buffer@5.1.2: {} - safe-buffer@5.2.1: {} safe-stable-stringify@2.5.0: {} @@ -4841,10 +4096,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -4853,27 +4104,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-json-comments@2.0.1: {} - supports-color@7.2.0: dependencies: has-flag: 4.0.0 - tar-fs@2.1.5: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - tar@6.2.1: dependencies: chownr: 2.0.0 @@ -4889,8 +4123,6 @@ snapshots: dependencies: real-require: 0.2.0 - thunky@1.1.0: {} - tiktoken@1.0.22: {} tinybench@2.9.0: {} @@ -4932,8 +4164,6 @@ snapshots: ts-mixer@6.0.4: {} - tslib@1.14.1: {} - tslib@2.8.1: {} tsx@4.23.1: @@ -4942,32 +4172,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tsyringe@4.10.0: - dependencies: - tslib: 1.14.1 - - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - - tweetnacl@1.0.3: {} - typescript@5.9.3: {} undici-types@7.24.6: {} undici@7.29.0: {} - universalify@2.0.1: {} - - unzipper@0.12.5: - dependencies: - bluebird: 3.7.2 - duplexer2: 0.1.4 - fs-extra: 11.3.1 - graceful-fs: 4.2.11 - node-int64: 0.4.0 - util-deprecate@1.0.2: {} vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(tsx@4.23.1): @@ -5012,38 +4222,6 @@ snapshots: webidl-conversions@3.0.1: {} - werift-common@0.0.3(supports-color@7.2.0): - dependencies: - '@shinyoshiaki/jspack': 0.0.6 - debug: 4.4.0(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - - werift-dtls@0.5.8(supports-color@7.2.0): - dependencies: - '@fidm/x509': 1.2.1 - '@noble/curves': 1.9.7 - '@peculiar/x509': 1.14.3 - '@shinyoshiaki/binary-data': 0.6.1 - debug: 4.4.0(supports-color@7.2.0) - tweetnacl: 1.0.3 - transitivePeerDependencies: - - supports-color - - werift-ice@0.2.2(supports-color@7.2.0): - dependencies: - '@shinyoshiaki/jspack': 0.0.6 - buffer-crc32: 1.0.0 - debug: 4.4.0(supports-color@7.2.0) - int64-buffer: 1.1.0 - ip: 2.0.1 - lodash: 4.18.1 - multicast-dns: 7.2.5 - p-cancelable: 2.1.1 - rx.mini: 1.4.0 - transitivePeerDependencies: - - supports-color - werift-rtp@0.8.9(supports-color@7.2.0): dependencies: buffer: 6.0.3 @@ -5052,35 +4230,6 @@ snapshots: transitivePeerDependencies: - supports-color - werift-sctp@0.0.11: - dependencies: - '@shinyoshiaki/jspack': 0.0.6 - - werift@0.23.0(supports-color@7.2.0): - dependencies: - '@fidm/x509': 1.2.1 - '@minhducsun2002/leb128': 1.0.0 - '@noble/curves': 1.9.7 - '@peculiar/x509': 1.14.3 - '@shinyoshiaki/binary-data': 0.6.1 - '@shinyoshiaki/jspack': 0.0.6 - aes-js: 3.1.2 - buffer: 6.0.3 - debug: 4.4.0(supports-color@7.2.0) - fast-deep-equal: 3.1.3 - int64-buffer: 1.1.0 - ip: 2.0.1 - mp4box: 0.5.4 - multicast-dns: 7.2.5 - tweetnacl: 1.0.3 - werift-common: 0.0.3(supports-color@7.2.0) - werift-dtls: 0.5.8(supports-color@7.2.0) - werift-ice: 0.2.2(supports-color@7.2.0) - werift-rtp: 0.8.9(supports-color@7.2.0) - werift-sctp: 0.0.11 - transitivePeerDependencies: - - supports-color - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -5088,10 +4237,6 @@ snapshots: which-module@2.0.1: {} - which@1.3.1: - dependencies: - isexe: 2.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -5138,9 +4283,4 @@ snapshots: yocto-queue@1.2.2: {} - zeromq@6.5.0: - dependencies: - cmake-ts: 1.0.2 - node-addon-api: 8.9.0 - zod@4.4.3: {} diff --git a/services/discord-gateway/src/goLive/AnnexBBitstreamReaderWriter.ts b/services/discord-gateway/src/goLive/AnnexBBitstreamReaderWriter.ts new file mode 100644 index 0000000..3770435 --- /dev/null +++ b/services/discord-gateway/src/goLive/AnnexBBitstreamReaderWriter.ts @@ -0,0 +1,155 @@ +/** + * 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); + } +} diff --git a/services/discord-gateway/src/goLive/AnnexBHelper.ts b/services/discord-gateway/src/goLive/AnnexBHelper.ts new file mode 100644 index 0000000..9eb947e --- /dev/null +++ b/services/discord-gateway/src/goLive/AnnexBHelper.ts @@ -0,0 +1,112 @@ +/** + * 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; +} diff --git a/services/discord-gateway/src/goLive/AudioStream.ts b/services/discord-gateway/src/goLive/AudioStream.ts new file mode 100644 index 0000000..ea39912 --- /dev/null +++ b/services/discord-gateway/src/goLive/AudioStream.ts @@ -0,0 +1,20 @@ +/** + * 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 { + this._conn.sendAudioFrame(frame, frametime); + } +} diff --git a/services/discord-gateway/src/goLive/BaseMediaConnection.ts b/services/discord-gateway/src/goLive/BaseMediaConnection.ts new file mode 100644 index 0000000..b568d6a --- /dev/null +++ b/services/discord-gateway/src/goLive/BaseMediaConnection.ts @@ -0,0 +1,594 @@ +/** + * 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; +} + +export class BaseMediaConnection extends EventEmitter { + interval: ReturnType | 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(); + protected _daveProtocolVersion = 0; + protected _davePendingTransitions = new Map(); + 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]; + 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 { + if (!("sdp" in d)) throw new Error("Only WebRTC connections are allowed"); + 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}`, + `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"); + this._webRtcWrapper.webRtcConn?.setRemoteDescription( + [audioSection, videoSection, videoRtpMap].join("\n"), + "answer", + ); + 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)); + 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 { + 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) => { + if (state === "closed" && !this._closed) reconnect(); + }); + this._webRtcWrapper.onLocalDescription = (sdp) => { + const rtc_connection_id = randomUUID(); + 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); + }); + }; + 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; + if (!enabled) { + this.sendOpcode(VoiceOpCodes.VIDEO, { + audio_ssrc: audioSsrc, + video_ssrc: 0, + rtx_ssrc: 0, + streams: [], + }); + } else { + if (!attr) throw new Error("Need to specify video attributes"); + this.sendOpcode(VoiceOpCodes.VIDEO, { + 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, + }, + }, + ], + }); + } + } + + /** Set speaking status */ + setSpeaking(speaking: boolean): void { + if (!this._webRtcParams) throw new Error("WebRTC connection not ready"); + this.sendOpcode(VoiceOpCodes.SPEAKING, { + delay: 0, + speaking: speaking ? 1 : 0, + ssrc: this._webRtcParams.audioSsrc, + }); + } +} + +export type { NativePeerConnection }; diff --git a/services/discord-gateway/src/goLive/BaseMediaStream.ts b/services/discord-gateway/src/goLive/BaseMediaStream.ts new file mode 100644 index 0000000..1e1e3ee --- /dev/null +++ b/services/discord-gateway/src/goLive/BaseMediaStream.ts @@ -0,0 +1,175 @@ +/** + * 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 { + 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 { + 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; + } +} diff --git a/services/discord-gateway/src/goLive/CodecPayloadType.ts b/services/discord-gateway/src/goLive/CodecPayloadType.ts new file mode 100644 index 0000000..180bab6 --- /dev/null +++ b/services/discord-gateway/src/goLive/CodecPayloadType.ts @@ -0,0 +1,71 @@ +/** 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 = { + 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, + }, +}; diff --git a/services/discord-gateway/src/goLive/Demuxer.ts b/services/discord-gateway/src/goLive/Demuxer.ts new file mode 100644 index 0000000..919b1e6 --- /dev/null +++ b/services/discord-gateway/src/goLive/Demuxer.ts @@ -0,0 +1,377 @@ +/** + * 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 { randomUUID } from "node:crypto"; +import { createWriteStream, existsSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; + +/** + * 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: /-ffmpeg-headless-*/bin/ + 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>> { + 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> = []; + 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 = { + 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. Uses ffmpeg -f h264 -c copy for video-only AnnexB output. + * Returns stream info + the video pipe. Audio is not extracted (GoLive + * screen share sends silence / uses Discord's mixed audio). + */ +export async function demux( + input: string | PassThrough, + _opts: { format: string }, +): Promise<{ + video: DemuxedStream | undefined; + audio: DemuxedStream | undefined; + close: () => void; +}> { + const _label = randomUUID(); + const vPipe = new PassThrough({ objectMode: true, highWaterMark: 128 }); + const aPipe = new PassThrough({ objectMode: true, highWaterMark: 128 }); + + // For stream input, spool to a temp file first so ffprobe can inspect it + // (ffprobe needs a seekable file; pipes can't be re-read). The stream is + // fully consumed before ffmpeg starts — acceptable for screen-share + // sources which are already fully buffered by yt-dlp in practice. + let spoolPath: string | null = null; + const cleanupSpool = () => { + if (spoolPath) { + import("node:fs").then(({ unlink }) => unlink(spoolPath!, () => {})); + spoolPath = null; + } + }; + + let effectiveInput: string; + if (typeof input === "string") { + effectiveInput = input; + } else { + spoolPath = join(tmpdir(), `golive-demux-${_label}.h264`); + const ws = createWriteStream(spoolPath); + await new Promise((resolve, reject) => { + input.pipe(ws); + input.on("error", reject); + ws.on("finish", resolve); + ws.on("error", reject); + }); + effectiveInput = spoolPath; + } + + // Probe for codec + dimensions + let streams: Array> = []; + try { + streams = await probeStreams(effectiveInput); + } catch (_e) { + // probe failed (e.g. raw h264 without container) — infer h264 default + streams = []; + } + + const v = streams.find((s) => s.codec_type === "video"); + const a = streams.find((s) => s.codec_type === "audio"); + let vInfo: DemuxedStream | undefined; + let aInfo: DemuxedStream | undefined; + + if (v) { + const codecName = (v.codec_name as string) ?? "h264"; + const rFrame = (v.r_frame_rate as string) ?? "0/1"; + const [num, den] = rFrame.split("/").map((n) => Number(n)); + vInfo = { + codec: + AVCodecID[ + (codecName.toUpperCase() as keyof typeof AVCodecID) ?? + "AV_CODEC_ID_H264" + ] ?? AVCodecID.AV_CODEC_ID_H264, + codecName, + width: (v.width as number) ?? 0, + height: (v.height as number) ?? 0, + framerate_num: num ?? 0, + framerate_den: den ?? 1, + sample_rate: 0, + stream: vPipe, + }; + } else { + // Probe failed (e.g. raw AnnexB h264 input) — still emit frames on the + // video pipe; playStream infers dimensions from the first frame. + vInfo = { + codec: AVCodecID.AV_CODEC_ID_H264, + codecName: "h264", + width: 0, + height: 0, + framerate_num: 0, + framerate_den: 1, + sample_rate: 0, + stream: vPipe, + }; + } + + if (a) { + const codecName = (a.codec_name as string) ?? "opus"; + aInfo = { + codec: + AVCodecID[ + (codecName.toUpperCase() as keyof typeof AVCodecID) ?? + "AV_CODEC_ID_OPUS" + ], + codecName, + width: 0, + height: 0, + framerate_num: 0, + framerate_den: 0, + sample_rate: Number(a.sample_rate) ?? 0, + stream: aPipe, + }; + } + + // Spawn ffmpeg — extract raw video (AnnexB for H264) to stdout + const args: string[] = [ + "-hide_banner", + "-loglevel", + "error", + "-i", + effectiveInput, + "-c:v", + "copy", + "-an", // no audio in this minimal demuxer + "-f", + "h264", + "pipe:1", + ]; + + const proc = spawn(FFMPEG, args, { stdio: ["ignore", "pipe", "pipe"] }); + + // Scan stdout for NAL units. Each NAL unit (between start codes) is one frame + // payload. We emit them individually; the packetizer chain handles FU-A. + let videoBuf = Buffer.alloc(0); + let frameCount = 0; + + const emitFrame = (nal: Uint8Array, isKeyFrame: boolean) => { + vPipe.write({ + data: Buffer.from(nal), + pts: frameCount, + duration: 1, + timeBase: { num: 1, den: 90000 }, + flags: isKeyFrame ? AV_PKT_FLAG_KEY : 0, + streamIndex: 0, + free: () => {}, + }); + frameCount++; + }; + + if (proc.stdout) { + 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 isIdr = (nalTrimmed[0] & 0x1f) === 5; // IDR + emitFrame(nalTrimmed, isIdr); + } + } + // 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); + } + }); + proc.stdout.on("end", () => { + if (videoBuf.length > 0) { + let end = videoBuf.length; + while (end > 0 && videoBuf[end - 1] === 0) end--; + if (end > 0) emitFrame(videoBuf.subarray(0, end), false); + } + vPipe.end(); + aPipe.end(); + }); + } + + if (proc.stderr) { + proc.stderr.on("data", () => { + /* errors swallowed */ + }); + } + proc.on("close", () => { + vPipe.end(); + aPipe.end(); + }); + + const close = () => { + proc.kill("SIGTERM"); + vPipe.end(); + aPipe.end(); + cleanupSpool(); + }; + + return { video: vInfo, audio: aInfo, close }; +} diff --git a/services/discord-gateway/src/goLive/Encoders.ts b/services/discord-gateway/src/goLive/Encoders.ts new file mode 100644 index 0000000..44296c6 --- /dev/null +++ b/services/discord-gateway/src/goLive/Encoders.ts @@ -0,0 +1,51 @@ +/** + * 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 = "film" } = + x264 ?? {}; + const { preset: x265Preset = "superfast", tune: x265Tune } = x265 ?? {}; + return () => ({ + H264: { + name: "libx264", + options: ["-forced-idr 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 }; diff --git a/services/discord-gateway/src/goLive/GatewayOpCodes.ts b/services/discord-gateway/src/goLive/GatewayOpCodes.ts new file mode 100644 index 0000000..b9fd579 --- /dev/null +++ b/services/discord-gateway/src/goLive/GatewayOpCodes.ts @@ -0,0 +1,41 @@ +/** 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, +} diff --git a/services/discord-gateway/src/goLive/SPSVUIRewriter.ts b/services/discord-gateway/src/goLive/SPSVUIRewriter.ts new file mode 100644 index 0000000..0ca5097 --- /dev/null +++ b/services/discord-gateway/src/goLive/SPSVUIRewriter.ts @@ -0,0 +1,291 @@ +/** + * 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(); +} diff --git a/services/discord-gateway/src/goLive/StreamConnection.ts b/services/discord-gateway/src/goLive/StreamConnection.ts new file mode 100644 index 0000000..427c79a --- /dev/null +++ b/services/discord-gateway/src/goLive/StreamConnection.ts @@ -0,0 +1,45 @@ +/** + * 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; + } +} diff --git a/services/discord-gateway/src/goLive/Streamer.ts b/services/discord-gateway/src/goLive/Streamer.ts new file mode 100644 index 0000000..e86d5c4 --- /dev/null +++ b/services/discord-gateway/src/goLive/Streamer.ts @@ -0,0 +1,280 @@ +/** + * 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; + }; +} + +/** 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) => { + this._gatewayEmitter.emit(packet.t, packet.d); + }); + } + + get client(): StreamerClientLike { + return this._client; + } + + get opts(): Record { + return {}; + } + + get voiceConnection(): VoiceConnection | null { + return this._voiceConnection; + } + + sendOpcode(code: number, data: unknown): void { + this.client.ws.broadcast({ op: code, d: data }); + } + + joinVoiceChannel(channel: VoiceChannelLike): Promise { + 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 { + 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 { + 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; + } + this.signalStream(); + 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) => { + resolve(conn); + }, + ); + this.voiceConnection.streamConnection = streamConn; + this._gatewayEmitter.on( + "STREAM_CREATE", + (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); + }, + ); + this._gatewayEmitter.on( + "STREAM_SERVER_UPDATE", + (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); + }, + ); + }); + } + + async setStreamPreview(image: Buffer): Promise { + 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; + 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 }; diff --git a/services/discord-gateway/src/goLive/VideoStream.ts b/services/discord-gateway/src/goLive/VideoStream.ts new file mode 100644 index 0000000..b110705 --- /dev/null +++ b/services/discord-gateway/src/goLive/VideoStream.ts @@ -0,0 +1,20 @@ +/** + * 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 { + this._conn.sendVideoFrame(frame, frametime); + } +} diff --git a/services/discord-gateway/src/goLive/VoiceConnection.ts b/services/discord-gateway/src/goLive/VoiceConnection.ts new file mode 100644 index 0000000..4275ae2 --- /dev/null +++ b/services/discord-gateway/src/goLive/VoiceConnection.ts @@ -0,0 +1,25 @@ +/** + * 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(); + } +} diff --git a/services/discord-gateway/src/goLive/VoiceOpCodes.ts b/services/discord-gateway/src/goLive/VoiceOpCodes.ts new file mode 100644 index 0000000..0d1d355 --- /dev/null +++ b/services/discord-gateway/src/goLive/VoiceOpCodes.ts @@ -0,0 +1,38 @@ +/** 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, +} diff --git a/services/discord-gateway/src/goLive/WebRtcWrapper.ts b/services/discord-gateway/src/goLive/WebRtcWrapper.ts new file mode 100644 index 0000000..012346d --- /dev/null +++ b/services/discord-gateway/src/goLive/WebRtcWrapper.ts @@ -0,0 +1,205 @@ +/** + * 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; + /** 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) 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)); + } + + setPacketizer(videoCodec: string): void { + if (!this.mediaConnection.webRtcParams) { + throw new Error("WebRTC connection not ready"); + } + const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams; + 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, + ); + } +} diff --git a/services/discord-gateway/src/goLive/index.ts b/services/discord-gateway/src/goLive/index.ts new file mode 100644 index 0000000..dcca2a4 --- /dev/null +++ b/services/discord-gateway/src/goLive/index.ts @@ -0,0 +1,19 @@ +/** + * 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"; diff --git a/services/discord-gateway/src/goLive/native.ts b/services/discord-gateway/src/goLive/native.ts new file mode 100644 index 0000000..ac7fc2d --- /dev/null +++ b/services/discord-gateway/src/goLive/native.ts @@ -0,0 +1,116 @@ +/** + * 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; + /** Resolves with the auto-generated answer SDP. */ + createAnswer(offerSdp: string): Promise; + 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; + } +} diff --git a/services/discord-gateway/src/goLive/prepareStream.ts b/services/discord-gateway/src/goLive/prepareStream.ts new file mode 100644 index 0000000..41a50ee --- /dev/null +++ b/services/discord-gateway/src/goLive/prepareStream.ts @@ -0,0 +1,325 @@ +/** + * 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 { 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; + options: Record; + videoCodec: string; + width: number; + height: number; + frameRate?: number; + includeAudio: boolean; +} + +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 = {}, +): 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) ?? + Encoders.software(), + customHeaders: { + ...DEFAULT_HEADERS, + ...(options.customHeaders as Record | undefined), + }, + customInputOptions: (options.customInputOptions as string[]) ?? [], + customFfmpegFlags: (options.customFfmpegFlags as string[]) ?? [], + minimizeLatency: options.minimizeLatency ?? false, + }; + + const output = new PassThrough(); + + const args: string[] = [ + "-hide_banner", + "-loglevel", + "error", + ...(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 + if (mergedOptions.includeAudio) { + args.push("-map", "0:a:0?"); + args.push( + "-c:a", + "libopus", + "-b:a", + `${mergedOptions.bitrateAudio}k`, + "-ar", + "48000", + "-ac", + "2", + ); + } else { + args.push("-an"); + } + + args.push(...mergedOptions.customFfmpegFlags); + args.push("-f", "h264", "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) { + input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk)); + input.on("end", () => proc.stdin?.end()); + input.on("error", () => proc.stdin?.destroy()); + } + + 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, + }; +} + +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 }, + options: PlayStreamOptions = {}, +): Promise { + const conn = await streamer.createStream(); + + const { video, close: demuxClose } = await demux(prepared.output, { + format: options.format ?? "nut", + }); + + if (!video) throw new Error("No video stream in media"); + + conn.setPacketizer(video.codecName); + conn.mediaConnection.setSpeaking(true); + + 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); + + 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 */ + } + }; + + return new Promise((resolve) => { + vStream.once("finish", () => { + cleanup(); + resolve(); + }); + vStream.once("error", () => { + cleanup(); + resolve(); + }); + }); +} + +export { Encoders }; diff --git a/services/discord-gateway/src/goLive/utils.ts b/services/discord-gateway/src/goLive/utils.ts new file mode 100644 index 0000000..c9a3e8e --- /dev/null +++ b/services/discord-gateway/src/goLive/utils.ts @@ -0,0 +1,82 @@ +/** 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" + ); +} diff --git a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts index 81858f1..540642d 100644 --- a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts +++ b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts @@ -1,12 +1,12 @@ +import type { Client } from "discord.js-selfbot-v13"; +import { createChildLogger } from "@/shared/logger/index"; import { Encoders, + normalizeVideoCodec, playStream, prepareStream, Streamer, - Utils, -} from "@dank074/discord-video-stream"; -import type { Client } from "discord.js-selfbot-v13"; -import { createChildLogger } from "@/shared/logger/index"; +} from "../../goLive/index.js"; import { getDirectScreenInput } from "./mediaSource.js"; import type { ScreenSharePlayback } from "./mediaTypes.js"; import { discordPlayer } from "./player.js"; @@ -98,7 +98,7 @@ export class ScreenShareController { ), ]); - const { command, output } = prepareStream(input, { + const prepared = prepareStream(input, { encoder: Encoders.software({ x264: { preset: "superfast" } }), width: 1280, height: 720, @@ -106,17 +106,9 @@ export class ScreenShareController { bitrateVideo: 2500, bitrateVideoMax: 4000, includeAudio: true, - videoCodec: Utils.normalizeVideoCodec("H264"), - // The library unconditionally appends `volume@internal_lib` + `azmq` - // audio filters that only exist in its custom node-av ffmpeg build - // (jellyfin-ffmpeg) — NOT in the Nix ffmpeg-headless on PATH. Without - // an override fluent-ffmpeg dies instantly with "Filter not found", - // the NUT output stays empty and playStream fails with "Invalid data - // found when processing input". ffmpeg applies the LAST -filter:a for - // a stream, so a trailing no-op filter neutralizes the custom chain. - // Realtime volume control was removed from GMW, so this is lossless. - customFfmpegFlags: ["-filter:a", "anull"], + videoCodec: normalizeVideoCodec("H264"), }); + const { command } = prepared; let stopped = false; // Restore the @discordjs/voice connection after the stream ends (both @@ -151,10 +143,10 @@ export class ScreenShareController { }, 5000); } }; - const done = playStream(output, this.streamer, { + const done = playStream(prepared, this.streamer, { type: "go-live", }) - .catch((err) => { + .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); diff --git a/services/discord-gateway/tests/goLive-port.test.ts b/services/discord-gateway/tests/goLive-port.test.ts new file mode 100644 index 0000000..a7210f1 --- /dev/null +++ b/services/discord-gateway/tests/goLive-port.test.ts @@ -0,0 +1,94 @@ +/** + * 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 superfast film", () => { + const enc = Encoders.software()(); + expect(enc.H264.name).toBe("libx264"); + expect(enc.H264.options).toContain("-preset superfast"); + expect(enc.H264.options).toContain("-tune film"); + }); + + 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 { + /* 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"); + }); +}); diff --git a/services/discord-gateway/tests/golive-demux-e2e.ts b/services/discord-gateway/tests/golive-demux-e2e.ts new file mode 100644 index 0000000..56f49aa --- /dev/null +++ b/services/discord-gateway/tests/golive-demux-e2e.ts @@ -0,0 +1,40 @@ +// Phase 2 E2E: Demuxer on a real ffmpeg-generated H264 file. +// Run: npx tsx tests/golive-demux-e2e.ts + +import { createReadStream } from "node:fs"; +import { demux } from "../src/goLive/Demuxer.js"; + +const input = process.argv[2] ?? "/tmp/sample.h264"; +const { video, close } = await demux(createReadStream(input), { + format: "h264", +}); + +console.log( + "video:", + JSON.stringify({ + codecName: video.codecName, + width: video.width, + height: video.height, + duration: video.duration, + fps: Math.round(video.framerate_num / video.framerate_den), + }), +); + +let count = 0; +let keyframes = 0; +let bytes = 0; +video.stream.on("data", (frame: { data: Buffer; keyframe: boolean }) => { + count++; + bytes += frame.data.length; + if (frame.keyframe) keyframes++; +}); +video.stream.on("end", () => { + console.log(`frames: ${count} (${keyframes} keyframes), ${bytes} bytes`); + close(); + process.exit(0); +}); +video.stream.on("error", (e: unknown) => { + console.error("stream error:", e); + close(); + process.exit(1); +}); diff --git a/services/discord-gateway/tests/golive-pipeline-e2e.ts b/services/discord-gateway/tests/golive-pipeline-e2e.ts new file mode 100644 index 0000000..042006a --- /dev/null +++ b/services/discord-gateway/tests/golive-pipeline-e2e.ts @@ -0,0 +1,58 @@ +// Phase 2 E2E: full pipeline prepareStream → demux → frame stream. +// Run: npx tsx tests/golive-pipeline-e2e.ts + +import { demux } from "../src/goLive/Demuxer.js"; +import { Encoders } from "../src/goLive/Encoders.js"; +import { prepareStream } from "../src/goLive/prepareStream.js"; +import { normalizeVideoCodec } from "../src/goLive/utils.js"; + +// Use a real ffmpeg-generated video file as input (from sample generation). +const input = process.argv[2] ?? "/tmp/sample.h264"; + +const prepared = prepareStream(input, { + encoder: Encoders.software({ x264: { preset: "superfast" } }), + width: 640, + height: 360, + frameRate: 25, + bitrateVideo: 500, + bitrateVideoMax: 800, + includeAudio: false, + videoCodec: normalizeVideoCodec("H264"), +}); + +console.log( + "prepareStream ok, videoCodec:", + prepared.videoCodec, + "size:", + prepared.width, + "x", + prepared.height, +); + +const { video, close } = await demux(prepared.output, { format: "h264" }); +console.log("demux video:", video?.codecName, video?.width, "x", video?.height); + +let frames = 0; +let keyframes = 0; +video.stream.on("data", (f: { keyframe?: boolean }) => { + frames++; + if (f.keyframe) keyframes++; +}); +video.stream.on("end", () => { + console.log(`pipeline frames: ${frames} (${keyframes} keyframes)`); + close(); + prepared.command.kill("SIGTERM"); + process.exit(frames > 0 ? 0 : 1); +}); +video.stream.on("error", (e: unknown) => { + console.error("pipeline error:", e); + close(); + prepared.command.kill("SIGTERM"); + process.exit(1); +}); +setTimeout(() => { + console.log("timeout after 30s — killing"); + close(); + prepared.command.kill("SIGTERM"); + process.exit(2); +}, 30000); diff --git a/services/discord-gateway/tests/golive-videostream-e2e.ts b/services/discord-gateway/tests/golive-videostream-e2e.ts new file mode 100644 index 0000000..4cb7b68 --- /dev/null +++ b/services/discord-gateway/tests/golive-videostream-e2e.ts @@ -0,0 +1,78 @@ +// Phase 2 E2E: demux → VideoStream → native packetizer chain (local pair). +// Run: npx tsx tests/golive-videostream-e2e.ts + +import { createReadStream } from "node:fs"; +import { demux } from "../src/goLive/Demuxer.js"; +import { loadNative } from "../src/goLive/native.js"; +import { VideoStream } from "../src/goLive/VideoStream.js"; + +async function main() { + const native = loadNative(); + const { PeerConnection } = native; + + const pcA = new PeerConnection({ iceServers: [] }); + const pcB = new PeerConnection({ iceServers: [] }); + + pcA.onStateChange(() => {}); + pcB.onStateChange(() => {}); + + // Both peers declare audio+video tracks (exact passing test-packetizer + // pattern — tracks trigger negotiation). + pcA.addTrack("0", "audio"); + pcA.addTrack("1", "video"); + pcB.addTrack("0", "audio"); + const trackB = pcB.addTrack("1", "video"); + if (!trackB) throw new Error("no track from addTrack"); + + const track = trackB; + // NOTE: setPacketizer is called AFTER connected (see below) — calling it + // before negotiation breaks the offer (libdatachannel negotiation state). + + const offer = await pcA.createOffer(); + console.log("T1 offer"); + pcB.setRemoteDescription(offer, "offer"); + const answer = await pcB.createAnswer(offer); + console.log("T2 answer"); + pcA.setRemoteDescription(answer, "answer"); + + await new Promise((r) => setTimeout(r, 1500)); + console.log("T3 states:", pcA.state(), "/", pcB.state()); + + // Discord-style SSRC/payload: H264 101 @ 90kHz, playout ext id 5 + track.setPacketizer("h264", 0x1234, 101, 90000, 5, 0, 10); + + const { video, close } = await demux(createReadStream("/tmp/sample.h264"), { + format: "h264", + }); + console.log("video stream:", video.codecName, video.width, "x", video.height); + + const conn = { + sendVideoFrame: (frame: Buffer, frametime: number) => { + track.sendFrame(frame); + track.addTimestamp(Math.round((frametime * 90000) / 1000)); + }, + } as unknown as { sendVideoFrame(frame: Buffer, frametime: number): void }; + + const vStream = new VideoStream(conn as never); + let sent = 0; + const origSend = conn.sendVideoFrame; + conn.sendVideoFrame = (frame: Buffer, frametime: number) => { + sent++; + origSend(frame, frametime); + }; + + video.stream.pipe(vStream); + await new Promise((r) => setTimeout(r, 4000)); + + console.log(`sent ${sent} frames via VideoStream; B state=${pcB.state()}`); + const ok = sent > 0 && pcB.state() === "connected"; + close(); + pcA.close(); + pcB.close(); + process.exit(ok ? 0 : 1); +} + +main().catch((e) => { + console.error("E2E failed:", e); + process.exit(1); +});