5 Commits

Author SHA1 Message Date
javis-bot
2fd5e0fe9e chore: lengthen humanised selfbot startup delays
Join/go-live still felt a touch fast. Widen the pauses: ~2.5-4.5s after
coming online before joining voice, ~6-10s after joining before Go Live.
2026-06-10 11:47:31 +09:00
javis-bot
2c7f0a95b5 fix: make humanised selfbot startup abort- and concurrency-safe
The human-pause delays leave start() in-flight for several seconds, which
exposed two races:
- stop() during a pause only ended the pause; start() continued and called
  joinVoice on the streamer stop() had already nulled (null deref).
- `active` was set only just before go-live, so a second /stream during the
  delay passed the guard and both calls raced on the same overwritten streamer.

Now start() locks `active` before any await, keeps controller/streamer/capture
as local refs, and calls signal.throwIfAborted() after each await so an
interleaved stop() unwinds into a catch that tears down via the local refs and
clears instance state only if it still points at this attempt. isActive() now
reflects "starting" during the delay too.

Verified live: concurrent start is rejected ("이미 송출 중입니다"), stop() mid-
startup returns a cancel message with isActive=false and no uncaught error, and
the happy path still goes live and tears down cleanly. tsc --noEmit passes.
2026-06-10 11:42:57 +09:00
javis-bot
b6cf05f6cf feat: humanise selfbot voice-join and go-live pacing
Joining voice and starting the broadcast instantly looks like a bot. Add
randomised, human-plausible pauses (~0.9-2.2s after coming online before
joining the channel, ~2.5-5s after joining before hitting Go Live) so the
cadence isn't machine-instant or fingerprintable. The pause resolves
immediately on stop() so teardown never hangs mid-wait.

Verified live: end-to-end join -> settle -> Go Live took ~8s before the
stream went live, held for 15s, and tore down cleanly. tsc --noEmit passes.
2026-06-10 11:37:39 +09:00
javis-bot
40fd7dbb59 fix: single-pass NVENC encode for selfbot stream (no double encode)
Address review: the capture ffmpeg had no -b:v, so it encoded at nvenc's
low default (~2.47 Mbps) and the library then re-encoded to 8 Mbps, which
only upscaled already-lost detail. The double encode also kept CPU decode
+ scale + re-encode in the library, contradicting the "GPU handles it"
claim.

Now the system ffmpeg produces the final Discord-ready H264 in one pass
(-b:v/-maxrate at the configured bitrate, -bf 0, 1s keyframes, yuv420p,
-forced-idr) and prepareStream uses noTranscoding:true to remux only. One
GPU encode, no library decode/scale/re-encode.

Verified locally: high-motion source fills 8.7 Mbps at these args (vs the
~2.47 Mbps no-bitrate default), real :1 desktop holds 60fps at realtime,
and the capture -> copy/remux chain yields h264 1920x1080 yuv420p 60fps
has_b_frames=0. tsc --noEmit passes. Live Discord test pending reboot.
2026-06-10 11:23:52 +09:00
javis-bot
ad0caa8142 feat: 1080p60 NVENC selfbot broadcast (8 Mbps default)
Bump the default broadcast to 1080p 60fps at 8 Mbps and route both encode
stages through the GPU (RTX 5050, h264_nvenc) so 60fps stays smooth without
loading the 4-core host.

- selfbot.ts: capture ffmpeg uses h264_nvenc when streamHw is on (falls back
  to software x264 otherwise), and prepareStream now passes Encoders.nvenc()
  so the library's transcode runs on the GPU too. Guard loadLib for Encoders.
- config.ts: VNC_FRAMERATE default 30 -> 60, VNC_BITRATE_KBPS 4000 -> 8000.
- .env.example: document the new 1080p60/8 Mbps defaults and STREAM_HW.

Verified locally: h264_nvenc x11grab holds a steady 60fps with headroom,
Encoders.nvenc() returns valid h264_nvenc settings, and tsc --noEmit passes.
Live Discord voice-channel verification pending a host reboot.
2026-06-10 11:17:44 +09:00
3 changed files with 147 additions and 54 deletions

View File

