c876a46e61
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>
104 lines
4.0 KiB
Python
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")
|