From c876a46e61a69a4d125343416776b2c6acd98333 Mon Sep 17 00:00:00 2001 From: core Date: Mon, 1 Jun 2026 16:38:04 +0500 Subject: [PATCH] transcode: add CBR/VBR/QP rate control + more x264 presets Player/transcoding settings gain encoder controls: - Rate-control mode VBR / CBR / QP (constant quantizer). x264: VBR uses -b:v/-maxrate/-bufsize, CBR adds -x264-params nal-hrd=cbr (min=max=b:v), QP uses -qp N (no bitrate). VP9 maps QP -> -crf N -b:v 0, CBR -> min=max=b:v. - Preset list expanded to ultrafast/superfast/veryfast/faster/fast/medium (was only ultrafast/superfast); for VP9 the preset maps to cpu-used. - New QP field in the UI, shown when rate control = QP, otherwise the bitrate field is shown. Both /play.mp4 (archive) and /live.mp4 accept rc + qp query params. All three modes verified producing valid output on the live restream. VERSION -> 0.0.119. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- backend/app/api/cameras.py | 7 ++- backend/app/api/recordings.py | 8 +++- backend/app/transcode.py | 85 ++++++++++++++++++++++++++--------- frontend/static/app.js | 21 ++++++--- 5 files changed, 90 insertions(+), 33 deletions(-) diff --git a/VERSION b/VERSION index 424a70f..42e3846 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.118 +0.0.119 diff --git a/backend/app/api/cameras.py b/backend/app/api/cameras.py index 3f960d8..8a74543 100644 --- a/backend/app/api/cameras.py +++ b/backend/app/api/cameras.py @@ -12,7 +12,7 @@ from ..config import Camera, save_config from ..runtime import runtime from ..services import go2rtc from ..transcode import (encode_args, norm_profile, norm_bitrate, norm_preset, - norm_keyint, norm_codec) + norm_keyint, norm_codec, norm_rc, norm_qp) router = APIRouter(prefix="/api/cameras", tags=["cameras"]) @@ -86,6 +86,8 @@ async def camera_live_transcoded( preset: str = Query(None), keyint: str = Query(None), codec: str = Query(None), + rc: str = Query(None), + qp: str = Query(None), stream: str = Query("sub"), ): """Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента. @@ -103,7 +105,8 @@ async def camera_live_transcoded( "-fflags", "nobuffer", "-flags", "low_delay", "-rtsp_transport", "tcp", "-i", src, *encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate), - low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)), + low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint), + rc=norm_rc(rc), qp=norm_qp(qp)), "-an", "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1", diff --git a/backend/app/api/recordings.py b/backend/app/api/recordings.py index 97d339b..3799a18 100644 --- a/backend/app/api/recordings.py +++ b/backend/app/api/recordings.py @@ -9,7 +9,8 @@ from fastapi.responses import FileResponse, StreamingResponse from ..runtime import runtime from ..transcode import (H264_PROFILES, BITRATES, encode_args, norm_profile, - norm_bitrate, norm_preset, norm_keyint, norm_codec) + norm_bitrate, norm_preset, norm_keyint, norm_codec, + norm_rc, norm_qp) router = APIRouter(prefix="/api/recordings", tags=["recordings"]) @@ -53,6 +54,8 @@ async def recording_transcoded( preset: str = Query(None), keyint: str = Query(None), codec: str = Query(None), + rc: str = Query(None), + qp: str = Query(None), start: float = Query(0.0), ): """Перекодирование H.265→H.264 на лету (fragmented mp4) — для браузеров без HEVC. @@ -71,7 +74,8 @@ async def recording_transcoded( *(["-ss", f"{start:.3f}"] if start and start > 0 else []), "-i", rec.path, *encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate), - preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)), + preset=norm_preset(preset), keyint_sec=norm_keyint(keyint), + rc=norm_rc(rc), qp=norm_qp(qp)), "-an", "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1", diff --git a/backend/app/transcode.py b/backend/app/transcode.py index 22ced97..4efe599 100644 --- a/backend/app/transcode.py +++ b/backend/app/transcode.py @@ -1,17 +1,25 @@ -"""Общие параметры перекодирования H.264 (профиль, битрейт, пресет, GOP).""" +"""Общие параметры перекодирования H.264/VP9: профиль, битрейт, пресет, GOP, режим RC.""" from __future__ import annotations H264_PROFILES = ("baseline", "main", "high") BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k") -PRESETS = ("ultrafast", "superfast") +PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium") KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды CODECS = ("h264", "vp9") +RC_MODES = ("vbr", "cbr", "qp") # контроль битрейта: переменный / постоянный / постоянный квантизатор DEFAULT_PROFILE = "main" DEFAULT_BITRATE = "2000k" DEFAULT_PRESET = "superfast" DEFAULT_KEYINT = 2 DEFAULT_CODEC = "h264" +DEFAULT_RC = "vbr" +DEFAULT_QP = 23 +QP_MIN, QP_MAX = 0, 51 + +# пресет x264 → cpu-used VP9 (больше число = быстрее кодирование, ниже качество) +_VP9_CPU = {"ultrafast": "8", "superfast": "8", "veryfast": "7", + "faster": "6", "fast": "5", "medium": "4"} def norm_profile(p: str | None) -> str: @@ -30,6 +38,10 @@ def norm_preset(p: str | None) -> str: return p if p in PRESETS else DEFAULT_PRESET +def norm_rc(m: str | None) -> str: + return m if m in RC_MODES else DEFAULT_RC + + def norm_keyint(k) -> int: try: k = int(k) @@ -38,51 +50,80 @@ def norm_keyint(k) -> int: return k if k in KEYINTS else DEFAULT_KEYINT -def x264_args(profile: str, bitrate: str, low_latency: bool = False, - preset: str | None = None, keyint_sec=None) -> list[str]: - """Аргументы libx264: профиль, битрейт, пресет и интервал ключевых кадров. +def norm_qp(q) -> int: + try: + q = int(q) + except (TypeError, ValueError): + return DEFAULT_QP + return max(QP_MIN, min(QP_MAX, q)) - Ключевые кадры расставляются по времени (через -force_key_frames), поэтому - интервал в секундах соблюдается независимо от fps камеры.""" - bufsize = f"{int(bitrate[:-1]) * 2}k" + +def _kbps(bitrate: str) -> int: + return int(bitrate[:-1]) + + +def x264_args(profile: str, bitrate: str, low_latency: bool = False, + preset: str | None = None, keyint_sec=None, + rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]: + """Аргументы libx264: профиль, пресет, GOP и режим контроля битрейта. + + rc: vbr — целевой битрейт с потолком (-b:v/-maxrate); cbr — постоянный + битрейт (nal-hrd=cbr, min=max=b:v); qp — постоянный квантизатор (-qp, без + битрейта). Ключевые кадры расставляются по времени (-force_key_frames).""" preset = norm_preset(preset) sec = norm_keyint(keyint_sec) + rc = norm_rc(rc) args = [ "-c:v", "libx264", "-preset", preset, "-profile:v", profile, - "-b:v", bitrate, - "-maxrate", bitrate, - "-bufsize", bufsize, "-force_key_frames", f"expr:gte(t,n_forced*{sec})", "-pix_fmt", "yuv420p", ] + if rc == "qp": + args += ["-qp", str(norm_qp(qp))] + elif rc == "cbr": + kb = _kbps(bitrate) + args += ["-b:v", bitrate, "-minrate", bitrate, "-maxrate", bitrate, + "-bufsize", f"{kb}k", "-x264-params", "nal-hrd=cbr"] + else: # vbr + kb = _kbps(bitrate) + args += ["-b:v", bitrate, "-maxrate", bitrate, "-bufsize", f"{kb * 2}k"] if low_latency: args += ["-tune", "zerolatency"] return args def vp9_args(bitrate: str, low_latency: bool = False, - preset: str | None = None, keyint_sec=None) -> list[str]: - """Аргументы кодировщика libvpx-VP9 (профиль H.264 неприменим).""" + preset: str | None = None, keyint_sec=None, + rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]: + """Аргументы libvpx-VP9 (профиль H.264 неприменим). + + rc: vbr/cbr — по битрейту; qp — постоянное качество (-crf N -b:v 0, у VP9 + нет прямого -qp). cpu-used маппится из пресета. realtime для live.""" sec = norm_keyint(keyint_sec) - # быстрый пресет → выше cpu-used (быстрее, ниже качество); realtime для live - cpu = "8" if norm_preset(preset) == "ultrafast" else "6" - return [ + rc = norm_rc(rc) + args = [ "-c:v", "libvpx-vp9", - "-b:v", bitrate, - "-maxrate", bitrate, "-deadline", "realtime", - "-cpu-used", cpu, + "-cpu-used", _VP9_CPU.get(norm_preset(preset), "6"), "-row-mt", "1", "-force_key_frames", f"expr:gte(t,n_forced*{sec})", "-pix_fmt", "yuv420p", ] + if rc == "qp": + args += ["-crf", str(norm_qp(qp)), "-b:v", "0"] + elif rc == "cbr": + args += ["-b:v", bitrate, "-minrate", bitrate, "-maxrate", bitrate] + else: # vbr + args += ["-b:v", bitrate, "-maxrate", bitrate] + return args def encode_args(codec: str, profile: str, bitrate: str, low_latency: bool = False, - preset: str | None = None, keyint_sec=None) -> list[str]: + preset: str | None = None, keyint_sec=None, + rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]: """Аргументы видеокодировщика под выбранный кодек (h264 | vp9).""" if norm_codec(codec) == "vp9": - return vp9_args(bitrate, low_latency, preset, keyint_sec) - return x264_args(profile, bitrate, low_latency, preset, keyint_sec) + return vp9_args(bitrate, low_latency, preset, keyint_sec, rc, qp) + return x264_args(profile, bitrate, low_latency, preset, keyint_sec, rc, qp) diff --git a/frontend/static/app.js b/frontend/static/app.js index 36a5e30..1e902e0 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -12,7 +12,7 @@ function post(path) { return fetch(path, { method: "POST" }); } // ── клиентские настройки (этот браузер), localStorage ─────────── const PREFS_KEY = "corevision.prefs"; // mode: "player" — свой плеер (клиентский WASM-декод H.265), "transcode" — серверный транскод -const PROFILE_DEFAULT = { mode: "player", codec: "h264", profile: "main", bitrate: "2000k", preset: "superfast", keyint: 2 }; +const PROFILE_DEFAULT = { mode: "player", codec: "h264", profile: "main", bitrate: "2000k", preset: "superfast", keyint: 2, rc: "vbr", qp: 23 }; const PROF_KEY = { "1": "prof1", "2": "prof2", "a": "profA" }; // 1 поток / 2 поток / архив // общие пресеты перекодирования — выставляют все параметры профиля разом const TR_PRESETS = { @@ -65,7 +65,7 @@ function setProfilePref(which, patch) { function transcodeQueryFor(which) { // строка запроса под профиль const e = encodeURIComponent, pr = transcodeProfile(which); return `profile=${e(pr.profile)}&bitrate=${e(pr.bitrate)}&preset=${e(pr.preset)}` + - `&keyint=${e(pr.keyint)}&codec=${e(pr.codec)}`; + `&keyint=${e(pr.keyint)}&codec=${e(pr.codec)}&rc=${e(pr.rc)}&qp=${e(pr.qp)}`; } function profileMode(which) { // "native" | "player" | "webcodecs" | "transcode" const m = transcodeProfile(which).mode; @@ -1461,9 +1461,11 @@ const Settings = { presets + `` + `` + + `` + `` + + `` + `` + - `` + `` ); }, @@ -1492,6 +1494,12 @@ const Settings = { const prof = row.querySelector('[data-f="profile"]'); const syncCodec = () => { prof.disabled = codec.value === "vp9"; }; syncCodec(); + // режим контроля битрейта: VBR/CBR → поле «Битрейт», QP → поле «QP» + const rcSel = row.querySelector('[data-f="rc"]'); + const brLabel = row.querySelector('[data-f="bitrate"]').closest("label"); + const qpLabel = row.querySelector('[data-f="qp"]').closest("label"); + const syncRc = () => { const isQp = rcSel.value === "qp"; qpLabel.hidden = !isQp; brLabel.hidden = isQp; }; + syncRc(); // подсветить пресет, совпадающий с текущими значениями const highlight = () => { const cur = { codec: codec.value, profile: prof.value, bitrate: row.querySelector('[data-f="bitrate"]').value, @@ -1505,12 +1513,12 @@ const Settings = { const p = TR_PRESETS[btn.dataset.preset]; const put = (f, v) => { const s = row.querySelector(`[data-f="${f}"]`); if (s) s.value = String(v); }; put("codec", p.codec); put("profile", p.profile); put("bitrate", p.bitrate); - put("keyint", p.keyint); put("preset", p.preset); - syncCodec(); highlight(); + put("keyint", p.keyint); put("preset", p.preset); put("rc", "vbr"); + syncCodec(); syncRc(); highlight(); }; }); row.querySelectorAll("select[data-f]").forEach((s) => { - s.addEventListener("change", () => { syncCodec(); highlight(); }); + s.addEventListener("change", () => { syncCodec(); syncRc(); highlight(); }); }); }); document.getElementById("prefs-save").onclick = () => { @@ -1524,6 +1532,7 @@ const Settings = { mode: sel ? sel.value : "player", codec: get("codec"), profile: get("profile"), bitrate: get("bitrate"), preset: get("preset"), keyint: +get("keyint"), + rc: get("rc"), qp: +get("qp"), }); }); saved.textContent = "Сохранено в этом браузере ✓";