From a1596cd6546c5dde685906acd4a89ce68d6cab53 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 9 Sep 2026 05:14:38 +0300 Subject: [PATCH] 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 --- .env.example | 14 ++++++++++++++ README.md | 34 ++++++++++++++++++++++++++++++++++ src/config.ts | 6 ++++++ src/core/video.ts | 44 ++++++++++++++++++++++++++++++++++++++------ 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 2bde08c..9ed4975 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 89ad758..09872f9 100644 --- a/README.md +++ b/README.md @@ -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 ≈ 2500–3500 кбит/с. +- **Частота кадров.** 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 — поиск, метаданные и сам diff --git a/src/config.ts b/src/config.ts index e78f67c..caa1d8b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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. */ diff --git a/src/core/video.ts b/src/core/video.ts index dcc2940..9343c23 100644 --- a/src/core/video.ts +++ b/src/core/video.ts @@ -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 = { + 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", ); }