Expose the knobs that actually decide video quality

Frame size alone does not make a picture sharp: LiveKit picks a
conservative bitrate for whatever resolution it is given, so 720p could
well look worse than a well-fed 480p. The encoder ceiling and codec are
now configurable, and the frame queue's memory budget with them, since
raising the resolution multiplies both the bytes per frame and the wait.

Startup logs what the track was published with, and the README explains
which knob to move first and what in the "video sync" line says the
machine has run out of headroom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 05:14:38 +03:00
co-authored by Claude Opus 5
parent 7a369cf9f2
commit a1596cd654
4 changed files with 92 additions and 6 deletions
+14
View File
@@ -90,6 +90,20 @@ VIDEO_WIDTH=640
VIDEO_HEIGHT=360
VIDEO_FPS=24
# Потолок битрейта кодировщика, кбит/с. 0 — оставить выбор LiveKit (он
# осторожен). Ориентиры: 360p ≈ 800, 480p ≈ 1500, 720p ≈ 2500-3500.
VIDEO_BITRATE_KBPS=0
# Кодек исходящей дорожки: auto (обычно VP8), vp8, h264, vp9, av1.
# h264 дешевле для процессора, vp9/av1 дают лучшую картинку на том же
# битрейте, но кодируются заметно дороже.
VIDEO_CODEC=auto
# Память под кадры, ожидающие своей секунды звука, МБ. Кадр 720p весит
# 1.4 МБ, 360p — 0.35 МБ, а ждать приходится несколько секунд, поэтому при
# повышении разрешения это значение нужно поднимать.
VIDEO_QUEUE_MB=64
# camera — картинка внутри плитки бота; screen — отдельной плиткой,
# как демонстрация экрана.
VIDEO_SOURCE=camera
+34
View File
@@ -283,6 +283,40 @@ VIDEO_SYNC_OFFSET_MS=0
тянет, и снижайте `VIDEO_FPS`, если нагрузка мешает.
- Живые трансляции и не-YouTube источники играют звуком, как раньше.
### Как поднять качество
Три ручки, в порядке влияния на результат:
```dotenv
VIDEO_WIDTH=854
VIDEO_HEIGHT=480
VIDEO_FPS=30
VIDEO_BITRATE_KBPS=1500
VIDEO_QUEUE_MB=96
```
- **Размер кадра.** `VIDEO_WIDTH`/`VIDEO_HEIGHT` — рамка; исходник скачивается ровно под неё,
ближайшей стандартной ступенью, так что лишнего декодирования не будет.
- **Битрейт.** Сам по себе размер кадра резкости не даёт: при `0` LiveKit выбирает осторожное
значение, и 720p может выглядеть хуже, чем 480p с хорошим битрейтом. Ориентиры: 360p ≈ 800,
480p ≈ 1500, 720p ≈ 25003500 кбит/с.
- **Частота кадров.** 24 хватает для клипов, 30 заметно в динамике и стоит примерно на четверть
дороже по процессору.
Что важно понимать про цену: переход с 360p на 720p — это вчетверо больше работы и на
декодировании, и на кодировании, и вчетверо больше памяти под очередь кадров (`VIDEO_QUEUE_MB`:
кадр 720p весит 1.4 МБ, а ждать своей секунды звука ему приходится несколько секунд).
Поднимайте по одной ступени и смотрите на строку `video sync` в логе:
- `resyncs` растёт (больше одного-двух за трек) — сервер не успевает декодировать, шаг назад;
- `dropped` растёт при нулевых `resyncs` — не хватает `VIDEO_QUEUE_MB`;
- обе нули, `aheadBy` стабилен — запас есть, можно пробовать следующую ступень.
`VIDEO_CODEC` трогайте в последнюю очередь: `h264` разгружает процессор, `vp9` и `av1` дают
лучшую картинку на том же битрейте, но кодируются дороже — на слабой машине это обычно
проигрыш.
## Прокси, когда YouTube недоступен
`YTDLP_PROXY` пропускает через прокси **все** обращения yt-dlp — поиск, метаданные и сам
+6
View File
@@ -73,6 +73,12 @@ const schema = z.object({
VIDEO_WIDTH: z.coerce.number().int().positive().default(640),
VIDEO_HEIGHT: z.coerce.number().int().positive().default(360),
VIDEO_FPS: z.coerce.number().int().min(1).max(60).default(24),
/** Encoder ceiling in kbit/s; 0 leaves LiveKit's own default in place. */
VIDEO_BITRATE_KBPS: z.coerce.number().int().min(0).default(0),
/** Outgoing codec. "auto" lets LiveKit choose (usually VP8). */
VIDEO_CODEC: z.enum(["auto", "vp8", "h264", "vp9", "av1"]).default("auto"),
/** Memory for frames waiting on the audio clock; raise it with the resolution. */
VIDEO_QUEUE_MB: z.coerce.number().int().positive().default(64),
/** "camera" shows up inside the bot's tile, "screen" as a separate one. */
VIDEO_SOURCE: z.enum(["camera", "screen"]).default("camera"),
/** Nudge the picture against the sound, in milliseconds; positive delays video. */
+38 -6
View File
@@ -10,6 +10,10 @@ import { logger } from "../logger.js";
// 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");
// VideoEncoding is not re-exported by rtc-node, only by the bindings it wraps.
const bindings = require("@livekit/rtc-ffi-bindings") as {
VideoEncoding: new (init: { maxBitrate: bigint; maxFramerate: number }) => unknown;
};
const log = logger.child({ mod: "video" });
/** Give up on the video track if the source never produced a picture. */
@@ -20,11 +24,19 @@ const MAX_LATE_SECONDS = 0.25;
const REPORT_EVERY_MS = 10_000;
/**
* Frames wait in memory until the sound catches up, so the queue is bounded by
* bytes rather than count — 720p frames are four times the size of 360p ones.
* Never by pausing the stream: one ffmpeg feeds both outputs, so blocking the
* video pipe would also stop the audio whose clock we are waiting for.
* bytes rather than count — a 720p frame is four times the size of a 360p one,
* and the wait is several seconds of the audio path's own latency. Never bounded
* by pausing the stream: one ffmpeg feeds both outputs, so blocking the video
* pipe would also stop the audio whose clock we are waiting for.
*/
const MAX_QUEUE_BYTES = 48 * 1024 * 1024;
const QUEUE_BYTES = () => config.VIDEO_QUEUE_MB * 1024 * 1024;
const CODECS: Record<string, number | undefined> = {
vp8: rtc.VideoCodec.VP8,
h264: rtc.VideoCodec.H264,
vp9: rtc.VideoCodec.VP9,
av1: rtc.VideoCodec.AV1,
};
export interface VideoPublisherOptions {
width: number;
@@ -76,7 +88,7 @@ export class VideoPublisher {
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);
this.maxQueuedFrames = Math.max(12, Math.floor(MAX_QUEUE_BYTES / this.frameSize));
this.maxQueuedFrames = Math.max(12, Math.floor(QUEUE_BYTES() / this.frameSize));
}
get isPublishing(): boolean {
@@ -95,6 +107,17 @@ export class VideoPublisher {
const options = new rtc.TrackPublishOptions();
options.source = asScreen ? rtc.TrackSource.SOURCE_SCREENSHARE : rtc.TrackSource.SOURCE_CAMERA;
// Left alone, LiveKit picks a conservative bitrate for the frame size; the
// ceiling is what actually decides how sharp the picture looks.
if (config.VIDEO_BITRATE_KBPS > 0) {
options.videoEncoding = new bindings.VideoEncoding({
maxBitrate: BigInt(config.VIDEO_BITRATE_KBPS * 1000),
maxFramerate: this.options.fps,
}) as typeof options.videoEncoding;
}
const codec = CODECS[config.VIDEO_CODEC];
if (codec !== undefined) options.videoCodec = codec;
const participant = room.localParticipant;
if (!participant) throw new Error("room has no local participant yet");
const publication = await participant.publishTrack(track, options);
@@ -124,7 +147,16 @@ export class VideoPublisher {
this.firstFrameTimer.unref?.();
log.info(
{ width, height, source: config.VIDEO_SOURCE, sid: publication.sid },
{
width,
height,
fps: this.options.fps,
source: config.VIDEO_SOURCE,
codec: config.VIDEO_CODEC,
bitrateKbps: config.VIDEO_BITRATE_KBPS || "auto",
queueFrames: this.maxQueuedFrames,
sid: publication.sid,
},
"video track published",
);
}