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 = "Сохранено в этом браузере ✓";