Fall back to separate video and audio downloads

Playing with video only worked for clips YouTube still serves as a single
progressive file, and it mostly does not: the log showed "Requested
format is not available" and every track fell back to sound only.

A progressive format is still preferred when offered — one download, one
decode — but when there is none, video and audio are fetched in parallel
and handed to one ffmpeg over separate pipes, which pairs them by
timestamp. This also lifts the 360p ceiling: the height is now a CPU
choice rather than a limit.

The extra-descriptor plumbing (two inputs, two outputs on one ffmpeg) was
verified against synthetic media: exactly 5.0s of PCM and 120 whole
frames at 24 fps, no partial tail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 02:21:14 +03:00
co-authored by Claude Opus 5
parent a9b680c418
commit 9285fd7fbd
5 changed files with 370 additions and 313 deletions
+3 -2
View File
@@ -77,8 +77,9 @@ YTDLP_JS_RUNTIME=node
# Показывать клип в голосовом канале как демонстрацию экрана (только YouTube). # Показывать клип в голосовом канале как демонстрацию экрана (только YouTube).
# Требует прав Video у бота и включённого видео в конфигурации инстанса. # Требует прав Video у бота и включённого видео в конфигурации инстанса.
# YouTube отдаёт одним файлом только 360p, а стримить можно лишь такой формат — # 360p по умолчанию из осторожности: кодирование видео заметно грузит CPU
# отсюда размер по умолчанию. Кодирование видео заметно грузит CPU сервера. # сервера. Можно поднять до 720 (VIDEO_WIDTH=1280, VIDEO_HEIGHT=720), если
# машина позволяет.
# Это лишь разрешение: сам показ включается тумблером в панели или командой # Это лишь разрешение: сам показ включается тумблером в панели или командой
# !video, и по умолчанию выключен. При false тумблер в панели не показывается. # !video, и по умолчанию выключен. При false тумблер в панели не показывается.
VIDEO_ENABLED=false VIDEO_ENABLED=false
+9 -5
View File
@@ -249,15 +249,19 @@ VIDEO_FPS=24
Как это устроено и почему так: Как это устроено и почему так:
- **Только YouTube и только 360p.** Стримить можно лишь прогрессивный формат — один файл со - **Только YouTube.** Для остальных источников картинки нет, трек играет звуком.
звуком и картинкой. Раздельные дорожки (720p и выше) yt-dlp обязан сначала скачать целиком - **Два способа получить картинку.** Если YouTube отдаёт прогрессивный формат (один файл со
и лишь потом склеить, то есть воспроизведение началось бы после полной загрузки. Отдавать звуком и видео) — используется он: одна загрузка, один декодер. Такие форматы встречаются всё
ffmpeg прямые ссылки на CDN тоже нельзя: YouTube их для сторонних клиентов подвешивает. реже, поэтому есть запасной путь: видео и звук качаются двумя процессами параллельно и
сводятся одним ffmpeg по таймкодам. Склейка средствами yt-dlp не годится — он скачивает оба
потока целиком, прежде чем выдать первый байт; прямые ссылки на CDN тоже: YouTube подвешивает
их для сторонних клиентов.
- **Один процесс ffmpeg, два выхода:** PCM для голосовой дорожки и сырые I420-кадры для видео. - **Один процесс ffmpeg, два выхода:** PCM для голосовой дорожки и сырые I420-кадры для видео.
Темп задаёт `-re`, иначе кадры улетали бы вперёд звука и съедали память — распакованный кадр Темп задаёт `-re`, иначе кадры улетали бы вперёд звука и съедали память — распакованный кадр
720p весит 1.4 МБ. 720p весит 1.4 МБ.
- **Цена.** Кодирование видео ложится на CPU сервера и держится всё время трека, в отличие от - **Цена.** Кодирование видео ложится на CPU сервера и держится всё время трека, в отличие от
почти бесплатного звука. Ставьте `VIDEO_FPS` пониже, если нагрузка мешает. почти бесплатного звука. 360p по умолчанию выбран из осторожности; поднимайте до 720p, если
машина тянет, и снижайте `VIDEO_FPS`, если нагрузка мешает.
- Живые трансляции и не-YouTube источники играют звуком, как раньше. - Живые трансляции и не-YouTube источники играют звуком, как раньше.
## Прокси, когда YouTube недоступен ## Прокси, когда YouTube недоступен
+6 -6
View File
@@ -223,12 +223,12 @@ export async function openPlayback(
// The format is checked before committing to the video pipeline: audio comes // The format is checked before committing to the video pipeline: audio comes
// out of that same pipeline, so falling back afterwards would kill the sound. // out of that same pipeline, so falling back afterwards would kill the sound.
if ( const videoMode = canShowVideo(track, options.video ?? false)
canShowVideo(track, options.video ?? false) && ? await ytdlp.selectVideoMode(track.url, config.VIDEO_HEIGHT)
(await ytdlp.hasProgressiveVideo(track.url, config.VIDEO_HEIGHT)) : null;
) { if (videoMode) {
log.info({ title: track.title }, "playing with video"); log.info({ title: track.title, mode: videoMode }, "playing with video");
const pipeline = openVideoPipeline(track.url, seekSeconds); const pipeline = openVideoPipeline(track.url, seekSeconds, videoMode);
return { return {
input: pipeline.audio, input: pipeline.audio,
inputOptions: pipeline.audioInputOptions, inputOptions: pipeline.audioInputOptions,
+78 -40
View File
@@ -1,9 +1,9 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import type { Readable } from "node:stream"; import type { Duplex, Readable, Writable } from "node:stream";
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { downloadArgs, progressiveFormat } from "./ytdlp.js"; import { AUDIO_FORMAT, downloadArgs, progressiveFormat, videoFormat, type VideoMode } from "./ytdlp.js";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
/** ffmpeg-static ships the binary revoice.js already relies on. */ /** ffmpeg-static ships the binary revoice.js already relies on. */
@@ -23,33 +23,35 @@ export interface VideoPipeline {
kill(): void; kill(): void;
} }
/** function spawnDownload(pageUrl: string, format: string) {
* One download, one decode, two outputs: PCM for the voice track and I420 frames return spawn(
* for the screen share.
*
* yt-dlp does the fetching (so cookies and the proxy apply as everywhere else)
* and hands ffmpeg a progressive stream. `-re` paces the decode at playback
* speed: it keeps picture and sound together and stops the video from racing
* ahead — a decoded 720p frame is 1.4 MB, so an unpaced stream would eat memory
* in seconds.
*/
export function openVideoPipeline(pageUrl: string, seekSeconds = 0): VideoPipeline {
const { VIDEO_WIDTH: width, VIDEO_HEIGHT: height, VIDEO_FPS: fps } = config;
const ytdlp = spawn(
config.YTDLP_PATH, config.YTDLP_PATH,
[ [...downloadArgs(), "-f", format, "--no-playlist", "--quiet", "-o", "-", pageUrl],
...downloadArgs(),
"-f",
progressiveFormat(height),
"--no-playlist",
"--quiet",
"-o",
"-",
pageUrl,
],
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
); );
}
/**
* Decodes a clip into the two things a call needs: PCM for the voice track and
* raw I420 frames for the screen share, both out of a single ffmpeg.
*
* A progressive format (one file with both streams) is the cheap path — one
* download, one decode. When YouTube offers no such format, which is now the
* common case, picture and sound are downloaded in parallel and fed to ffmpeg
* over separate pipes, and it pairs them by timestamp.
*
* Everything is fetched by yt-dlp, so cookies and the proxy apply as everywhere
* else — handing CDN URLs to ffmpeg instead does not work, YouTube stalls them.
* `-re` paces the decode at playback speed, which keeps the two in step and
* stops the video from racing ahead: a decoded 720p frame is 1.4 MB, so an
* unpaced stream would eat memory in seconds.
*/
export function openVideoPipeline(pageUrl: string, seekSeconds = 0, mode: VideoMode = "split"): VideoPipeline {
const { VIDEO_WIDTH: width, VIDEO_HEIGHT: height, VIDEO_FPS: fps } = config;
const split = mode === "split";
const videoSource = spawnDownload(pageUrl, split ? videoFormat(height) : progressiveFormat(height));
const audioSource = split ? spawnDownload(pageUrl, AUDIO_FORMAT) : null;
const filters = [ const filters = [
`scale=${width}:${height}:force_original_aspect_ratio=decrease`, `scale=${width}:${height}:force_original_aspect_ratio=decrease`,
@@ -58,19 +60,25 @@ export function openVideoPipeline(pageUrl: string, seekSeconds = 0): VideoPipeli
"format=yuv420p", "format=yuv420p",
].join(","); ].join(",");
const seek = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
// Split: fd 0 video in, fd 3 audio in, fd 4 frames out.
// Progressive: fd 0 everything in, fd 3 frames out.
const videoOutFd = split ? 4 : 3;
const ffmpeg = spawn( const ffmpeg = spawn(
FFMPEG_PATH, FFMPEG_PATH,
[ [
"-hide_banner", "-hide_banner",
"-loglevel", "-loglevel",
"error", "error",
...(seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : []), ...seek,
"-re", "-re",
"-i", "-i",
"pipe:0", "pipe:0",
...(split ? [...seek, "-re", "-i", "pipe:3"] : []),
// Audio branch → stdout. // Audio branch → stdout.
"-map", "-map",
"0:a:0?", split ? "1:a:0" : "0:a:0",
"-f", "-f",
"s16le", "s16le",
"-ar", "-ar",
@@ -78,27 +86,56 @@ export function openVideoPipeline(pageUrl: string, seekSeconds = 0): VideoPipeli
"-ac", "-ac",
"2", "2",
"pipe:1", "pipe:1",
// Video branch → the extra pipe on fd 3. // Video branch → its own pipe.
"-map", "-map",
"0:v:0", "0:v:0",
"-vf", "-vf",
filters, filters,
"-f", "-f",
"rawvideo", "rawvideo",
"pipe:3", `pipe:${videoOutFd}`,
], ],
{ stdio: ["pipe", "pipe", "pipe", "pipe"], windowsHide: true }, {
) as ChildProcessWithoutNullStreams & { stdio: Readable[] }; stdio: split
? ["pipe", "pipe", "pipe", "pipe", "pipe"]
: ["pipe", "pipe", "pipe", "pipe"],
windowsHide: true,
},
) as ChildProcessWithoutNullStreams & { stdio: Array<Duplex & Readable> };
ytdlp.stdout.pipe(ffmpeg.stdin); const videoOut = ffmpeg.stdio[videoOutFd] as Readable;
videoSource.stdout.pipe(ffmpeg.stdin);
ytdlp.stderr.setEncoding("utf8"); const pipes: Array<Readable | Writable | Duplex> = [
ytdlp.stderr.on("data", (chunk: string) => log.warn({ ytdlp: chunk.trim() }, "video download")); videoSource.stdout,
ffmpeg.stdin,
ffmpeg.stdout,
videoOut,
];
if (audioSource) {
const audioIn = ffmpeg.stdio[3] as Duplex;
audioSource.stdout.pipe(audioIn);
pipes.push(audioSource.stdout, audioIn);
}
const children: ChildProcess[] = [videoSource, ffmpeg];
if (audioSource) children.push(audioSource);
for (const [name, child] of [
["video", videoSource],
["audio", audioSource],
] as const) {
if (!child) continue;
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) =>
log.warn({ stream: name, ytdlp: chunk.trim() }, "video pipeline download"),
);
}
ffmpeg.stderr.setEncoding("utf8"); ffmpeg.stderr.setEncoding("utf8");
ffmpeg.stderr.on("data", (chunk: string) => log.warn({ ffmpeg: chunk.trim() }, "ffmpeg")); ffmpeg.stderr.on("data", (chunk: string) => log.warn({ ffmpeg: chunk.trim() }, "ffmpeg"));
// A closed pipe when the other side goes away is expected, not an error. // A closed pipe when the other side goes away is expected, not an error.
for (const stream of [ytdlp.stdout, ffmpeg.stdin, ffmpeg.stdout]) { for (const stream of pipes) {
stream.on("error", (err: NodeJS.ErrnoException) => { stream.on("error", (err: NodeJS.ErrnoException) => {
if (err.code !== "EPIPE") log.debug({ err }, "pipeline stream error"); if (err.code !== "EPIPE") log.debug({ err }, "pipeline stream error");
}); });
@@ -108,14 +145,15 @@ export function openVideoPipeline(pageUrl: string, seekSeconds = 0): VideoPipeli
return { return {
audio: ffmpeg.stdout, audio: ffmpeg.stdout,
audioInputOptions: ["-f", "s16le", "-ar", "48000", "-ac", "2"], audioInputOptions: ["-f", "s16le", "-ar", "48000", "-ac", "2"],
video: ffmpeg.stdio[3] as Readable, video: videoOut,
width, width,
height, height,
kill: () => { kill: () => {
if (killed) return; if (killed) return;
killed = true; killed = true;
if (ytdlp.exitCode === null) ytdlp.kill("SIGKILL"); for (const child of children) {
if (ffmpeg.exitCode === null) ffmpeg.kill("SIGKILL"); if (child.exitCode === null) child.kill("SIGKILL");
}
}, },
}; };
} }
+30 -16
View File
@@ -346,43 +346,57 @@ export function downloadArgs(): string[] {
return baseArgs({ download: true }); return baseArgs({ download: true });
} }
export type VideoMode = "progressive" | "split";
/** /**
* Checks whether YouTube still offers a progressive format (video and audio in * Decides how a clip can be played with picture, in one lookup:
* one file) at this size. Only those can be streamed: anything that needs *
* merging makes yt-dlp download both streams in full before writing a byte, and * - "progressive" — one file carries both streams. Cheapest: a single download
* direct CDN URLs are no longer fetchable by ffmpeg — YouTube stalls them. * and a single decode.
* - "split" — video and audio come separately, so they are downloaded in
* parallel and paired by ffmpeg. YouTube increasingly offers only this.
* - null — no usable picture; the track plays as audio.
*/ */
export async function hasProgressiveVideo(pageUrl: string, maxHeight: number): Promise<boolean> { export async function selectVideoMode(pageUrl: string, maxHeight: number): Promise<VideoMode | null> {
try { try {
const { stdout } = await runYtDlp([ const { stdout } = await runYtDlp([
...baseArgs({ download: true }), ...baseArgs({ download: true }),
"-f", "-f",
progressiveFormat(maxHeight), `b[height<=${maxHeight}]/bv*[height<=${maxHeight}]/b`,
"--no-playlist", "--no-playlist",
"--print", "--print",
"%(format_id)s", "%(format_id)s|%(acodec)s|%(vcodec)s",
pageUrl, pageUrl,
]); ]);
const formatId = stdout.trim(); const [formatId, acodec, vcodec] = stdout.trim().split("|");
if (!formatId) { if (!formatId || !vcodec || vcodec === "none") {
log.warn({ pageUrl }, "no progressive format offered, playing audio only"); log.warn({ pageUrl }, "no video format offered, playing audio only");
return false; return null;
} }
log.info({ pageUrl, formatId }, "progressive format for video playback"); const mode: VideoMode = acodec && acodec !== "none" ? "progressive" : "split";
return true; log.info({ pageUrl, formatId, mode }, "video format selected");
return mode;
} catch (err) { } catch (err) {
// Worth seeing: this is the difference between "clip plays" and "sound only". // Worth seeing: this is the difference between "clip plays" and "sound only".
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
log.warn({ pageUrl, reason }, "format lookup failed, playing audio only"); log.warn({ pageUrl, reason }, "format lookup failed, playing audio only");
return false; return null;
} }
} }
/** Best single-file format within the height budget. */ /** Single file with both streams, when YouTube still offers one. */
export function progressiveFormat(maxHeight: number): string { export function progressiveFormat(maxHeight: number): string {
return `b[height<=${maxHeight}]/b`; return `b[height<=${maxHeight}]`;
} }
/** Video-only stream, paired with AUDIO_FORMAT by ffmpeg. */
export function videoFormat(maxHeight: number): string {
return `bv*[height<=${maxHeight}]/b[height<=${maxHeight}]/b`;
}
/** Audio stream to pair with the picture. */
export const AUDIO_FORMAT = "ba/b";
/** Private, disposable copy of the cookie jar for concurrent reads. */ /** Private, disposable copy of the cookie jar for concurrent reads. */
async function copyCookies(): Promise<string | null> { async function copyCookies(): Promise<string | null> {
if (!config.YTDLP_COOKIES) return null; if (!config.YTDLP_COOKIES) return null;