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) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 16:38:04 +05:00
parent 5828873910
commit c876a46e61
5 changed files with 90 additions and 33 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.118 0.0.119
+5 -2
View File
@@ -12,7 +12,7 @@ from ..config import Camera, save_config
from ..runtime import runtime from ..runtime import runtime
from ..services import go2rtc from ..services import go2rtc
from ..transcode import (encode_args, norm_profile, norm_bitrate, norm_preset, 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"]) router = APIRouter(prefix="/api/cameras", tags=["cameras"])
@@ -86,6 +86,8 @@ async def camera_live_transcoded(
preset: str = Query(None), preset: str = Query(None),
keyint: str = Query(None), keyint: str = Query(None),
codec: str = Query(None), codec: str = Query(None),
rc: str = Query(None),
qp: str = Query(None),
stream: str = Query("sub"), stream: str = Query("sub"),
): ):
"""Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента. """Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента.
@@ -103,7 +105,8 @@ async def camera_live_transcoded(
"-fflags", "nobuffer", "-flags", "low_delay", "-fflags", "nobuffer", "-flags", "low_delay",
"-rtsp_transport", "tcp", "-i", src, "-rtsp_transport", "tcp", "-i", src,
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate), *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", "-an",
"-movflags", "frag_keyframe+empty_moov+default_base_moof", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4", "pipe:1", "-f", "mp4", "pipe:1",
+6 -2
View File
@@ -9,7 +9,8 @@ from fastapi.responses import FileResponse, StreamingResponse
from ..runtime import runtime from ..runtime import runtime
from ..transcode import (H264_PROFILES, BITRATES, encode_args, norm_profile, 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"]) router = APIRouter(prefix="/api/recordings", tags=["recordings"])
@@ -53,6 +54,8 @@ async def recording_transcoded(
preset: str = Query(None), preset: str = Query(None),
keyint: str = Query(None), keyint: str = Query(None),
codec: str = Query(None), codec: str = Query(None),
rc: str = Query(None),
qp: str = Query(None),
start: float = Query(0.0), start: float = Query(0.0),
): ):
"""Перекодирование H.265→H.264 на лету (fragmented mp4) — для браузеров без HEVC. """Перекодирование 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 []), *(["-ss", f"{start:.3f}"] if start and start > 0 else []),
"-i", rec.path, "-i", rec.path,
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate), *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", "-an",
"-movflags", "frag_keyframe+empty_moov+default_base_moof", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4", "pipe:1", "-f", "mp4", "pipe:1",
+63 -22
View File
@@ -1,17 +1,25 @@
"""Общие параметры перекодирования H.264 (профиль, битрейт, пресет, GOP).""" """Общие параметры перекодирования H.264/VP9: профиль, битрейт, пресет, GOP, режим RC."""
from __future__ import annotations from __future__ import annotations
H264_PROFILES = ("baseline", "main", "high") H264_PROFILES = ("baseline", "main", "high")
BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k") BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k")
PRESETS = ("ultrafast", "superfast") PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium")
KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды
CODECS = ("h264", "vp9") CODECS = ("h264", "vp9")
RC_MODES = ("vbr", "cbr", "qp") # контроль битрейта: переменный / постоянный / постоянный квантизатор
DEFAULT_PROFILE = "main" DEFAULT_PROFILE = "main"
DEFAULT_BITRATE = "2000k" DEFAULT_BITRATE = "2000k"
DEFAULT_PRESET = "superfast" DEFAULT_PRESET = "superfast"
DEFAULT_KEYINT = 2 DEFAULT_KEYINT = 2
DEFAULT_CODEC = "h264" 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: 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 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: def norm_keyint(k) -> int:
try: try:
k = int(k) k = int(k)
@@ -38,51 +50,80 @@ def norm_keyint(k) -> int:
return k if k in KEYINTS else DEFAULT_KEYINT return k if k in KEYINTS else DEFAULT_KEYINT
def x264_args(profile: str, bitrate: str, low_latency: bool = False, def norm_qp(q) -> int:
preset: str | None = None, keyint_sec=None) -> list[str]: try:
"""Аргументы libx264: профиль, битрейт, пресет и интервал ключевых кадров. q = int(q)
except (TypeError, ValueError):
return DEFAULT_QP
return max(QP_MIN, min(QP_MAX, q))
Ключевые кадры расставляются по времени (через -force_key_frames), поэтому
интервал в секундах соблюдается независимо от fps камеры.""" def _kbps(bitrate: str) -> int:
bufsize = f"{int(bitrate[:-1]) * 2}k" 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) preset = norm_preset(preset)
sec = norm_keyint(keyint_sec) sec = norm_keyint(keyint_sec)
rc = norm_rc(rc)
args = [ args = [
"-c:v", "libx264", "-c:v", "libx264",
"-preset", preset, "-preset", preset,
"-profile:v", profile, "-profile:v", profile,
"-b:v", bitrate,
"-maxrate", bitrate,
"-bufsize", bufsize,
"-force_key_frames", f"expr:gte(t,n_forced*{sec})", "-force_key_frames", f"expr:gte(t,n_forced*{sec})",
"-pix_fmt", "yuv420p", "-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: if low_latency:
args += ["-tune", "zerolatency"] args += ["-tune", "zerolatency"]
return args return args
def vp9_args(bitrate: str, low_latency: bool = False, def vp9_args(bitrate: str, low_latency: bool = False,
preset: str | None = None, keyint_sec=None) -> list[str]: preset: str | None = None, keyint_sec=None,
"""Аргументы кодировщика libvpx-VP9 (профиль H.264 неприменим).""" 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) sec = norm_keyint(keyint_sec)
# быстрый пресет → выше cpu-used (быстрее, ниже качество); realtime для live rc = norm_rc(rc)
cpu = "8" if norm_preset(preset) == "ultrafast" else "6" args = [
return [
"-c:v", "libvpx-vp9", "-c:v", "libvpx-vp9",
"-b:v", bitrate,
"-maxrate", bitrate,
"-deadline", "realtime", "-deadline", "realtime",
"-cpu-used", cpu, "-cpu-used", _VP9_CPU.get(norm_preset(preset), "6"),
"-row-mt", "1", "-row-mt", "1",
"-force_key_frames", f"expr:gte(t,n_forced*{sec})", "-force_key_frames", f"expr:gte(t,n_forced*{sec})",
"-pix_fmt", "yuv420p", "-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, 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).""" """Аргументы видеокодировщика под выбранный кодек (h264 | vp9)."""
if norm_codec(codec) == "vp9": if norm_codec(codec) == "vp9":
return vp9_args(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) return x264_args(profile, bitrate, low_latency, preset, keyint_sec, rc, qp)
+15 -6
View File
@@ -12,7 +12,7 @@ function post(path) { return fetch(path, { method: "POST" }); }
// ── клиентские настройки (этот браузер), localStorage ─────────── // ── клиентские настройки (этот браузер), localStorage ───────────
const PREFS_KEY = "corevision.prefs"; const PREFS_KEY = "corevision.prefs";
// mode: "player" — свой плеер (клиентский WASM-декод H.265), "transcode" — серверный транскод // 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 PROF_KEY = { "1": "prof1", "2": "prof2", "a": "profA" }; // 1 поток / 2 поток / архив
// общие пресеты перекодирования — выставляют все параметры профиля разом // общие пресеты перекодирования — выставляют все параметры профиля разом
const TR_PRESETS = { const TR_PRESETS = {
@@ -65,7 +65,7 @@ function setProfilePref(which, patch) {
function transcodeQueryFor(which) { // строка запроса под профиль function transcodeQueryFor(which) { // строка запроса под профиль
const e = encodeURIComponent, pr = transcodeProfile(which); const e = encodeURIComponent, pr = transcodeProfile(which);
return `profile=${e(pr.profile)}&bitrate=${e(pr.bitrate)}&preset=${e(pr.preset)}` + 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" function profileMode(which) { // "native" | "player" | "webcodecs" | "transcode"
const m = transcodeProfile(which).mode; const m = transcodeProfile(which).mode;
@@ -1461,9 +1461,11 @@ const Settings = {
presets + presets +
`<label>Кодек${sel("codec", [["h264", "H.264"], ["vp9", "VP9"]])}</label>` + `<label>Кодек${sel("codec", [["h264", "H.264"], ["vp9", "VP9"]])}</label>` +
`<label>Профиль H.264${sel("profile", [["baseline", "baseline"], ["main", "main"], ["high", "high"]])}</label>` + `<label>Профиль H.264${sel("profile", [["baseline", "baseline"], ["main", "main"], ["high", "high"]])}</label>` +
`<label>Контроль битрейта${sel("rc", [["vbr", "VBR (переменный)"], ["cbr", "CBR (постоянный)"], ["qp", "QP (постоянный)"]])}</label>` +
`<label>Битрейт${sel("bitrate", [["500k", "0,5 Мбит/с"], ["1000k", "1 Мбит/с"], ["2000k", "2 Мбит/с"], ["4000k", "4 Мбит/с"], ["8000k", "8 Мбит/с"], ["10000k", "10 Мбит/с"], ["16000k", "16 Мбит/с"], ["20000k", "20 Мбит/с"], ["24000k", "24 Мбит/с"]])}</label>` + `<label>Битрейт${sel("bitrate", [["500k", "0,5 Мбит/с"], ["1000k", "1 Мбит/с"], ["2000k", "2 Мбит/с"], ["4000k", "4 Мбит/с"], ["8000k", "8 Мбит/с"], ["10000k", "10 Мбит/с"], ["16000k", "16 Мбит/с"], ["20000k", "20 Мбит/с"], ["24000k", "24 Мбит/с"]])}</label>` +
`<label>QP (квантизатор)<input type="number" data-f="qp" min="0" max="51" value="${pr.qp}" style="width:72px"></label>` +
`<label>Интервал ключевых кадров${sel("keyint", [[1, "1 секунда"], [2, "2 секунды"], [5, "5 секунд"]])}</label>` + `<label>Интервал ключевых кадров${sel("keyint", [[1, "1 секунда"], [2, "2 секунды"], [5, "5 секунд"]])}</label>` +
`<label>Пресет (Preset)${sel("preset", [["ultrafast", "Ultrafast"], ["superfast", "Superfast"]])}</label>` `<label>Пресет (Preset)${sel("preset", [["ultrafast", "Ultrafast"], ["superfast", "Superfast"], ["veryfast", "Veryfast"], ["faster", "Faster"], ["fast", "Fast"], ["medium", "Medium"]])}</label>`
); );
}, },
@@ -1492,6 +1494,12 @@ const Settings = {
const prof = row.querySelector('[data-f="profile"]'); const prof = row.querySelector('[data-f="profile"]');
const syncCodec = () => { prof.disabled = codec.value === "vp9"; }; const syncCodec = () => { prof.disabled = codec.value === "vp9"; };
syncCodec(); 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 highlight = () => {
const cur = { codec: codec.value, profile: prof.value, bitrate: row.querySelector('[data-f="bitrate"]').value, 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 p = TR_PRESETS[btn.dataset.preset];
const put = (f, v) => { const s = row.querySelector(`[data-f="${f}"]`); if (s) s.value = String(v); }; 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("codec", p.codec); put("profile", p.profile); put("bitrate", p.bitrate);
put("keyint", p.keyint); put("preset", p.preset); put("keyint", p.keyint); put("preset", p.preset); put("rc", "vbr");
syncCodec(); highlight(); syncCodec(); syncRc(); highlight();
}; };
}); });
row.querySelectorAll("select[data-f]").forEach((s) => { row.querySelectorAll("select[data-f]").forEach((s) => {
s.addEventListener("change", () => { syncCodec(); highlight(); }); s.addEventListener("change", () => { syncCodec(); syncRc(); highlight(); });
}); });
}); });
document.getElementById("prefs-save").onclick = () => { document.getElementById("prefs-save").onclick = () => {
@@ -1524,6 +1532,7 @@ const Settings = {
mode: sel ? sel.value : "player", mode: sel ? sel.value : "player",
codec: get("codec"), profile: get("profile"), bitrate: get("bitrate"), codec: get("codec"), profile: get("profile"), bitrate: get("bitrate"),
preset: get("preset"), keyint: +get("keyint"), preset: get("preset"), keyint: +get("keyint"),
rc: get("rc"), qp: +get("qp"),
}); });
}); });
saved.textContent = "Сохранено в этом браузере ✓"; saved.textContent = "Сохранено в этом браузере ✓";