Publish the clip as a screen share alongside the audio

VIDEO_ENABLED makes the bot publish a second LiveKit track with the
picture. Stoat only grants screen_share in the call token when the bot
has the Video permission and the instance has video enabled, so a refusal
is reported in chat and playback continues with sound alone.

Two constraints shaped the pipeline, both found by testing rather than
assumption:

- Only progressive formats can be streamed. Separate video+audio streams
  make yt-dlp download both in full before muxing a single byte, and
  direct CDN URLs handed to ffmpeg simply hang — YouTube no longer serves
  them to other clients. That caps video at the 360p single file YouTube
  offers, and the format is checked before committing to the video path,
  since audio would otherwise come from the same broken pipeline.
- One ffmpeg with two outputs, paced by -re: an unpaced decode races
  ahead of the sound and eats memory at 1.4 MB per frame.

The same CDN-URL finding removes the audio seek shortcut, which resolved
such a URL and would have hung the same way; seeking now decodes up to
the offset like it already did behind a proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 01:10:11 +03:00
co-authored by Claude Opus 5
parent 24dfcf9232
commit 49b87dc172
10 changed files with 387 additions and 23 deletions
+32
View File
@@ -9,6 +9,7 @@ import {
type PlayerStatus,
type Track,
} from "../types.js";
import { VideoPublisher } from "./video.js";
import {
MediaPlayer,
parseFfmpegDuration,
@@ -73,6 +74,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
/** Tracked from connection events: revoice's own `connected` getter is broken. */
private voiceReady = false;
private media: MediaPlayerLike | null = null;
private videoPublisher: VideoPublisher | null = null;
private currentInput: PlaybackInput | null = null;
private seekOffset = 0;
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
@@ -217,6 +219,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
async leaveVoice(): Promise<void> {
this.cancelLeaveTimer();
this.stopScreenShare();
this.stopTicker();
this.teardownPlayback();
this.current = null;
@@ -293,6 +296,10 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
media.setVolume(this.volume / 100);
this.startTicker();
if (input.video && this.connection) {
await this.startScreenShare(input.video);
}
// A downloader that dies mid-stream just looks like a very short track, so
// say why instead of silently moving on.
void input.failure?.then((reason) => {
@@ -331,11 +338,36 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.log.debug({ err }, "media.stop() threw");
}
}
this.stopScreenShare();
this.currentInput?.cleanup();
this.currentInput = null;
this.seekOffset = 0;
}
private async startScreenShare(video: NonNullable<PlaybackInput["video"]>): Promise<void> {
const room = this.connection?.room;
if (!room) return;
const publisher = new VideoPublisher({ width: video.width, height: video.height });
try {
await publisher.start(room, video.stream, (reason) => {
this.notify(`📺 Видео отключено: ${reason}.`);
});
this.videoPublisher = publisher;
} catch (err) {
this.log.warn({ err }, "could not publish screen share");
this.notify(
"📺 Не удалось показать клип: инстанс не разрешает видео (нужны право Video у бота и включённое видео в конфигурации Stoat). Звук играет как обычно.",
);
await publisher.stop().catch(() => {});
}
}
private stopScreenShare(): void {
const publisher = this.videoPublisher;
this.videoPublisher = null;
if (publisher) void publisher.stop().catch(() => {});
}
private async handleFinish(): Promise<void> {
if (this.expectingStop) {
this.expectingStop = false;
+3
View File
@@ -1,5 +1,6 @@
import { createRequire } from "node:module";
import type { Readable } from "node:stream";
import type { Room } from "@livekit/rtc-node";
import { UserFacingError } from "../types.js";
// revoice.js is CommonJS and its bundled typings lag behind the LiveKit rewrite,
@@ -29,6 +30,8 @@ export const VOICE_STATE_OFFLINE = "off";
export interface VoiceConnectionLike {
channelId: string;
/** The underlying LiveKit room, used to publish the screen share. */
room: Room;
play(media: MediaPlayerLike): Promise<void>;
leave(): Promise<void>;
destroy(): Promise<void>;
+136
View File
@@ -0,0 +1,136 @@
import { createRequire } from "node:module";
import type { Readable } from "node:stream";
import type { LocalVideoTrack, Room, VideoSource } from "@livekit/rtc-node";
import { logger } from "../logger.js";
// revoice.js pulls in the CommonJS build of @livekit/rtc-node, and the native
// FFI client is per-module-instance: importing the ESM build here would give us
// a second instance whose tracks the room (created by revoice) cannot publish.
// So we require the very same module object it uses.
const require = createRequire(import.meta.url);
const rtc = require("@livekit/rtc-node") as typeof import("@livekit/rtc-node");
const log = logger.child({ mod: "video" });
/** Give up on the video track if the source never produced a picture. */
const FIRST_FRAME_TIMEOUT_MS = 15_000;
export interface VideoPublisherOptions {
width: number;
height: number;
}
/**
* Publishes a raw I420 stream into a LiveKit room as a screen share. ffmpeg
* feeds frames at playback speed, so this class only has to slice the byte
* stream into frames and hand them over.
*/
export class VideoPublisher {
private readonly frameSize: number;
private source: VideoSource | null = null;
private track: LocalVideoTrack | null = null;
private trackSid: string | null = null;
private room: Room | null = null;
private stream: Readable | null = null;
private pending: Buffer[] = [];
private pendingBytes = 0;
private frames = 0;
private firstFrameTimer: NodeJS.Timeout | null = null;
constructor(private readonly options: VideoPublisherOptions) {
// I420: one luma plane plus two half-resolution chroma planes.
this.frameSize = Math.floor((options.width * options.height * 3) / 2);
}
get isPublishing(): boolean {
return this.trackSid !== null;
}
/** Publishes the track and starts pumping frames. Resolves once published. */
async start(room: Room, stream: Readable, onGiveUp?: (reason: string) => void): Promise<void> {
const { width, height } = this.options;
const source = new rtc.VideoSource(width, height);
const track = rtc.LocalVideoTrack.createVideoTrack("screen", source);
const options = new rtc.TrackPublishOptions();
options.source = rtc.TrackSource.SOURCE_SCREENSHARE;
const participant = room.localParticipant;
if (!participant) throw new Error("room has no local participant yet");
const publication = await participant.publishTrack(track, options);
this.room = room;
this.source = source;
this.track = track;
this.trackSid = publication.sid ?? null;
this.stream = stream;
stream.on("data", (chunk: Buffer) => this.consume(chunk));
stream.once("error", (err) => log.debug({ err }, "video stream error"));
this.firstFrameTimer = setTimeout(() => {
if (this.frames > 0) return;
log.warn("no video frames arrived, dropping the screen share");
onGiveUp?.("источник не отдал видео");
void this.stop();
}, FIRST_FRAME_TIMEOUT_MS);
this.firstFrameTimer.unref?.();
log.info({ width, height, sid: publication.sid }, "screen share published");
}
private consume(chunk: Buffer): void {
if (!this.source) return;
this.pending.push(chunk);
this.pendingBytes += chunk.length;
if (this.pendingBytes < this.frameSize) return;
let buffer = this.pending.length === 1 ? (this.pending[0] as Buffer) : Buffer.concat(this.pending, this.pendingBytes);
while (buffer.length >= this.frameSize) {
const frame = buffer.subarray(0, this.frameSize);
try {
this.source.captureFrame(
new rtc.VideoFrame(frame, this.options.width, this.options.height, rtc.VideoBufferType.I420),
);
this.frames += 1;
} catch (err) {
log.debug({ err }, "captureFrame failed");
}
buffer = buffer.subarray(this.frameSize);
}
// Keep only the tail of a partial frame for the next chunk.
this.pending = buffer.length > 0 ? [Buffer.from(buffer)] : [];
this.pendingBytes = buffer.length;
}
async stop(): Promise<void> {
if (this.firstFrameTimer) {
clearTimeout(this.firstFrameTimer);
this.firstFrameTimer = null;
}
this.stream?.removeAllListeners("data");
this.stream = null;
this.pending = [];
this.pendingBytes = 0;
const { room, trackSid } = this;
this.trackSid = null;
if (room?.localParticipant && trackSid) {
try {
await room.localParticipant.unpublishTrack(trackSid, true);
} catch (err) {
log.debug({ err }, "unpublishTrack failed");
}
}
try {
this.track?.close?.();
await this.source?.close?.();
} catch (err) {
log.debug({ err }, "closing video track failed");
}
this.track = null;
this.source = null;
this.room = null;
this.frames = 0;
}
}