Files
javis_bot/bot/src/stream/selfbot.ts
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

159 lines
6.1 KiB
TypeScript

/**
* Selfbot live-stream backend (default).
*
* Streams the VNC X display (:1) into the voice channel as a real Discord
* "Go Live" broadcast. Discord blocks video from *bot* accounts, so this path
* requires a USER account token (a "selfbot"), which violates Discord ToS and
* can get the account banned. Use a throwaway/burner account, never your main.
*
* Dependencies are optional (native) and dynamically imported so the core bot
* installs/runs without them:
* bun add discord.js-selfbot-v13 @dank074/discord-video-stream
* bun pm trust @dank074/node-av node-datachannel # build native deps
*
* API targets @dank074/discord-video-stream v6 (verified against its d.ts):
* new Streamer(client) -> joinVoice(guildId, channelId)
* prepareStream(input, opts, signal) -> { command, output }
* playStream(output, streamer, { type: "go-live" }, signal)
*/
import { spawn, type ChildProcess } from "node:child_process";
import type { AppConfig } from "../config.ts";
import type { ScreenStreamer, StreamContext } from "./index.ts";
export class SelfbotStreamer implements ScreenStreamer {
readonly kind = "selfbot" as const;
private streamer: any = null;
private capture: ChildProcess | null = null;
private controller: AbortController | null = null;
private active = false;
constructor(private config: AppConfig) {}
isActive() {
return this.active;
}
private async loadLib() {
let selfbot: any, vs: any;
try {
selfbot = await import("discord.js-selfbot-v13");
// Optional native dep; resolved at runtime only.
// @ts-ignore - optional dependency, may be absent until `bun add`ed
vs = await import("@dank074/discord-video-stream");
} catch (e) {
throw new Error(
"셀프봇 송출 의존성이 없습니다. 설치: bun add discord.js-selfbot-v13 @dank074/discord-video-stream\n" +
`원본 오류: ${(e as Error).message}`,
);
}
if (!vs.Streamer || !vs.prepareStream || !vs.playStream) {
throw new Error(
"@dank074/discord-video-stream v6 API(Streamer/prepareStream/playStream)를 찾지 못했습니다. " +
"package.json 버전을 ^6.0.0으로 맞추세요.",
);
}
return { selfbot, vs };
}
async start(ctx: StreamContext): Promise<string> {
if (this.active) return "이미 송출 중입니다.";
if (!this.config.selfbotToken) {
return "DISCORD_SELFBOT_TOKEN이 설정되지 않았습니다 (.env). 버너 계정 토큰을 넣어주세요.";
}
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.)
//
// 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"];
const 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",
]);
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,
{
// 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,
},
this.controller.signal,
);
command.on("error", (err: Error) => {
if (!this.controller?.signal.aborted) console.error("[selfbot] ffmpeg error:", err);
});
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;
});
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
}
async stop(): Promise<void> {
this.controller?.abort();
this.controller = null;
try {
this.capture?.kill("SIGKILL");
} catch {
/* ignore */
}
this.capture = null;
try {
this.streamer?.leaveVoice?.();
this.streamer?.client?.destroy?.();
} catch {
/* ignore */
}
this.streamer = null;
this.active = false;
}
}