@@ -58,13 +58,18 @@ STREAM_BACKEND=selfbot
# The VNC desktop runs on X display :1 (see docs/vnc-xfce-setup.md)
VNC_DISPLAY=:1
VNC_RESOLUTION=1920x1080
VNC_FRAMERATE=30
VNC_BITRATE_KBPS=4000
# 1080p60 broadcast. 8 Mbps suits 60fps (YouTube-style 1080p60 sits ~8-12 Mbps);
# drop to 30/4000 for a lighter stream. Max bitrate is 1.5x this value.
VNC_FRAMERATE=60
VNC_BITRATE_KBPS=8000
# --- selfbot backend ---
# A THROWAWAY/burner Discord user account token. NEVER your main account.
# Using a selfbot violates Discord ToS and can get the account banned.
DISCORD_SELFBOT_TOKEN=
# Hardware (NVENC) encode for the stream. 1 = use the GPU (recommended for
# 1080p60), 0 = software x264. Requires an NVIDIA GPU + ffmpeg built with nvenc.
STREAM_HW=1
# --- novnc backend ---
# e.g. http://192.168.10.9:6080/vnc.html (websockify --web=/usr/share/novnc 6080 localhost:5901)

View File

@@ -35,8 +35,8 @@ export const config = {
// x11grab source for the VNC display (TigerVNC runs the desktop on :1)
vncDisplay: opt("VNC_DISPLAY", ":1"),
vncResolution: opt("VNC_RESOLUTION", "1920x1080"),
vncFramerate: parseInt(opt("VNC_FRAMERATE", "30"), 10),
vncBitrateKbps: parseInt(opt("VNC_BITRATE_KBPS", "4000"), 10),
vncFramerate: parseInt(opt("VNC_FRAMERATE", "60"), 10),
vncBitrateKbps: parseInt(opt("VNC_BITRATE_KBPS", "8000"), 10),
// selfbot backend (ToS-risk; use a throwaway account token, never your main)
selfbotToken: opt("DISCORD_SELFBOT_TOKEN"),

View File

@@ -33,6 +33,26 @@ export class SelfbotStreamer implements ScreenStreamer {
return this.active;
}
/**
* Wait a randomised, human-plausible amount of time. Resolves immediately if
* the stream is aborted (stop()) mid-wait, so teardown never hangs on a pause.
*/
private humanPause(minMs: number, maxMs: number, signal?: AbortSignal): Promise<void> {
const ms = Math.floor(minMs + Math.random() * Math.max(0, maxMs - minMs));
return new Promise((resolve) => {
if (signal?.aborted) return resolve();
const onAbort = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
private async loadLib() {
let selfbot: any, vs: any;
try {
@@ -63,59 +83,127 @@ export class SelfbotStreamer implements ScreenStreamer {
if (!ctx.voiceChannelId) {
return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다.";
}
const { selfbot, vs } = await this.loadLib();
const { Streamer, prepareStream, playStream } = vs;
this.streamer = new Streamer(new selfbot.Client());
await this.streamer.client.login(this.config.selfbotToken);
await this.streamer.joinVoice(ctx.guildId, ctx.voiceChannelId);
const [w, h] = this.config.vncResolution.split("x").map((n) => parseInt(n, 10));
this.controller = new AbortController();
// Capture the VNC X display with the SYSTEM ffmpeg (which reliably has
// x11grab), then pipe that stream into the library. Relying on the lib's
// bundled libav for the x11grab input device is not portable; piping the
// system ffmpeg is. (Verified live against a real voice channel.)
const capture = spawn("ffmpeg", [
"-loglevel", "error",
"-f", "x11grab",
"-framerate", String(this.config.vncFramerate),
"-video_size", this.config.vncResolution,
"-i", this.config.vncDisplay,
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
"-pix_fmt", "yuv420p", "-g", String(this.config.vncFramerate),
"-f", "mpegts", "pipe:1",
]);
this.capture = capture;
capture.stderr?.on("data", (d) => {
if (!this.controller?.signal.aborted) console.error("[selfbot x11grab]", d.toString().trim());
});
const { command, output } = prepareStream(
capture.stdout,
{
width: w || 1920,
height: h || 1080,
frameRate: this.config.vncFramerate,
videoCodec: "H264",
bitrateVideo: this.config.vncBitrateKbps,
bitrateVideoMax: Math.round(this.config.vncBitrateKbps * 1.5),
},
this.controller.signal,
);
command.on("error", (err: Error) => {
if (!this.controller?.signal.aborted) console.error("[selfbot] ffmpeg error:", err);
});
// Lock the starting state BEFORE any await: the human-pause delays below
// mean start() is in-flight for several seconds, so a second /stream call
// must be rejected by the `this.active` guard above, and the status must
// read "starting" rather than idle during the wait. Keep controller /
// streamer / capture as LOCAL refs so an interleaved stop() (which nulls the
// instance fields) can't turn our own continuation into a null dereference.
this.active = true;
playStream(output, this.streamer, { type: "go-live" }, this.controller.signal)
.catch((err: Error) => console.error("[selfbot] playStream:", err))
.finally(() => {
this.active = false;
const controller = (this.controller = new AbortController());
const signal = controller.signal;
let streamer: any = null;
let capture: ChildProcess | null = null;
try {
const { selfbot, vs } = await this.loadLib();
const { Streamer, prepareStream, playStream } = vs;
signal.throwIfAborted();
streamer = this.streamer = new Streamer(new selfbot.Client());
await streamer.client.login(this.config.selfbotToken);
signal.throwIfAborted();
// Act like a person, not a bot: take a breath after coming online before
// navigating into the voice channel, then settle in for a few seconds
// before hitting "Go Live". Randomised so the cadence isn't
// fingerprintable. throwIfAborted() after each pause unwinds into the
// catch below if stop() lands mid-wait, so we never join/go-live on a
// torn-down streamer.
await this.humanPause(2500, 4500, signal);
signal.throwIfAborted();
await streamer.joinVoice(ctx.guildId, ctx.voiceChannelId);
await this.humanPause(6000, 10000, signal);
signal.throwIfAborted();
const [w, h] = this.config.vncResolution.split("x").map((n) => parseInt(n, 10));
// Capture the VNC X display with the SYSTEM ffmpeg (which reliably has
// x11grab), then pipe that stream into the library. Relying on the lib's
// bundled libav for the x11grab input device is not portable; piping the
// system ffmpeg is. (Verified live against a real voice channel.)
//
// The SYSTEM ffmpeg produces the final, Discord-ready H264 in one pass:
// target bitrate (-b:v/-maxrate), no B-frames (WebRTC requires this), a
// 1s keyframe interval, and yuv420p. The library then only REMUXES it
// (noTranscoding below) so there is no second decode/scale/encode. With
// streamHw on (default) this single encode runs on the GPU (h264_nvenc,
// RTX 5050); otherwise it falls back to software x264.
const hw = this.config.streamHw;
const kbps = this.config.vncBitrateKbps;
const maxKbps = Math.round(kbps * 1.5);
const captureCodecArgs = hw
? ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "ll", "-forced-idr", "1"]
: ["-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency"];
capture = this.capture = spawn("ffmpeg", [
"-loglevel", "error",
"-f", "x11grab",
"-framerate", String(this.config.vncFramerate),
"-video_size", this.config.vncResolution,
"-i", this.config.vncDisplay,
...captureCodecArgs,
"-b:v", `${kbps}k`, "-maxrate", `${maxKbps}k`, "-bufsize", `${kbps}k`,
"-bf", "0",
"-pix_fmt", "yuv420p",
"-g", String(this.config.vncFramerate),
"-f", "mpegts", "pipe:1",
]);
capture.stderr?.on("data", (d) => {
if (!signal.aborted) console.error("[selfbot x11grab]", d.toString().trim());
});
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
const { command, output } = prepareStream(
capture.stdout,
{
// The capture above is already a Discord-ready H264 elementary stream,
// so the library only remuxes it (no second encode). width/height/
// frameRate are passed for signalling; encoding options are ignored
// on the copy path.
width: w || 1920,
height: h || 1080,
frameRate: this.config.vncFramerate,
videoCodec: "H264",
noTranscoding: true,
},
signal,
);
command.on("error", (err: Error) => {
if (!signal.aborted) console.error("[selfbot] ffmpeg error:", err);
});
signal.throwIfAborted();
playStream(output, streamer, { type: "go-live" }, signal)
.catch((err: Error) => {
if (!signal.aborted) console.error("[selfbot] playStream:", err);
})
.finally(() => {
// The stream ended on its own (not via stop()); release the lock.
if (this.controller === controller) this.active = false;
});
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
} catch (e) {
// Startup was aborted (stop() during a pause) or failed. Tear down using
// our LOCAL refs, then clear instance state only if it still points at us
// (a concurrent stop()/start() may already have replaced it).
try {
capture?.kill("SIGKILL");
} catch {
/* ignore */
}
try {
streamer?.leaveVoice?.();
streamer?.client?.destroy?.();
} catch {
/* ignore */
}
if (this.capture === capture) this.capture = null;
if (this.streamer === streamer) this.streamer = null;
if (this.controller === controller) this.controller = null;
this.active = false;
if (signal.aborted) return "송출을 시작하는 중에 중지했습니다.";
throw e;
}
}
async stop(): Promise<void> {