Compare commits
5 Commits
5137fdeaf7
...
2fd5e0fe9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd5e0fe9e | ||
|
|
2c7f0a95b5 | ||
|
|
b6cf05f6cf | ||
|
|
40fd7dbb59 | ||
|
|
ad0caa8142 |
@@ -58,13 +58,18 @@ STREAM_BACKEND=selfbot
|
|||||||
# The VNC desktop runs on X display :1 (see docs/vnc-xfce-setup.md)
|
# The VNC desktop runs on X display :1 (see docs/vnc-xfce-setup.md)
|
||||||
VNC_DISPLAY=:1
|
VNC_DISPLAY=:1
|
||||||
VNC_RESOLUTION=1920x1080
|
VNC_RESOLUTION=1920x1080
|
||||||
VNC_FRAMERATE=30
|
# 1080p60 broadcast. 8 Mbps suits 60fps (YouTube-style 1080p60 sits ~8-12 Mbps);
|
||||||
VNC_BITRATE_KBPS=4000
|
# drop to 30/4000 for a lighter stream. Max bitrate is 1.5x this value.
|
||||||
|
VNC_FRAMERATE=60
|
||||||
|
VNC_BITRATE_KBPS=8000
|
||||||
|
|
||||||
# --- selfbot backend ---
|
# --- selfbot backend ---
|
||||||
# A THROWAWAY/burner Discord user account token. NEVER your main account.
|
# A THROWAWAY/burner Discord user account token. NEVER your main account.
|
||||||
# Using a selfbot violates Discord ToS and can get the account banned.
|
# Using a selfbot violates Discord ToS and can get the account banned.
|
||||||
DISCORD_SELFBOT_TOKEN=
|
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 ---
|
# --- novnc backend ---
|
||||||
# e.g. http://192.168.10.9:6080/vnc.html (websockify --web=/usr/share/novnc 6080 localhost:5901)
|
# e.g. http://192.168.10.9:6080/vnc.html (websockify --web=/usr/share/novnc 6080 localhost:5901)
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export const config = {
|
|||||||
// x11grab source for the VNC display (TigerVNC runs the desktop on :1)
|
// x11grab source for the VNC display (TigerVNC runs the desktop on :1)
|
||||||
vncDisplay: opt("VNC_DISPLAY", ":1"),
|
vncDisplay: opt("VNC_DISPLAY", ":1"),
|
||||||
vncResolution: opt("VNC_RESOLUTION", "1920x1080"),
|
vncResolution: opt("VNC_RESOLUTION", "1920x1080"),
|
||||||
vncFramerate: parseInt(opt("VNC_FRAMERATE", "30"), 10),
|
vncFramerate: parseInt(opt("VNC_FRAMERATE", "60"), 10),
|
||||||
vncBitrateKbps: parseInt(opt("VNC_BITRATE_KBPS", "4000"), 10),
|
vncBitrateKbps: parseInt(opt("VNC_BITRATE_KBPS", "8000"), 10),
|
||||||
|
|
||||||
// selfbot backend (ToS-risk; use a throwaway account token, never your main)
|
// selfbot backend (ToS-risk; use a throwaway account token, never your main)
|
||||||
selfbotToken: opt("DISCORD_SELFBOT_TOKEN"),
|
selfbotToken: opt("DISCORD_SELFBOT_TOKEN"),
|
||||||
|
|||||||
@@ -33,6 +33,26 @@ export class SelfbotStreamer implements ScreenStreamer {
|
|||||||
return this.active;
|
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() {
|
private async loadLib() {
|
||||||
let selfbot: any, vs: any;
|
let selfbot: any, vs: any;
|
||||||
try {
|
try {
|
||||||
@@ -63,59 +83,127 @@ export class SelfbotStreamer implements ScreenStreamer {
|
|||||||
if (!ctx.voiceChannelId) {
|
if (!ctx.voiceChannelId) {
|
||||||
return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다.";
|
return "셀프봇 송출은 음성 채널 안에서 호출해야 합니다.";
|
||||||
}
|
}
|
||||||
|
// 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;
|
||||||
|
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 { selfbot, vs } = await this.loadLib();
|
||||||
const { Streamer, prepareStream, playStream } = vs;
|
const { Streamer, prepareStream, playStream } = vs;
|
||||||
|
signal.throwIfAborted();
|
||||||
|
|
||||||
this.streamer = new Streamer(new selfbot.Client());
|
streamer = this.streamer = new Streamer(new selfbot.Client());
|
||||||
await this.streamer.client.login(this.config.selfbotToken);
|
await streamer.client.login(this.config.selfbotToken);
|
||||||
await this.streamer.joinVoice(ctx.guildId, ctx.voiceChannelId);
|
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));
|
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
|
// 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
|
// 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
|
// bundled libav for the x11grab input device is not portable; piping the
|
||||||
// system ffmpeg is. (Verified live against a real voice channel.)
|
// system ffmpeg is. (Verified live against a real voice channel.)
|
||||||
const capture = spawn("ffmpeg", [
|
//
|
||||||
|
// 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",
|
"-loglevel", "error",
|
||||||
"-f", "x11grab",
|
"-f", "x11grab",
|
||||||
"-framerate", String(this.config.vncFramerate),
|
"-framerate", String(this.config.vncFramerate),
|
||||||
"-video_size", this.config.vncResolution,
|
"-video_size", this.config.vncResolution,
|
||||||
"-i", this.config.vncDisplay,
|
"-i", this.config.vncDisplay,
|
||||||
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
|
...captureCodecArgs,
|
||||||
"-pix_fmt", "yuv420p", "-g", String(this.config.vncFramerate),
|
"-b:v", `${kbps}k`, "-maxrate", `${maxKbps}k`, "-bufsize", `${kbps}k`,
|
||||||
|
"-bf", "0",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-g", String(this.config.vncFramerate),
|
||||||
"-f", "mpegts", "pipe:1",
|
"-f", "mpegts", "pipe:1",
|
||||||
]);
|
]);
|
||||||
this.capture = capture;
|
|
||||||
capture.stderr?.on("data", (d) => {
|
capture.stderr?.on("data", (d) => {
|
||||||
if (!this.controller?.signal.aborted) console.error("[selfbot x11grab]", d.toString().trim());
|
if (!signal.aborted) console.error("[selfbot x11grab]", d.toString().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
const { command, output } = prepareStream(
|
const { command, output } = prepareStream(
|
||||||
capture.stdout,
|
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,
|
width: w || 1920,
|
||||||
height: h || 1080,
|
height: h || 1080,
|
||||||
frameRate: this.config.vncFramerate,
|
frameRate: this.config.vncFramerate,
|
||||||
videoCodec: "H264",
|
videoCodec: "H264",
|
||||||
bitrateVideo: this.config.vncBitrateKbps,
|
noTranscoding: true,
|
||||||
bitrateVideoMax: Math.round(this.config.vncBitrateKbps * 1.5),
|
|
||||||
},
|
},
|
||||||
this.controller.signal,
|
signal,
|
||||||
);
|
);
|
||||||
command.on("error", (err: Error) => {
|
command.on("error", (err: Error) => {
|
||||||
if (!this.controller?.signal.aborted) console.error("[selfbot] ffmpeg error:", err);
|
if (!signal.aborted) console.error("[selfbot] ffmpeg error:", err);
|
||||||
});
|
});
|
||||||
|
signal.throwIfAborted();
|
||||||
|
|
||||||
this.active = true;
|
playStream(output, streamer, { type: "go-live" }, signal)
|
||||||
playStream(output, this.streamer, { type: "go-live" }, this.controller.signal)
|
.catch((err: Error) => {
|
||||||
.catch((err: Error) => console.error("[selfbot] playStream:", err))
|
if (!signal.aborted) console.error("[selfbot] playStream:", err);
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.active = false;
|
// The stream ended on its own (not via stop()); release the lock.
|
||||||
|
if (this.controller === controller) this.active = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
|
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> {
|
async stop(): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user