Files
CoRE.Vision/backend/app/api/recordings.py
T
git_admin c876a46e61 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>
2026-06-01 16:38:04 +05:00

104 lines
4.0 KiB
Python

"""REST: архив — список сегментов, отдача mp4 (Range), перекодирование H.265→H.264."""
from __future__ import annotations
import asyncio
import os
from fastapi import APIRouter, HTTPException, Query
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_rc, norm_qp)
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
@router.get("")
async def list_recordings(
camera: str | None = Query(None),
ts_from: int | None = Query(None, alias="from"),
ts_to: int | None = Query(None, alias="to"),
) -> dict:
recs = await runtime.db.list_recordings(camera, ts_from, ts_to)
return {
"recordings": [
{
"id": r.id,
"camera_id": r.camera_id,
"started_at": r.started_at,
"ended_at": r.ended_at,
"duration_s": r.duration_s,
"size_bytes": r.size_bytes,
}
for r in recs
]
}
@router.get("/{rec_id}/file")
async def recording_file(rec_id: int) -> FileResponse:
"""Отдаёт mp4 как есть (H.265). Range поддерживается FileResponse."""
rec = await runtime.db.get_recording(rec_id)
if not rec or not os.path.exists(rec.path):
raise HTTPException(404, "recording not found")
return FileResponse(rec.path, media_type="video/mp4", filename=os.path.basename(rec.path))
@router.get("/{rec_id}/play.mp4")
async def recording_transcoded(
rec_id: int,
profile: str = Query(None),
bitrate: str = Query(None),
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.
Профиль и битрейт задаёт клиент (настройка в браузере).
start — секунда, с которой начать перекодирование (перемотка): транскод
из пайпа не seekable, поэтому seek реализуется перезапуском с -ss."""
rec = await runtime.db.get_recording(rec_id)
if not rec or not os.path.exists(rec.path):
raise HTTPException(404, "recording not found")
args = [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
# -ss ДО -i — быстрый input-seek к ближайшему ключевому кадру ≤ start,
# выходные PTS обнуляются → новый поток идёт с 0 (плеер ведёт виртуальный таймлайн)
*(["-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),
rc=norm_rc(rc), qp=norm_qp(qp)),
"-an",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4", "pipe:1",
]
proc = await asyncio.create_subprocess_exec(
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
async def stream():
assert proc.stdout is not None
try:
while True:
chunk = await proc.stdout.read(64 * 1024)
if not chunk:
break
yield chunk
finally:
if proc.returncode is None:
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
return StreamingResponse(stream(), media_type="video/mp4")