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", ); }