init
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"auth": {
|
||||
"username": "admin",
|
||||
"password": "admin"
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"role": "superadmin"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
"single_main_stream": true
|
||||
},
|
||||
"ui": {
|
||||
"live_transcode": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
DESIGN.md
|
||||
README.md
|
||||
cameras.yaml
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# ffmpeg/ffprobe — движок записи и перекодирования
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY frontend ./frontend
|
||||
|
||||
EXPOSE 8090
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""REST: список камер, статус, live-источники, перезапуск, логи + CRUD."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..auth import require_role, auth_manager, COOKIE_NAME
|
||||
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)
|
||||
|
||||
router = APIRouter(prefix="/api/cameras", tags=["cameras"])
|
||||
|
||||
ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||
DEFAULT_MAIN = "/Streaming/Channels/101"
|
||||
DEFAULT_SUB = "/Streaming/Channels/102"
|
||||
|
||||
|
||||
def _camera_from_body(body: dict, cam_id: str) -> Camera:
|
||||
ip = (body.get("ip") or "").strip()
|
||||
user = (body.get("user") or "").strip()
|
||||
password = body.get("password") or ""
|
||||
if not ip or not user:
|
||||
raise HTTPException(400, "обязательны поля ip и user")
|
||||
sub = (body.get("sub_path") or "").strip() or None
|
||||
return Camera(
|
||||
id=cam_id,
|
||||
name=(body.get("name") or cam_id).strip(),
|
||||
enabled=bool(body.get("enabled", True)),
|
||||
ip=ip,
|
||||
user=user,
|
||||
password=password,
|
||||
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
|
||||
sub_path=sub,
|
||||
)
|
||||
|
||||
|
||||
def _camera_payload(cam_id: str) -> dict:
|
||||
cfg = runtime.config
|
||||
cam = cfg.camera(cam_id)
|
||||
status = runtime.supervisor.status(cam_id)
|
||||
has_sub = bool(cam and cam.sub_path)
|
||||
return {
|
||||
"id": cam.id,
|
||||
"name": cam.name,
|
||||
"enabled": cam.enabled,
|
||||
"ip": cam.ip,
|
||||
"user": cam.user,
|
||||
"password": cam.password,
|
||||
"main_path": cam.main_path,
|
||||
"sub_path": cam.sub_path,
|
||||
"has_sub": has_sub,
|
||||
"status": status.to_dict() if status else None,
|
||||
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
|
||||
"live": {
|
||||
"go2rtc_url": cfg.live.go2rtc_url,
|
||||
"src": cam.id,
|
||||
"src_h264": f"{cam.id}_h264",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_cameras() -> dict:
|
||||
cfg = runtime.config
|
||||
return {"cameras": [_camera_payload(c.id) for c in cfg.cameras]}
|
||||
|
||||
|
||||
@router.get("/{cam_id}")
|
||||
async def get_camera(cam_id: str) -> dict:
|
||||
if not runtime.config.camera(cam_id):
|
||||
raise HTTPException(404, "camera not found")
|
||||
return _camera_payload(cam_id)
|
||||
|
||||
|
||||
@router.get("/{cam_id}/live.mp4")
|
||||
async def camera_live_transcoded(
|
||||
cam_id: str,
|
||||
profile: str = Query(None),
|
||||
bitrate: str = Query(None),
|
||||
preset: str = Query(None),
|
||||
keyint: str = Query(None),
|
||||
codec: str = Query(None),
|
||||
stream: str = Query("sub"),
|
||||
):
|
||||
"""Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента.
|
||||
|
||||
stream=main — основной поток (высокое разрешение, для fullscreen),
|
||||
иначе субпоток. Открывает отдельную RTSP-сессию на каждого зрителя."""
|
||||
cam = runtime.config.camera(cam_id)
|
||||
if not cam:
|
||||
raise HTTPException(404, "camera not found")
|
||||
# источник — рестрим go2rtc (камера держит одну сессию: и запись, и live из go2rtc)
|
||||
src = (go2rtc.restream_main_url(runtime.config, cam_id) if stream == "main"
|
||||
else go2rtc.restream_sub_url(runtime.config, cam_id))
|
||||
args = [
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
|
||||
"-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)),
|
||||
"-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")
|
||||
|
||||
|
||||
@router.get("/{cam_id}/live.flv")
|
||||
async def camera_live_flv(cam_id: str, stream: str = Query("sub")):
|
||||
"""Live H.265 в FLV по HTTP (ffmpeg -c copy, БЕЗ перекодирования, 0% CPU).
|
||||
|
||||
Для плееров с http-flv (h265web.js): URL оканчивается на .flv, плеер
|
||||
сам определяет формат. Источник — рестрим go2rtc."""
|
||||
cam = runtime.config.camera(cam_id)
|
||||
if not cam:
|
||||
raise HTTPException(404, "camera not found")
|
||||
src = (go2rtc.restream_main_url(runtime.config, cam_id) if stream == "main"
|
||||
else go2rtc.restream_sub_url(runtime.config, cam_id))
|
||||
args = [
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
|
||||
"-fflags", "nobuffer", "-flags", "low_delay",
|
||||
"-rtsp_transport", "tcp", "-i", src,
|
||||
"-an", "-c:v", "copy",
|
||||
"-f", "flv", "pipe:1",
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
async def gen():
|
||||
assert proc.stdout is not None
|
||||
try:
|
||||
while True:
|
||||
chunk = await proc.stdout.read(32 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await proc.wait()
|
||||
|
||||
return StreamingResponse(gen(), media_type="video/x-flv")
|
||||
|
||||
|
||||
@router.websocket("/{cam_id}/flv")
|
||||
async def camera_flv(ws: WebSocket, cam_id: str, stream: str = Query("sub")):
|
||||
"""Поток H.265 в FLV (ffmpeg -c copy, БЕЗ перекодирования) по WebSocket.
|
||||
|
||||
Источник — рестрим go2rtc; в браузере его декодирует свой плеер (jessibuca,
|
||||
WASM). Так H.265 играет даже в браузерах без аппаратного HEVC."""
|
||||
if not auth_manager.validate(ws.cookies.get(COOKIE_NAME)):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
cam = runtime.config.camera(cam_id)
|
||||
if not cam:
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
await ws.accept()
|
||||
src = (go2rtc.restream_main_url(runtime.config, cam_id) if stream == "main"
|
||||
else go2rtc.restream_sub_url(runtime.config, cam_id))
|
||||
args = [
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
|
||||
"-fflags", "nobuffer", "-flags", "low_delay",
|
||||
"-rtsp_transport", "tcp", "-i", src,
|
||||
"-an", "-c:v", "copy",
|
||||
"-f", "flv", "pipe:1",
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
while True:
|
||||
chunk = await proc.stdout.read(32 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
await ws.send_bytes(chunk)
|
||||
except (WebSocketDisconnect, RuntimeError, ConnectionError):
|
||||
pass
|
||||
finally:
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await proc.wait()
|
||||
try:
|
||||
await ws.close()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/{cam_id}/log")
|
||||
async def camera_log(cam_id: str) -> dict:
|
||||
if not runtime.config.camera(cam_id):
|
||||
raise HTTPException(404, "camera not found")
|
||||
return {"camera_id": cam_id, "log": runtime.supervisor.log_tail(cam_id)}
|
||||
|
||||
|
||||
@router.post("/{cam_id}/restart")
|
||||
async def restart_camera(cam_id: str, request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
ok = await runtime.supervisor.restart(cam_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "camera not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── CRUD ────────────────────────────────────────────────────────
|
||||
@router.post("")
|
||||
async def create_camera(request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
body = await request.json()
|
||||
cam_id = (body.get("id") or "").strip()
|
||||
if not ID_RE.match(cam_id):
|
||||
raise HTTPException(400, "id: разрешены латиница, цифры, _ и - (до 32 символов)")
|
||||
if runtime.config.camera(cam_id):
|
||||
raise HTTPException(409, "камера с таким id уже существует")
|
||||
|
||||
cam = _camera_from_body(body, cam_id)
|
||||
runtime.config.cameras.append(cam)
|
||||
save_config(runtime.config)
|
||||
runtime.supervisor.add_camera(cam)
|
||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||
return _camera_payload(cam_id)
|
||||
|
||||
|
||||
@router.put("/{cam_id}")
|
||||
async def update_camera(cam_id: str, request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
if not runtime.config.camera(cam_id):
|
||||
raise HTTPException(404, "camera not found")
|
||||
body = await request.json()
|
||||
cam = _camera_from_body(body, cam_id) # id неизменяем (привязан к архиву)
|
||||
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
|
||||
runtime.config.cameras[idx] = cam
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.replace_camera(cam)
|
||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||
return _camera_payload(cam_id)
|
||||
|
||||
|
||||
@router.delete("/{cam_id}")
|
||||
async def delete_camera(cam_id: str, request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
if not runtime.config.camera(cam_id):
|
||||
raise HTTPException(404, "camera not found")
|
||||
runtime.config.cameras = [c for c in runtime.config.cameras if c.id != cam_id]
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.remove_camera(cam_id)
|
||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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)
|
||||
|
||||
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),
|
||||
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)),
|
||||
"-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")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""REST: системная статистика и сводный статус."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from ..auth import require_role
|
||||
from ..config import Storage, save_config
|
||||
from ..runtime import runtime
|
||||
from ..services.system import system_stats
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["system"])
|
||||
|
||||
ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут
|
||||
VERSION_FILE = os.environ.get("NVR_VERSION_FILE", "/app/VERSION")
|
||||
|
||||
|
||||
def app_version() -> str:
|
||||
try:
|
||||
with open(VERSION_FILE, "r", encoding="utf-8") as fh:
|
||||
return fh.read().strip()
|
||||
except OSError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
@router.get("/system")
|
||||
async def get_system() -> dict:
|
||||
stats = system_stats(runtime.config)
|
||||
stats["cameras"] = [s.to_dict() for s in runtime.supervisor.statuses()]
|
||||
stats["archive_size_bytes"] = await runtime.db.total_size()
|
||||
stats["version"] = app_version()
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/storage")
|
||||
async def update_storage(request: Request) -> dict:
|
||||
"""Смена размера сегмента записи (1/5/10/15 мин). Перезапускает рекордеры."""
|
||||
require_role(request, "admin")
|
||||
body = await request.json()
|
||||
try:
|
||||
seg = int(body.get("segment_seconds"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "segment_seconds должен быть числом")
|
||||
if seg not in ALLOWED_SEGMENTS:
|
||||
raise HTTPException(400, "допустимо 60 / 300 / 600 / 900 секунд")
|
||||
|
||||
st = runtime.config.storage
|
||||
runtime.config.storage = Storage(
|
||||
path=st.path, quota_gb=st.quota_gb,
|
||||
retention_days=st.retention_days, segment_seconds=seg,
|
||||
)
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.restart_all()
|
||||
return {"segment_seconds": seg}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""WebSocket: широковещательные live-обновления статуса камер."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ..runtime import runtime
|
||||
from ..auth import COOKIE_NAME, auth_manager
|
||||
|
||||
log = logging.getLogger("nvr.ws")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self._clients: set[WebSocket] = set()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop = loop
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
self._clients.add(ws)
|
||||
|
||||
def disconnect(self, ws: WebSocket) -> None:
|
||||
self._clients.discard(ws)
|
||||
|
||||
async def broadcast(self, message: dict) -> None:
|
||||
data = json.dumps(message, ensure_ascii=False)
|
||||
dead: list[WebSocket] = []
|
||||
for ws in list(self._clients):
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception: # noqa: BLE001
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self._clients.discard(ws)
|
||||
|
||||
def broadcast_threadsafe(self, message: dict) -> None:
|
||||
"""Вызов из синхронного callback рекордера."""
|
||||
if self._loop and self._loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(self.broadcast(message), self._loop)
|
||||
|
||||
|
||||
@router.websocket("/api/ws")
|
||||
async def ws_endpoint(ws: WebSocket) -> None:
|
||||
if not auth_manager.validate(ws.cookies.get(COOKIE_NAME)):
|
||||
await ws.close(code=1008) # policy violation
|
||||
return
|
||||
await runtime.ws.connect(ws)
|
||||
# начальный снапшот статусов
|
||||
await ws.send_text(json.dumps({
|
||||
"type": "status",
|
||||
"cameras": [s.to_dict() for s in runtime.supervisor.statuses()],
|
||||
}, ensure_ascii=False))
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text() # клиент ничего не шлёт; держим соединение
|
||||
except WebSocketDisconnect:
|
||||
runtime.ws.disconnect(ws)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Авторизация по логину/паролю + роли (оператор / админ / суперадмин).
|
||||
|
||||
Пользователи читаются из .env.json (раздел "users"). Если его нет — он
|
||||
выводится из старого раздела "auth" (как один суперадмин) или admin/admin по
|
||||
умолчанию. Сессии — в памяти (перезапуск сервиса разлогинивает всех). Пароли
|
||||
хранятся в открытом виде, как и раньше; для нескольких операторов этого
|
||||
достаточно, при необходимости позже заменим на хеши.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
COOKIE_NAME = "nvr_session"
|
||||
|
||||
ROLES = ("operator", "admin", "superadmin")
|
||||
_RANK = {"operator": 1, "admin": 2, "superadmin": 3}
|
||||
|
||||
|
||||
def role_at_least(role: str | None, minimum: str) -> bool:
|
||||
return bool(role) and _RANK.get(role, 0) >= _RANK[minimum]
|
||||
|
||||
|
||||
class AuthManager:
|
||||
def __init__(self, settings_path: str | None = None):
|
||||
self.path = settings_path or os.environ.get("NVR_SETTINGS", ".env.json")
|
||||
self.users: list[dict] = []
|
||||
self._sessions: dict[str, str] = {} # token -> username
|
||||
self.reload()
|
||||
|
||||
# ── загрузка/сохранение ─────────────────────────────────────
|
||||
def _read(self) -> dict:
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def reload(self) -> None:
|
||||
data = self._read()
|
||||
users = data.get("users")
|
||||
if not users:
|
||||
auth = data.get("auth") or {}
|
||||
users = [{
|
||||
"username": auth.get("username", "admin"),
|
||||
"password": auth.get("password", "admin"),
|
||||
"role": "superadmin",
|
||||
}]
|
||||
norm: list[dict] = []
|
||||
for u in users:
|
||||
role = u.get("role", "operator")
|
||||
if role not in ROLES:
|
||||
role = "operator"
|
||||
norm.append({
|
||||
"username": u.get("username", ""),
|
||||
"password": u.get("password", ""),
|
||||
"role": role,
|
||||
})
|
||||
# гарантируем хотя бы одного суперадмина
|
||||
if norm and not any(u["role"] == "superadmin" for u in norm):
|
||||
norm[0]["role"] = "superadmin"
|
||||
self.users = norm
|
||||
|
||||
def _write_users(self) -> None:
|
||||
data = self._read()
|
||||
data["users"] = self.users
|
||||
with open(self.path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
|
||||
def _find(self, username: str) -> dict | None:
|
||||
return next((u for u in self.users if u["username"] == username), None)
|
||||
|
||||
def _superadmin_count(self) -> int:
|
||||
return sum(1 for u in self.users if u["role"] == "superadmin")
|
||||
|
||||
# ── сессии ──────────────────────────────────────────────────
|
||||
def login(self, username: str, password: str) -> str | None:
|
||||
u = self._find(username)
|
||||
if u and secrets.compare_digest(password, u["password"]):
|
||||
token = secrets.token_urlsafe(32)
|
||||
self._sessions[token] = username
|
||||
return token
|
||||
return None
|
||||
|
||||
def validate(self, token: str | None) -> bool:
|
||||
return bool(token) and token in self._sessions and \
|
||||
self._find(self._sessions[token]) is not None
|
||||
|
||||
def user_for(self, token: str | None) -> dict | None:
|
||||
if not token:
|
||||
return None
|
||||
username = self._sessions.get(token)
|
||||
return self._find(username) if username else None
|
||||
|
||||
def role_for(self, token: str | None) -> str | None:
|
||||
u = self.user_for(token)
|
||||
return u["role"] if u else None
|
||||
|
||||
def logout(self, token: str | None) -> None:
|
||||
if token:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def verify_token_password(self, token: str | None, password: str) -> bool:
|
||||
u = self.user_for(token)
|
||||
return bool(u) and secrets.compare_digest(password or "", u["password"])
|
||||
|
||||
# ── управление пользователями ───────────────────────────────
|
||||
def list_users(self) -> list[dict]:
|
||||
return [{"username": u["username"], "role": u["role"]} for u in self.users]
|
||||
|
||||
def add_user(self, username: str, password: str, role: str) -> None:
|
||||
username = (username or "").strip()
|
||||
if not username:
|
||||
raise ValueError("пустой логин")
|
||||
if role not in ROLES:
|
||||
raise ValueError("недопустимая роль")
|
||||
if not password:
|
||||
raise ValueError("пустой пароль")
|
||||
if self._find(username):
|
||||
raise ValueError("пользователь с таким логином уже существует")
|
||||
self.users.append({"username": username, "password": password, "role": role})
|
||||
self._write_users()
|
||||
|
||||
def update_user(self, username: str, password: str | None = None,
|
||||
role: str | None = None) -> None:
|
||||
u = self._find(username)
|
||||
if not u:
|
||||
raise ValueError("пользователь не найден")
|
||||
if role is not None:
|
||||
if role not in ROLES:
|
||||
raise ValueError("недопустимая роль")
|
||||
if u["role"] == "superadmin" and role != "superadmin" and \
|
||||
self._superadmin_count() == 1:
|
||||
raise ValueError("нельзя снять роль с последнего суперадмина")
|
||||
u["role"] = role
|
||||
if password:
|
||||
u["password"] = password
|
||||
self._write_users()
|
||||
|
||||
def delete_user(self, username: str) -> None:
|
||||
u = self._find(username)
|
||||
if not u:
|
||||
raise ValueError("пользователь не найден")
|
||||
if u["role"] == "superadmin" and self._superadmin_count() == 1:
|
||||
raise ValueError("нельзя удалить последнего суперадмина")
|
||||
self.users = [x for x in self.users if x["username"] != username]
|
||||
self._sessions = {t: n for t, n in self._sessions.items() if n != username}
|
||||
self._write_users()
|
||||
|
||||
|
||||
auth_manager = AuthManager()
|
||||
|
||||
|
||||
def require_role(request: Request, minimum: str) -> str:
|
||||
"""Проверяет, что текущая сессия имеет роль не ниже minimum, иначе 403."""
|
||||
role = auth_manager.role_for(request.cookies.get(COOKIE_NAME))
|
||||
if not role_at_least(role, minimum):
|
||||
raise HTTPException(403, "недостаточно прав")
|
||||
return role
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: Request) -> Response:
|
||||
body = await request.json()
|
||||
token = auth_manager.login(body.get("username", ""), body.get("password", ""))
|
||||
if not token:
|
||||
return JSONResponse({"detail": "неверный логин или пароль"}, status_code=401)
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", max_age=7 * 86400)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request) -> Response:
|
||||
u = auth_manager.user_for(request.cookies.get(COOKIE_NAME))
|
||||
if not u:
|
||||
return JSONResponse({"detail": "unauthorized"}, status_code=401)
|
||||
return JSONResponse({"username": u["username"], "role": u["role"]})
|
||||
|
||||
|
||||
@router.post("/verify-password")
|
||||
async def verify_password(request: Request) -> Response:
|
||||
"""Подтверждение пароля текущего оператора (для защищённых настроек)."""
|
||||
body = await request.json()
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not auth_manager.verify_token_password(token, body.get("password", "")):
|
||||
return JSONResponse({"detail": "неверный пароль"}, status_code=403)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request) -> Response:
|
||||
auth_manager.logout(request.cookies.get(COOKIE_NAME))
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie(COOKIE_NAME)
|
||||
return resp
|
||||
|
||||
|
||||
# ── управление пользователями (только суперадмин) ───────────────
|
||||
@router.get("/users")
|
||||
async def users_list(request: Request) -> dict:
|
||||
require_role(request, "superadmin")
|
||||
return {"users": auth_manager.list_users()}
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def users_create(request: Request) -> dict:
|
||||
require_role(request, "superadmin")
|
||||
body = await request.json()
|
||||
try:
|
||||
auth_manager.add_user(body.get("username", ""), body.get("password", ""),
|
||||
body.get("role", "operator"))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/users/{username}")
|
||||
async def users_update(username: str, request: Request) -> dict:
|
||||
require_role(request, "superadmin")
|
||||
body = await request.json()
|
||||
try:
|
||||
auth_manager.update_user(
|
||||
username,
|
||||
password=(body.get("password") or None),
|
||||
role=(body.get("role") or None),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/users/{username}")
|
||||
async def users_delete(username: str, request: Request) -> dict:
|
||||
require_role(request, "superadmin")
|
||||
try:
|
||||
auth_manager.delete_user(username)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Загрузка и валидация конфигурации NVR из cameras.yaml."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Camera:
|
||||
id: str
|
||||
name: str
|
||||
enabled: bool
|
||||
ip: str
|
||||
user: str
|
||||
password: str
|
||||
main_path: str
|
||||
sub_path: str | None = None
|
||||
|
||||
def rtsp_url(self, path: str) -> str:
|
||||
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
|
||||
|
||||
@property
|
||||
def record_url(self) -> str:
|
||||
"""Поток для записи (основной, H.265, копированием)."""
|
||||
return self.rtsp_url(self.main_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Storage:
|
||||
path: str = "/records"
|
||||
quota_gb: int = 50
|
||||
retention_days: int = 7
|
||||
segment_seconds: int = 600
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Live:
|
||||
go2rtc_url: str = "http://localhost:1984"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
storage: Storage
|
||||
live: Live
|
||||
cameras: list[Camera] = field(default_factory=list)
|
||||
|
||||
def camera(self, cam_id: str) -> Camera | None:
|
||||
return next((c for c in self.cameras if c.id == cam_id), None)
|
||||
|
||||
@property
|
||||
def enabled_cameras(self) -> list[Camera]:
|
||||
return [c for c in self.cameras if c.enabled]
|
||||
|
||||
|
||||
def load_config(path: str | None = None) -> Config:
|
||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh) or {}
|
||||
|
||||
storage = Storage(**(raw.get("storage") or {}))
|
||||
live = Live(**(raw.get("live") or {}))
|
||||
cameras = [
|
||||
Camera(
|
||||
id=c["id"],
|
||||
name=c.get("name", c["id"]),
|
||||
enabled=c.get("enabled", True),
|
||||
ip=c["ip"],
|
||||
user=c["user"],
|
||||
password=c["password"],
|
||||
main_path=c["main_path"],
|
||||
sub_path=c.get("sub_path"),
|
||||
)
|
||||
for c in (raw.get("cameras") or [])
|
||||
]
|
||||
return Config(storage=storage, live=live, cameras=cameras)
|
||||
|
||||
|
||||
def camera_to_yaml(c: Camera) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"enabled": c.enabled,
|
||||
"ip": c.ip,
|
||||
"user": c.user,
|
||||
"password": c.password,
|
||||
"main_path": c.main_path,
|
||||
"sub_path": c.sub_path,
|
||||
}
|
||||
|
||||
|
||||
def save_config(config: Config, path: str | None = None) -> None:
|
||||
"""Сохраняет конфиг обратно в cameras.yaml (комментарии теряются)."""
|
||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||
data = {
|
||||
"storage": {
|
||||
"path": config.storage.path,
|
||||
"quota_gb": config.storage.quota_gb,
|
||||
"retention_days": config.storage.retention_days,
|
||||
"segment_seconds": config.storage.segment_seconds,
|
||||
},
|
||||
"live": {"go2rtc_url": config.live.go2rtc_url},
|
||||
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
||||
}
|
||||
# пишем на месте: cameras.yaml примонтирован как отдельный файл (bind mount),
|
||||
# переименование поверх точки монтирования даёт EBUSY.
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
yaml.safe_dump(data, fh, allow_unicode=True, sort_keys=False)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_config() -> Config:
|
||||
return load_config()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""SQLite-индекс архива (aiosqlite)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from .models import Recording
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
camera_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
duration_s REAL,
|
||||
size_bytes INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_cam_time ON recordings(camera_id, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
camera_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
meta TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: str | None = None):
|
||||
self.path = path or os.environ.get("NVR_DB", "/data/nvr.db")
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(self.path)
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.executescript(SCHEMA)
|
||||
await self._conn.commit()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("Database not connected")
|
||||
return self._conn
|
||||
|
||||
# ── recordings ─────────────────────────────────────────────
|
||||
async def has_path(self, path: str) -> bool:
|
||||
cur = await self.conn.execute("SELECT 1 FROM recordings WHERE path = ?", (path,))
|
||||
return await cur.fetchone() is not None
|
||||
|
||||
async def add_recording(
|
||||
self, camera_id: str, path: str, started_at: int,
|
||||
duration_s: float | None, size_bytes: int | None,
|
||||
) -> None:
|
||||
ended_at = int(started_at + duration_s) if duration_s else None
|
||||
await self.conn.execute(
|
||||
"""INSERT OR IGNORE INTO recordings
|
||||
(camera_id, path, started_at, ended_at, duration_s, size_bytes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(camera_id, path, started_at, ended_at, duration_s, size_bytes, int(time.time())),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def list_recordings(
|
||||
self, camera_id: str | None, ts_from: int | None, ts_to: int | None,
|
||||
limit: int = 2000,
|
||||
) -> list[Recording]:
|
||||
sql = "SELECT * FROM recordings WHERE 1=1"
|
||||
args: list = []
|
||||
if camera_id:
|
||||
sql += " AND camera_id = ?"
|
||||
args.append(camera_id)
|
||||
if ts_from is not None:
|
||||
sql += " AND started_at >= ?"
|
||||
args.append(ts_from)
|
||||
if ts_to is not None:
|
||||
sql += " AND started_at <= ?"
|
||||
args.append(ts_to)
|
||||
sql += " ORDER BY started_at ASC LIMIT ?"
|
||||
args.append(limit)
|
||||
cur = await self.conn.execute(sql, args)
|
||||
rows = await cur.fetchall()
|
||||
return [Recording(**dict(r)) for r in rows]
|
||||
|
||||
async def get_recording(self, rec_id: int) -> Recording | None:
|
||||
cur = await self.conn.execute("SELECT * FROM recordings WHERE id = ?", (rec_id,))
|
||||
row = await cur.fetchone()
|
||||
return Recording(**dict(row)) if row else None
|
||||
|
||||
async def oldest_recordings(self, limit: int = 50) -> list[Recording]:
|
||||
cur = await self.conn.execute(
|
||||
"SELECT * FROM recordings ORDER BY started_at ASC LIMIT ?", (limit,)
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [Recording(**dict(r)) for r in rows]
|
||||
|
||||
async def recordings_before(self, ts: int) -> list[Recording]:
|
||||
cur = await self.conn.execute(
|
||||
"SELECT * FROM recordings WHERE started_at < ? ORDER BY started_at ASC", (ts,)
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [Recording(**dict(r)) for r in rows]
|
||||
|
||||
async def delete_recording(self, rec_id: int) -> None:
|
||||
await self.conn.execute("DELETE FROM recordings WHERE id = ?", (rec_id,))
|
||||
await self.conn.commit()
|
||||
|
||||
async def delete_missing(self, path: str) -> None:
|
||||
await self.conn.execute("DELETE FROM recordings WHERE path = ?", (path,))
|
||||
await self.conn.commit()
|
||||
|
||||
async def total_size(self) -> int:
|
||||
cur = await self.conn.execute("SELECT COALESCE(SUM(size_bytes), 0) FROM recordings")
|
||||
row = await cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
async def known_paths(self) -> set[str]:
|
||||
cur = await self.conn.execute("SELECT path FROM recordings")
|
||||
rows = await cur.fetchall()
|
||||
return {r[0] for r in rows}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Точка входа NVR: FastAPI + супервизор записи + индексатор + retention."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from .config import load_config
|
||||
from .db import Database
|
||||
from .recorder.indexer import Indexer
|
||||
from .recorder.supervisor import Supervisor
|
||||
from .services.retention import Retention
|
||||
from .runtime import runtime
|
||||
from . import auth, prefs
|
||||
from .api import cameras, recordings, system, ws
|
||||
from .api.ws import ConnectionManager
|
||||
from .services import go2rtc
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("NVR_LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger("nvr")
|
||||
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
runtime.config = load_config()
|
||||
runtime.db = Database()
|
||||
await runtime.db.connect()
|
||||
|
||||
runtime.ws = ConnectionManager()
|
||||
runtime.ws.bind_loop(asyncio.get_running_loop())
|
||||
|
||||
def on_status(status) -> None:
|
||||
runtime.ws.broadcast_threadsafe({"type": "camera", "camera": status.to_dict()})
|
||||
|
||||
runtime.supervisor = Supervisor(runtime.config, on_status=on_status)
|
||||
runtime.indexer = Indexer(runtime.config, runtime.db)
|
||||
runtime.retention = Retention(runtime.config, runtime.db)
|
||||
|
||||
os.makedirs(runtime.config.storage.path, exist_ok=True)
|
||||
await go2rtc.sync_all(runtime.config) # потоки (вкл. <cam>_main) в go2rtc для рестрима
|
||||
runtime.supervisor.start_all()
|
||||
runtime.indexer.start()
|
||||
runtime.retention.start()
|
||||
log.info("NVR запущен: камер=%d, хранилище=%s",
|
||||
len(runtime.config.cameras), runtime.config.storage.path)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
log.info("остановка NVR…")
|
||||
await runtime.indexer.stop()
|
||||
await runtime.retention.stop()
|
||||
await runtime.supervisor.stop_all()
|
||||
await runtime.db.close()
|
||||
|
||||
|
||||
app = FastAPI(title="CoRE.Vision", version="1.0", lifespan=lifespan)
|
||||
|
||||
# публичные пути (без авторизации)
|
||||
PUBLIC_PATHS = {"/login", "/api/login", "/api/health"}
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def cross_origin_isolation(request: Request, call_next):
|
||||
# COOP/COEP включают crossOriginIsolated → доступен SharedArrayBuffer,
|
||||
# который нужен многопоточному WASM-декодеру H.265 (свой плеер jessibuca).
|
||||
response = await call_next(request)
|
||||
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
||||
response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def require_auth(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if path in PUBLIC_PATHS or path.startswith("/static/"):
|
||||
return await call_next(request)
|
||||
if auth.auth_manager.validate(request.cookies.get(auth.COOKIE_NAME)):
|
||||
return await call_next(request)
|
||||
if path.startswith("/api/"):
|
||||
return JSONResponse({"detail": "unauthorized"}, status_code=401)
|
||||
return RedirectResponse("/login")
|
||||
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(prefs.router)
|
||||
app.include_router(cameras.router)
|
||||
app.include_router(recordings.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(ws.router)
|
||||
|
||||
|
||||
# ── фронтенд ────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
||||
|
||||
|
||||
@app.get("/archive")
|
||||
async def archive() -> FileResponse:
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, "archive.html"))
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
async def settings() -> FileResponse:
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, "settings.html"))
|
||||
|
||||
|
||||
@app.get("/cams")
|
||||
async def cams() -> FileResponse:
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, "cams.html"))
|
||||
|
||||
|
||||
@app.get("/layout")
|
||||
async def layout() -> RedirectResponse:
|
||||
# раскладка переехала во вкладку «Настройки → Раскладка»
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login_page() -> FileResponse:
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, "login.html"))
|
||||
|
||||
|
||||
if os.path.isdir(os.path.join(FRONTEND_DIR, "static")):
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(FRONTEND_DIR, "static")), name="static")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Датаклассы строк БД и статусов рекордера."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CameraState(str, Enum):
|
||||
ONLINE = "online" # ffmpeg пишет
|
||||
RETRYING = "retrying" # упал, ждём перезапуск (backoff)
|
||||
OFFLINE = "offline" # выключена/остановлена
|
||||
STARTING = "starting"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recording:
|
||||
id: int
|
||||
camera_id: str
|
||||
path: str
|
||||
started_at: int
|
||||
ended_at: int | None
|
||||
duration_s: float | None
|
||||
size_bytes: int | None
|
||||
created_at: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraStatus:
|
||||
camera_id: str
|
||||
name: str
|
||||
enabled: bool
|
||||
state: CameraState
|
||||
last_error: str | None = None
|
||||
restarts: int = 0
|
||||
pid: int | None = None
|
||||
since: int | None = None # unix-время входа в текущее состояние
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
d["state"] = self.state.value
|
||||
return d
|
||||
@@ -0,0 +1,104 @@
|
||||
"""UI-настройки, сохраняемые в .env.json (раздел "ui").
|
||||
|
||||
Хранятся на сервере → значение общее для всех клиентов и переживает перезапуск.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from .auth import COOKIE_NAME, auth_manager, require_role
|
||||
|
||||
DEFAULT_UI = {"live_transcode": False, "archive_transcode": False}
|
||||
DEFAULT_OPT = {"single_main_stream": True, "mode_16plus": False} # серверная политика Live view
|
||||
|
||||
|
||||
def _path() -> str:
|
||||
return os.environ.get("NVR_SETTINGS", ".env.json")
|
||||
|
||||
|
||||
def load_ui() -> dict:
|
||||
try:
|
||||
with open(_path(), "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
ui = data.get("ui") or {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
ui = {}
|
||||
return {**DEFAULT_UI, **{k: ui[k] for k in DEFAULT_UI if k in ui}}
|
||||
|
||||
|
||||
def save_ui(patch: dict) -> dict:
|
||||
path = _path()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
merged = load_ui()
|
||||
for k in DEFAULT_UI:
|
||||
if k in patch:
|
||||
merged[k] = bool(patch[k])
|
||||
data["ui"] = merged
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
return merged
|
||||
|
||||
|
||||
def load_optimization() -> dict:
|
||||
try:
|
||||
with open(_path(), "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
opt = data.get("optimization") or {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
opt = {}
|
||||
return {**DEFAULT_OPT, **{k: bool(opt[k]) for k in DEFAULT_OPT if k in opt}}
|
||||
|
||||
|
||||
def save_optimization(patch: dict) -> dict:
|
||||
path = _path()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
merged = load_optimization()
|
||||
for k in DEFAULT_OPT:
|
||||
if k in patch:
|
||||
merged[k] = bool(patch[k])
|
||||
data["optimization"] = merged
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
return merged
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings() -> dict:
|
||||
return load_ui()
|
||||
|
||||
|
||||
@router.post("/settings")
|
||||
async def update_settings(request: Request) -> dict:
|
||||
body = await request.json()
|
||||
return save_ui(body)
|
||||
|
||||
|
||||
@router.get("/optimization")
|
||||
async def get_optimization() -> dict:
|
||||
"""Политика Live view; читают все авторизованные клиенты."""
|
||||
return load_optimization()
|
||||
|
||||
|
||||
@router.post("/optimization")
|
||||
async def update_optimization(request: Request) -> dict:
|
||||
"""Менять может только суперадмин и только с подтверждением пароля."""
|
||||
require_role(request, "superadmin")
|
||||
body = await request.json()
|
||||
if not auth_manager.verify_token_password(request.cookies.get(COOKIE_NAME),
|
||||
body.get("password", "")):
|
||||
raise HTTPException(403, "неверный пароль")
|
||||
return save_optimization(body)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Сборка команд ffmpeg/ffprobe. Запись — строго копированием (без перекодирования)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from ..config import Camera, Storage
|
||||
|
||||
|
||||
def record_args(cam: Camera, storage: Storage, input_url: str | None = None) -> list[str]:
|
||||
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
||||
|
||||
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
||||
"""
|
||||
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
|
||||
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
|
||||
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
|
||||
out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-hide_banner",
|
||||
"-loglevel", "warning",
|
||||
"-rtsp_transport", "tcp",
|
||||
"-timeout", "10000000", # 10s на установку соединения (микросекунды)
|
||||
"-i", input_url or cam.record_url,
|
||||
"-an", # звук не пишем (камеры обычно без него)
|
||||
"-c:v", "copy",
|
||||
"-f", "segment",
|
||||
"-segment_time", str(storage.segment_seconds),
|
||||
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
|
||||
"-segment_clocktime_offset", "0",
|
||||
"-segment_format", "mp4",
|
||||
"-reset_timestamps", "1",
|
||||
"-strftime", "1",
|
||||
out_template,
|
||||
]
|
||||
|
||||
|
||||
async def probe_duration(path: str) -> tuple[float | None, int | None]:
|
||||
"""ffprobe: длительность (сек) и размер файла (байт)."""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = None
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", path,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
out, _ = await proc.communicate()
|
||||
duration = None
|
||||
if proc.returncode == 0 and out:
|
||||
try:
|
||||
data = json.loads(out)
|
||||
duration = float(data["format"]["duration"])
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
duration = None
|
||||
return duration, size
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Индексатор архива: периодически сканирует /records, заносит новые сегменты в БД.
|
||||
|
||||
Логика: самый свежий сегмент каждой камеры ещё пишется ffmpeg-ом — его пропускаем.
|
||||
Все остальные mp4, которых нет в БД, пробуем через ffprobe и добавляем.
|
||||
Параллельно убираем из БД записи о пропавших файлах.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from ..config import Config
|
||||
from ..db import Database
|
||||
from .ffmpeg import probe_duration
|
||||
|
||||
log = logging.getLogger("nvr.indexer")
|
||||
|
||||
SCAN_INTERVAL = 30 # сек
|
||||
|
||||
|
||||
def _parse_started_at(path: str) -> int:
|
||||
"""Время начала сегмента из имени <cam>/<YYYY-MM-DD_HH-MM-SS>.mp4."""
|
||||
try:
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
dt = datetime.strptime(stem, "%Y-%m-%d_%H-%M-%S")
|
||||
return int(dt.timestamp())
|
||||
except ValueError:
|
||||
return int(os.path.getmtime(path))
|
||||
|
||||
|
||||
def _scan_segments(root: str) -> dict[str, list[str]]:
|
||||
"""Возвращает {camera_id: [пути mp4, отсортированы]}."""
|
||||
result: dict[str, list[str]] = {}
|
||||
if not os.path.isdir(root):
|
||||
return result
|
||||
for cam_id in os.listdir(root):
|
||||
cam_dir = os.path.join(root, cam_id)
|
||||
if not os.path.isdir(cam_dir):
|
||||
continue
|
||||
files = [
|
||||
os.path.join(cam_dir, fn)
|
||||
for fn in os.listdir(cam_dir)
|
||||
if fn.endswith(".mp4")
|
||||
]
|
||||
files.sort()
|
||||
if files:
|
||||
result[cam_id] = files
|
||||
return result
|
||||
|
||||
|
||||
class Indexer:
|
||||
def __init__(self, config: Config, db: Database):
|
||||
self.config = config
|
||||
self.db = db
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stopping = False
|
||||
|
||||
def start(self) -> None:
|
||||
self._stopping = False
|
||||
self._task = asyncio.create_task(self._loop(), name="indexer")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stopping = True
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while not self._stopping:
|
||||
try:
|
||||
await self.scan_once()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("indexer scan failed")
|
||||
await asyncio.sleep(SCAN_INTERVAL)
|
||||
|
||||
async def scan_once(self) -> int:
|
||||
root = self.config.storage.path
|
||||
by_cam = _scan_segments(root)
|
||||
known = await self.db.known_paths()
|
||||
added = 0
|
||||
|
||||
for cam_id, files in by_cam.items():
|
||||
# последний файл может писаться прямо сейчас — не индексируем
|
||||
complete = files[:-1] if len(files) > 1 else []
|
||||
for path in complete:
|
||||
if path in known:
|
||||
continue
|
||||
duration, size = await probe_duration(path)
|
||||
if size is None or size == 0:
|
||||
continue
|
||||
await self.db.add_recording(
|
||||
cam_id, path, _parse_started_at(path), duration, size
|
||||
)
|
||||
added += 1
|
||||
|
||||
# уборка пропавших файлов
|
||||
for path in known:
|
||||
if not os.path.exists(path):
|
||||
await self.db.delete_missing(path)
|
||||
|
||||
if added:
|
||||
log.info("indexer: добавлено сегментов %d", added)
|
||||
return added
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Задача записи одной камеры: запуск ffmpeg + супервизия с backoff."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from ..config import Camera, Storage
|
||||
from ..models import CameraState, CameraStatus
|
||||
from .ffmpeg import record_args
|
||||
|
||||
log = logging.getLogger("nvr.recorder")
|
||||
|
||||
BACKOFF_START = 1.0
|
||||
BACKOFF_MAX = 60.0
|
||||
MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => считаем старт удачным, сбрасываем backoff
|
||||
|
||||
|
||||
class RecorderTask:
|
||||
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
||||
|
||||
def __init__(self, cam: Camera, storage: Storage, on_status=None, input_url: str | None = None):
|
||||
self.cam = cam
|
||||
self.storage = storage
|
||||
self.input_url = input_url # источник записи (рестрим go2rtc)
|
||||
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
||||
self.status = CameraStatus(
|
||||
camera_id=cam.id, name=cam.name, enabled=cam.enabled,
|
||||
state=CameraState.OFFLINE, since=int(time.time()),
|
||||
)
|
||||
self._proc: asyncio.subprocess.Process | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stopping = False
|
||||
self._log_tail: deque[str] = deque(maxlen=40)
|
||||
|
||||
# ── управление ──────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._stopping = False
|
||||
self._task = asyncio.create_task(self._run(), name=f"rec-{self.cam.id}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stopping = True
|
||||
await self._kill_proc()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._set_state(CameraState.OFFLINE)
|
||||
|
||||
async def _kill_proc(self) -> None:
|
||||
proc = self._proc
|
||||
if proc and proc.returncode is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self._proc = None
|
||||
|
||||
# ── основной цикл ───────────────────────────────────────────
|
||||
async def _run(self) -> None:
|
||||
backoff = BACKOFF_START
|
||||
os.makedirs(os.path.join(self.storage.path, self.cam.id), exist_ok=True)
|
||||
|
||||
while not self._stopping:
|
||||
self._set_state(CameraState.STARTING)
|
||||
args = record_args(self.cam, self.storage, self.input_url)
|
||||
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
|
||||
try:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
self._set_state(CameraState.RETRYING, "ffmpeg not found")
|
||||
return
|
||||
|
||||
self.status.pid = self._proc.pid
|
||||
self._set_state(CameraState.ONLINE)
|
||||
started = time.monotonic()
|
||||
|
||||
await self._drain_stderr(self._proc)
|
||||
rc = await self._proc.wait()
|
||||
uptime = time.monotonic() - started
|
||||
|
||||
if self._stopping:
|
||||
break
|
||||
|
||||
# backoff сбрасываем только если поток реально шёл (а не упал сразу, как .33 с 453)
|
||||
if uptime >= MIN_HEALTHY_UPTIME:
|
||||
backoff = BACKOFF_START
|
||||
else:
|
||||
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||
|
||||
err = self._log_tail[-1] if self._log_tail else f"exit code {rc}"
|
||||
self.status.restarts += 1
|
||||
self._set_state(CameraState.RETRYING, err)
|
||||
log.warning("[%s] ffmpeg exited rc=%s (uptime %.1fs), retry in %.0fs",
|
||||
self.cam.id, rc, uptime, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
async def _drain_stderr(self, proc: asyncio.subprocess.Process) -> None:
|
||||
assert proc.stderr is not None
|
||||
async for raw in proc.stderr:
|
||||
line = raw.decode("utf-8", "replace").rstrip()
|
||||
if line:
|
||||
self._log_tail.append(line)
|
||||
log.debug("[%s] %s", self.cam.id, line)
|
||||
|
||||
# ── статус ──────────────────────────────────────────────────
|
||||
def _set_state(self, state: CameraState, error: str | None = None) -> None:
|
||||
self.status.state = state
|
||||
self.status.last_error = error
|
||||
self.status.since = int(time.time())
|
||||
if state != CameraState.ONLINE:
|
||||
self.status.pid = None
|
||||
if self.on_status:
|
||||
try:
|
||||
self.on_status(self.status)
|
||||
except Exception: # noqa: BLE001 — статус не должен ронять рекордер
|
||||
log.exception("on_status callback failed")
|
||||
|
||||
@property
|
||||
def log_tail(self) -> list[str]:
|
||||
return list(self._log_tail)
|
||||
|
||||
|
||||
def _redact(args: list[str], cam: Camera) -> str:
|
||||
return " ".join(a.replace(cam.password, "***") for a in args)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Супервизор: владеет набором RecorderTask по всем включённым камерам."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..config import Camera, Config
|
||||
from ..models import CameraStatus
|
||||
from ..services import go2rtc
|
||||
from .recorder_task import RecorderTask
|
||||
|
||||
log = logging.getLogger("nvr.supervisor")
|
||||
|
||||
|
||||
class Supervisor:
|
||||
def __init__(self, config: Config, on_status=None):
|
||||
self.config = config
|
||||
self.on_status = on_status
|
||||
self.tasks: dict[str, RecorderTask] = {}
|
||||
|
||||
def _input_url(self, cam: Camera) -> str:
|
||||
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
|
||||
return go2rtc.restream_main_url(self.config, cam.id)
|
||||
|
||||
def start_all(self) -> None:
|
||||
for cam in self.config.cameras:
|
||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
||||
input_url=self._input_url(cam))
|
||||
self.tasks[cam.id] = task
|
||||
if cam.enabled:
|
||||
task.start()
|
||||
log.info("recorder started: %s", cam.id)
|
||||
else:
|
||||
log.info("recorder skipped (disabled): %s", cam.id)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
for task in self.tasks.values():
|
||||
await task.stop()
|
||||
|
||||
def statuses(self) -> list[CameraStatus]:
|
||||
return [t.status for t in self.tasks.values()]
|
||||
|
||||
def status(self, cam_id: str) -> CameraStatus | None:
|
||||
task = self.tasks.get(cam_id)
|
||||
return task.status if task else None
|
||||
|
||||
def log_tail(self, cam_id: str) -> list[str]:
|
||||
task = self.tasks.get(cam_id)
|
||||
return task.log_tail if task else []
|
||||
|
||||
async def restart(self, cam_id: str) -> bool:
|
||||
task = self.tasks.get(cam_id)
|
||||
if not task:
|
||||
return False
|
||||
await task.stop()
|
||||
task.start()
|
||||
return True
|
||||
|
||||
def add_camera(self, cam: Camera) -> None:
|
||||
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
|
||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
||||
input_url=self._input_url(cam))
|
||||
self.tasks[cam.id] = task
|
||||
if cam.enabled:
|
||||
task.start()
|
||||
log.info("recorder added: %s", cam.id)
|
||||
|
||||
async def remove_camera(self, cam_id: str) -> None:
|
||||
task = self.tasks.pop(cam_id, None)
|
||||
if task:
|
||||
await task.stop()
|
||||
log.info("recorder removed: %s", cam_id)
|
||||
|
||||
async def replace_camera(self, cam: Camera) -> None:
|
||||
await self.remove_camera(cam.id)
|
||||
self.add_camera(cam)
|
||||
|
||||
async def restart_all(self) -> None:
|
||||
"""Пересоздаёт все задачи (например, после смены настроек хранилища)."""
|
||||
for cam in list(self.config.cameras):
|
||||
await self.replace_camera(cam)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Глобальные ссылки на живые компоненты (заполняются в lifespan)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import Config
|
||||
from .db import Database
|
||||
from .recorder.indexer import Indexer
|
||||
from .recorder.supervisor import Supervisor
|
||||
from .services.retention import Retention
|
||||
from .api.ws import ConnectionManager
|
||||
|
||||
|
||||
class Runtime:
|
||||
config: "Config"
|
||||
db: "Database"
|
||||
supervisor: "Supervisor"
|
||||
indexer: "Indexer"
|
||||
retention: "Retention"
|
||||
ws: "ConnectionManager"
|
||||
|
||||
|
||||
runtime = Runtime()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Синхронизация потоков с go2rtc + рестрим основного потока.
|
||||
|
||||
Камеры Hikvision ограничивают основной поток (нельзя открыть его дважды:
|
||||
запись + live дают RTSP 453). Решение: go2rtc держит ОДНО соединение с камерой
|
||||
и раздаёт нескольким потребителям. И запись, и live основного потока читают
|
||||
из go2rtc (rtsp-рестрим на :8554), поэтому камера видит одну сессию.
|
||||
|
||||
go2rtc-потоки на камеру:
|
||||
<cam>_main — основной поток (101), единственный источник записи и live-HD.
|
||||
<cam> — субпоток (102) для живой сетки; если субпотока нет — ссылка на
|
||||
<cam>_main (повторное использование, без второго коннекта к камере).
|
||||
<cam>_h264 — субпоток, перекодированный в H.264 (для WebRTC/браузеров без HEVC).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
|
||||
from ..config import Camera, Config
|
||||
|
||||
log = logging.getLogger("nvr.go2rtc")
|
||||
|
||||
GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml")
|
||||
|
||||
|
||||
def _rtsp_host(config: Config) -> str:
|
||||
return urlparse(config.live.go2rtc_url).hostname or "127.0.0.1"
|
||||
|
||||
|
||||
def restream_main_url(config: Config, cam_id: str) -> str:
|
||||
"""RTSP-адрес основного потока камеры через go2rtc (для записи и live-HD)."""
|
||||
return f"rtsp://{_rtsp_host(config)}:8554/{cam_id}_main"
|
||||
|
||||
|
||||
def restream_sub_url(config: Config, cam_id: str) -> str:
|
||||
"""RTSP-адрес живого (суб)потока через go2rtc (для транскода сабпотока)."""
|
||||
return f"rtsp://{_rtsp_host(config)}:8554/{cam_id}"
|
||||
|
||||
|
||||
def _streams_for(cam: Camera) -> dict[str, str]:
|
||||
"""Имя go2rtc-потока -> источник, для одной камеры."""
|
||||
out = {f"{cam.id}_main": cam.rtsp_url(cam.main_path)}
|
||||
if cam.sub_path:
|
||||
out[cam.id] = cam.rtsp_url(cam.sub_path)
|
||||
else:
|
||||
# нет субпотока — берём основной из go2rtc, не открывая второй коннект к камере
|
||||
out[cam.id] = f"rtsp://127.0.0.1:8554/{cam.id}_main"
|
||||
out[f"{cam.id}_h264"] = f"ffmpeg:{cam.id}#video=h264#audio=copy"
|
||||
return out
|
||||
|
||||
|
||||
def build_streams(config: Config) -> dict:
|
||||
streams: dict = {}
|
||||
for c in config.cameras:
|
||||
for name, src in _streams_for(c).items():
|
||||
streams[name] = [src]
|
||||
return streams
|
||||
|
||||
|
||||
def write_go2rtc_yaml(config: Config) -> None:
|
||||
data = {
|
||||
"api": {"listen": ":1984"},
|
||||
"rtsp": {"listen": ":8554"},
|
||||
"webrtc": {"listen": ":8555"},
|
||||
"log": {"level": "info"},
|
||||
"streams": build_streams(config),
|
||||
}
|
||||
try:
|
||||
with open(GO2RTC_YAML, "w", encoding="utf-8") as fh:
|
||||
yaml.safe_dump(data, fh, allow_unicode=True, sort_keys=False)
|
||||
except OSError as exc:
|
||||
log.warning("не удалось записать go2rtc.yaml: %s", exc)
|
||||
|
||||
|
||||
def _http(method: str, url: str) -> int:
|
||||
req = urllib.request.Request(url, method=method)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return resp.status
|
||||
|
||||
|
||||
async def _call(method: str, url: str) -> None:
|
||||
try:
|
||||
await asyncio.to_thread(_http, method, url)
|
||||
except Exception as exc: # noqa: BLE001 — go2rtc может быть недоступен/вернуть 4xx при ro-конфиге
|
||||
log.debug("go2rtc API %s -> %s", method, exc)
|
||||
|
||||
|
||||
def _put(base: str, name: str, src: str) -> str:
|
||||
return f"{base}/api/streams?" + urllib.parse.urlencode({"name": name, "src": src})
|
||||
|
||||
|
||||
def _del(base: str, name: str) -> str:
|
||||
return f"{base}/api/streams?" + urllib.parse.urlencode({"src": name})
|
||||
|
||||
|
||||
async def add_camera_streams(config: Config, cam: Camera) -> None:
|
||||
base = config.live.go2rtc_url.rstrip("/")
|
||||
for name, src in _streams_for(cam).items():
|
||||
await _call("PUT", _put(base, name, src))
|
||||
write_go2rtc_yaml(config)
|
||||
|
||||
|
||||
async def remove_camera_streams(config: Config, cam_id: str) -> None:
|
||||
base = config.live.go2rtc_url.rstrip("/")
|
||||
for name in (f"{cam_id}_main", cam_id, f"{cam_id}_h264"):
|
||||
await _call("DELETE", _del(base, name))
|
||||
write_go2rtc_yaml(config)
|
||||
|
||||
|
||||
async def sync_all(config: Config) -> None:
|
||||
"""При старте: записать конфиг и добавить все потоки в go2rtc через API."""
|
||||
write_go2rtc_yaml(config)
|
||||
base = config.live.go2rtc_url.rstrip("/")
|
||||
for cam in config.cameras:
|
||||
for name, src in _streams_for(cam).items():
|
||||
await _call("PUT", _put(base, name, src))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Очистка архива по квоте диска и по сроку хранения."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..config import Config
|
||||
from ..db import Database
|
||||
|
||||
log = logging.getLogger("nvr.retention")
|
||||
|
||||
CHECK_INTERVAL = 300 # сек
|
||||
|
||||
|
||||
class Retention:
|
||||
def __init__(self, config: Config, db: Database):
|
||||
self.config = config
|
||||
self.db = db
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stopping = False
|
||||
|
||||
def start(self) -> None:
|
||||
self._stopping = False
|
||||
self._task = asyncio.create_task(self._loop(), name="retention")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stopping = True
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while not self._stopping:
|
||||
try:
|
||||
await self.enforce()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("retention failed")
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
async def enforce(self) -> int:
|
||||
removed = 0
|
||||
removed += await self._enforce_age()
|
||||
removed += await self._enforce_quota()
|
||||
if removed:
|
||||
log.info("retention: удалено сегментов %d", removed)
|
||||
return removed
|
||||
|
||||
async def _enforce_age(self) -> int:
|
||||
days = self.config.storage.retention_days
|
||||
if not days:
|
||||
return 0
|
||||
cutoff = int(time.time()) - days * 86400
|
||||
old = await self.db.recordings_before(cutoff)
|
||||
for rec in old:
|
||||
await self._remove(rec.id, rec.path)
|
||||
return len(old)
|
||||
|
||||
async def _enforce_quota(self) -> int:
|
||||
quota = self.config.storage.quota_gb * 1024 ** 3
|
||||
if not quota:
|
||||
return 0
|
||||
removed = 0
|
||||
# удаляем самые старые, пока суммарный размер не уложится в квоту
|
||||
while await self.db.total_size() > quota:
|
||||
batch = await self.db.oldest_recordings(limit=20)
|
||||
if not batch:
|
||||
break
|
||||
for rec in batch:
|
||||
await self._remove(rec.id, rec.path)
|
||||
removed += 1
|
||||
if await self.db.total_size() <= quota:
|
||||
break
|
||||
return removed
|
||||
|
||||
async def _remove(self, rec_id: int, path: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("не удалось удалить %s: %s", path, exc)
|
||||
await self.db.delete_recording(rec_id)
|
||||
_cleanup_empty_dirs(os.path.dirname(path))
|
||||
|
||||
|
||||
def _cleanup_empty_dirs(path: str) -> None:
|
||||
"""Удаляет пустой каталог дня (и камеры), оставшийся после очистки."""
|
||||
for _ in range(2):
|
||||
try:
|
||||
if path and os.path.isdir(path) and not os.listdir(path):
|
||||
os.rmdir(path)
|
||||
path = os.path.dirname(path)
|
||||
else:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import psutil
|
||||
|
||||
from ..config import Config
|
||||
|
||||
_START = time.time()
|
||||
|
||||
|
||||
def system_stats(config: Config) -> dict:
|
||||
path = config.storage.path
|
||||
try:
|
||||
usage = shutil.disk_usage(path)
|
||||
disk = {
|
||||
"total_gb": round(usage.total / 1024 ** 3, 1),
|
||||
"used_gb": round(usage.used / 1024 ** 3, 1),
|
||||
"free_gb": round(usage.free / 1024 ** 3, 1),
|
||||
"percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
||||
}
|
||||
except OSError:
|
||||
disk = {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
return {
|
||||
"uptime_s": int(time.time() - _START),
|
||||
"cpu_percent": psutil.cpu_percent(interval=None),
|
||||
"ram_percent": vm.percent,
|
||||
"ram_used_gb": round(vm.used / 1024 ** 3, 1),
|
||||
"ram_total_gb": round(vm.total / 1024 ** 3, 1),
|
||||
"storage_path": path,
|
||||
"disk": disk,
|
||||
"quota_gb": config.storage.quota_gb,
|
||||
"retention_days": config.storage.retention_days,
|
||||
"segment_seconds": config.storage.segment_seconds,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Общие параметры перекодирования H.264 (профиль, битрейт, пресет, GOP)."""
|
||||
from __future__ import annotations
|
||||
|
||||
H264_PROFILES = ("baseline", "main", "high")
|
||||
BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k")
|
||||
PRESETS = ("ultrafast", "superfast")
|
||||
KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды
|
||||
CODECS = ("h264", "vp9")
|
||||
|
||||
DEFAULT_PROFILE = "main"
|
||||
DEFAULT_BITRATE = "2000k"
|
||||
DEFAULT_PRESET = "superfast"
|
||||
DEFAULT_KEYINT = 2
|
||||
DEFAULT_CODEC = "h264"
|
||||
|
||||
|
||||
def norm_profile(p: str | None) -> str:
|
||||
return p if p in H264_PROFILES else DEFAULT_PROFILE
|
||||
|
||||
|
||||
def norm_codec(c: str | None) -> str:
|
||||
return c if c in CODECS else DEFAULT_CODEC
|
||||
|
||||
|
||||
def norm_bitrate(b: str | None) -> str:
|
||||
return b if b in BITRATES else DEFAULT_BITRATE
|
||||
|
||||
|
||||
def norm_preset(p: str | None) -> str:
|
||||
return p if p in PRESETS else DEFAULT_PRESET
|
||||
|
||||
|
||||
def norm_keyint(k) -> int:
|
||||
try:
|
||||
k = int(k)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_KEYINT
|
||||
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: профиль, битрейт, пресет и интервал ключевых кадров.
|
||||
|
||||
Ключевые кадры расставляются по времени (через -force_key_frames), поэтому
|
||||
интервал в секундах соблюдается независимо от fps камеры."""
|
||||
bufsize = f"{int(bitrate[:-1]) * 2}k"
|
||||
preset = norm_preset(preset)
|
||||
sec = norm_keyint(keyint_sec)
|
||||
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 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 неприменим)."""
|
||||
sec = norm_keyint(keyint_sec)
|
||||
# быстрый пресет → выше cpu-used (быстрее, ниже качество); realtime для live
|
||||
cpu = "8" if norm_preset(preset) == "ultrafast" else "6"
|
||||
return [
|
||||
"-c:v", "libvpx-vp9",
|
||||
"-b:v", bitrate,
|
||||
"-maxrate", bitrate,
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", cpu,
|
||||
"-row-mt", "1",
|
||||
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
||||
"-pix_fmt", "yuv420p",
|
||||
]
|
||||
|
||||
|
||||
def encode_args(codec: str, profile: str, bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None) -> 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)
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
aiosqlite==0.20.0
|
||||
PyYAML==6.0.2
|
||||
psutil==6.1.1
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
nvr-backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
container_name: nvr-backend
|
||||
restart: unless-stopped
|
||||
# HTTPS обязателен: SharedArrayBuffer (для WASM-декодера H.265) работает
|
||||
# только в защищённом контексте (https/localhost).
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090",
|
||||
"--ssl-keyfile", "/app/certs/key.pem", "--ssl-certfile", "/app/certs/cert.pem"]
|
||||
ports:
|
||||
- "443:8090" # дашборд на https://<сервер>
|
||||
volumes:
|
||||
- ./certs:/app/certs:ro # self-signed TLS-сертификат
|
||||
- ./VERSION:/app/VERSION:ro # номер версии (показывается в UI)
|
||||
- ./cameras.yaml:/app/cameras.yaml # rw: редактирование камер из UI
|
||||
- ./go2rtc.yaml:/app/go2rtc.yaml # rw: бэкенд регенерирует live-потоки
|
||||
- ./.env.json:/app/.env.json # логин/пароль + ui-настройки (JSON; имя .env.json, чтобы не путать с .env docker compose)
|
||||
- ./backend/app:/app/app # код бэкенда (правка без пересборки образа)
|
||||
- ./frontend:/app/frontend # фронтенд (правка без пересборки)
|
||||
- ./data:/data # SQLite-индекс архива
|
||||
# Запись. Локальная папка ./records ИЛИ смонтированный на хост NAS (SMB/NFS).
|
||||
# Для NAS: смонтируйте на хосте (например /mnt/nas/cctv) и замените левую часть:
|
||||
# - /mnt/nas/cctv:/records
|
||||
- ./records:/records
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
NVR_CONFIG: /app/cameras.yaml
|
||||
NVR_SETTINGS: /app/.env.json
|
||||
GO2RTC_CONFIG: /app/go2rtc.yaml
|
||||
NVR_DB: /data/nvr.db
|
||||
|
||||
go2rtc:
|
||||
image: alexxit/go2rtc:latest
|
||||
container_name: nvr-go2rtc
|
||||
restart: unless-stopped
|
||||
network_mode: host # WebRTC проще всего в host-сети (порт 8555 udp/tcp)
|
||||
volumes:
|
||||
- ./go2rtc.yaml:/config/go2rtc.yaml:ro
|
||||
environment:
|
||||
TZ: Asia/Tashkent
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import sys
|
||||
p = sys.argv[1] if len(sys.argv) > 1 else '/tmp/cap.flv'
|
||||
d = open(p, 'rb').read()
|
||||
print('size', len(d))
|
||||
if d[:3] != b'FLV':
|
||||
print('NOT FLV, first bytes:', d[:16].hex()); sys.exit(0)
|
||||
off = int.from_bytes(d[5:9], 'big')
|
||||
print('sig', d[:3], 'ver', d[3], 'flags', hex(d[4]), 'dataoffset', off)
|
||||
i = off + 4 # skip PrevTagSize0
|
||||
n = 0
|
||||
while i + 11 <= len(d) and n < 14:
|
||||
ttype = d[i] & 0x1f
|
||||
dsize = int.from_bytes(d[i+1:i+4], 'big')
|
||||
body = d[i+11:i+11+dsize]
|
||||
info = ''
|
||||
if ttype == 9 and body:
|
||||
b0 = body[0]; isEx = (b0 & 0x80) != 0
|
||||
if isEx:
|
||||
ft = (b0 >> 4) & 7; pt = b0 & 0xf; fourcc = bytes(body[1:5])
|
||||
info = f'video EX frameType={ft} packetType={pt} fourcc={fourcc} head={body[:14].hex()}'
|
||||
else:
|
||||
cid = b0 & 0xf; ft = (b0 >> 4) & 0xf; avcpt = body[1] if len(body) > 1 else -1
|
||||
info = f'video LEGACY codecId={cid} frameType={ft} avcPacketType={avcpt} head={body[:14].hex()}'
|
||||
elif ttype == 18:
|
||||
info = 'script/meta'
|
||||
elif ttype == 8:
|
||||
info = 'audio'
|
||||
print(f'tag#{n} type={ttype} dsize={dsize} {info}')
|
||||
i = i + 11 + dsize + 4
|
||||
n += 1
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Архив</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="archive">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<div class="subnav">
|
||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
||||
</div>
|
||||
<a class="nav-item active" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
</aside>
|
||||
|
||||
<main class="archive-main">
|
||||
<div class="toolbar">
|
||||
<label class="muted">Камера:</label>
|
||||
<select id="cam-select"></select>
|
||||
<label class="muted">Дата:</label>
|
||||
<input type="date" id="date">
|
||||
</div>
|
||||
<div class="player-wrap" id="player">
|
||||
<div class="hint">Кликните по таймлайну ниже, чтобы смотреть запись</div>
|
||||
</div>
|
||||
<div class="timeline" id="timeline"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Камеры</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="cams">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<a class="nav-item active" href="/cams" data-nav="cams"><span class="ico">🎥</span>Камеры</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
</aside>
|
||||
|
||||
<main class="settings-main">
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Камеры — 64 слота</h2>
|
||||
<button id="add-cam">+ Добавить камеру</button>
|
||||
</div>
|
||||
<table class="cams">
|
||||
<thead>
|
||||
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th>Статус</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="cam-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Live view</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="live">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<a class="nav-item active" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<div class="subnav" id="live-subnav">
|
||||
<a class="subitem subplay" id="play-toggle" href="#" title="Начать/остановить просмотр">⏹ Стоп</a>
|
||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
||||
<a class="subitem subfs" id="fs-grid" href="#" title="Развернуть сетку на весь экран">⛶ Полный экран</a>
|
||||
</div>
|
||||
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="grid g4" id="grid"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Раскладка</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="layout">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="navline">
|
||||
<a class="nav-item" href="/" data-nav="live" style="flex:1"><span class="ico">▦</span>Live view</a>
|
||||
<a class="nav-gear active" href="/layout" title="Настройка раскладки">⚙</a>
|
||||
</div>
|
||||
<div class="subnav">
|
||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
||||
</div>
|
||||
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
</aside>
|
||||
|
||||
<main class="settings-main">
|
||||
<div class="wrap" style="max-width:1100px">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Настройка раскладки</h2>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<label class="muted">Раскладка:</label>
|
||||
<select id="layout-dim">
|
||||
<option value="2">4 (2×2)</option>
|
||||
<option value="3">9 (3×3)</option>
|
||||
<option value="4">16 (4×4)</option>
|
||||
<option value="5">25 (5×5)</option>
|
||||
<option value="6">36 (6×6)</option>
|
||||
<option value="7">49 (7×7)</option>
|
||||
<option value="8">64 (8×8)</option>
|
||||
</select>
|
||||
<button id="layout-save">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" style="margin-bottom:12px">Назначьте камеру в каждую ячейку раскладки. Сохраняется в этом браузере.</div>
|
||||
<div id="layout-grid" class="layout-grid"></div>
|
||||
<div class="muted" id="layout-saved" style="margin-top:12px;font-size:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Вход</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<form class="login-card" id="login-form">
|
||||
<div class="brand login-brand">CoRE<span>.Vision</span></div>
|
||||
<input type="text" id="username" placeholder="Логин" autocomplete="username" autofocus>
|
||||
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
|
||||
<button type="submit">Войти</button>
|
||||
<div class="login-err" id="login-err"></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById("login-form");
|
||||
const err = document.getElementById("login-err");
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
err.textContent = "";
|
||||
const r = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById("username").value,
|
||||
password: document.getElementById("password").value,
|
||||
}),
|
||||
});
|
||||
if (r.ok) {
|
||||
location.href = "/";
|
||||
} else {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
err.textContent = d.detail || "Ошибка входа";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Настройки</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="settings">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<div class="subnav">
|
||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
||||
</div>
|
||||
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item active" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
</aside>
|
||||
|
||||
<main class="settings-main">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="cams" data-min-role="admin">Камеры</button>
|
||||
<button class="tab" data-tab="layout">Раскладка</button>
|
||||
<button class="tab" data-tab="users" data-min-role="superadmin">Пользователи</button>
|
||||
<button class="tab" data-tab="general" data-min-role="operator">Система</button>
|
||||
<button class="tab" data-tab="optim" data-min-role="superadmin">Доп настройки</button>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА 1: Камеры -->
|
||||
<div class="tab-panel" data-panel="cams" data-min-role="admin">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Камеры — 64 слота</h2>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button id="bulk-edit">Изменить</button>
|
||||
<button id="bulk-cancel" hidden>Отмена</button>
|
||||
<button id="add-cam">+ Добавить камеру</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="cams">
|
||||
<thead>
|
||||
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th>Статус</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="cam-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА 2: Система -->
|
||||
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
||||
<div class="wrap">
|
||||
<div class="card" data-min-role="admin">
|
||||
<h2>Запись</h2>
|
||||
<label class="fl" style="font-size:13px;color:var(--text)">Размер сегмента</label>
|
||||
<select id="segment-size" style="margin-top:6px;display:block">
|
||||
<option value="60">1 минута</option>
|
||||
<option value="120">2 минуты</option>
|
||||
<option value="300">5 минут</option>
|
||||
<option value="600">10 минут</option>
|
||||
<option value="900">15 минут</option>
|
||||
</select>
|
||||
<div style="margin-top:12px"><button id="segment-save">Сохранить</button></div>
|
||||
<div class="muted" id="segment-saved" style="margin-top:8px;font-size:12px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Плеер и перекодирование <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
|
||||
<div class="muted" style="margin-top:-4px;margin-bottom:16px;font-size:12px">
|
||||
Для каждого потока выберите способ воспроизведения: <b>как есть</b> —
|
||||
поток отдаётся без обработки (ffmpeg -c copy), браузер декодирует HEVC сам
|
||||
(нужна аппаратная поддержка); <b>встроенный плеер</b> — декодирует H.265
|
||||
в браузере софтом (WASM hevc.js, в Web Worker), тянет и основной поток
|
||||
(3200×1800, включая тайлы), и субпоток, без нагрузки на сервер (декод
|
||||
нагружает CPU клиента); <b>WebCodecs (железо)</b> — аппаратный декод H.265 на GPU клиента,
|
||||
тянет основной поток 3200×1800 при 0% CPU сервера (рекомендуется для
|
||||
основного потока); <b>перекодировать</b> — сервер транскодирует в H.264 /
|
||||
VP9 (нагрузка на CPU сервера, для слабых клиентов).
|
||||
</div>
|
||||
<!-- 3 независимых профиля: 1 поток / 2 поток / архив -->
|
||||
<div class="tr-block" data-tr="1">
|
||||
<h3>1 поток (основной)</h3>
|
||||
<div class="seg-toggle">
|
||||
<label><input type="radio" name="mode-1" value="native"><span>Как есть</span></label>
|
||||
<label><input type="radio" name="mode-1" value="player"><span>Встроенный плеер</span></label>
|
||||
<label><input type="radio" name="mode-1" value="webcodecs"><span>WebCodecs (железо)</span></label>
|
||||
<label><input type="radio" name="mode-1" value="transcode"><span>Перекодировать</span></label>
|
||||
</div>
|
||||
<div class="tr-row"></div>
|
||||
</div>
|
||||
|
||||
<div class="tr-block" data-tr="2">
|
||||
<h3>2 поток (суб)</h3>
|
||||
<div class="seg-toggle">
|
||||
<label><input type="radio" name="mode-2" value="native"><span>Как есть</span></label>
|
||||
<label><input type="radio" name="mode-2" value="player"><span>Встроенный плеер</span></label>
|
||||
<label><input type="radio" name="mode-2" value="webcodecs"><span>WebCodecs (железо)</span></label>
|
||||
<label><input type="radio" name="mode-2" value="transcode"><span>Перекодировать</span></label>
|
||||
</div>
|
||||
<div class="tr-row"></div>
|
||||
</div>
|
||||
|
||||
<div class="tr-block" data-tr="a">
|
||||
<h3>Архив</h3>
|
||||
<div class="seg-toggle">
|
||||
<label><input type="radio" name="mode-a" value="native"><span>Как есть</span></label>
|
||||
<label><input type="radio" name="mode-a" value="player"><span>Встроенный плеер</span></label>
|
||||
<label><input type="radio" name="mode-a" value="transcode"><span>Перекодировать</span></label>
|
||||
</div>
|
||||
<div class="tr-row"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px"><button id="prefs-save">Сохранить</button></div>
|
||||
<div class="muted" id="prefs-saved" style="margin-top:8px;font-size:12px">Сохраняется в этом браузере</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Хранилище</h2>
|
||||
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Система</h2>
|
||||
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Учётная запись</h2>
|
||||
<div class="muted" id="me-info" style="margin-bottom:10px;font-size:13px"></div>
|
||||
<button id="logout">Выйти из системы</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА: Раскладка (назначение камер по ячейкам, этот браузер) -->
|
||||
<div class="tab-panel" data-panel="layout" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Настройка раскладки</h2>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<label class="muted">Раскладка:</label>
|
||||
<select id="layout-dim">
|
||||
<option value="2">4 (2×2)</option>
|
||||
<option value="3">9 (3×3)</option>
|
||||
<option value="4">16 (4×4)</option>
|
||||
<option value="5">25 (5×5)</option>
|
||||
<option value="6">36 (6×6)</option>
|
||||
<option value="7">49 (7×7)</option>
|
||||
<option value="8">64 (8×8)</option>
|
||||
</select>
|
||||
<button id="layout-save">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" style="margin-bottom:12px">Назначьте камеру в каждую ячейку раскладки. Сохраняется в этом браузере.</div>
|
||||
<div id="layout-grid" class="layout-grid"></div>
|
||||
<div class="muted" id="layout-saved" style="margin-top:12px;font-size:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА: Доп настройки (только суперадмин) -->
|
||||
<div class="tab-panel" data-panel="optim" data-min-role="superadmin" hidden>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h2>Live view <span class="muted" style="font-weight:400;font-size:12px">— общая политика для всех клиентов</span></h2>
|
||||
<label class="toggle" style="color:var(--text)">
|
||||
<input type="checkbox" id="single-main">
|
||||
Не более одного основного потока в режиме Live view
|
||||
</label>
|
||||
<div class="muted" style="margin-top:8px;font-size:12px">
|
||||
Включено по умолчанию. Основной (HD) поток можно держать одновременно
|
||||
только в одной ячейке — при переключении другой ячейки на «1»
|
||||
предыдущая вернётся на субпоток. Снижает нагрузку на сеть и камеры.
|
||||
</div>
|
||||
<label class="toggle" style="color:var(--text);margin-top:18px">
|
||||
<input type="checkbox" id="mode-16plus">
|
||||
Режим 16+
|
||||
</label>
|
||||
<div class="muted" style="margin-top:8px;font-size:12px">
|
||||
По умолчанию в Live view доступно не более 16 камер (сетки до 4×4).
|
||||
Включите «16+», чтобы открыть раскладки 25 / 36 / 49 / 64.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:12px;font-size:12px">
|
||||
<b>Менять может только суперадмин с подтверждением пароля.</b>
|
||||
</div>
|
||||
<div style="margin-top:14px"><button id="optim-save">Сохранить</button></div>
|
||||
<div class="muted" id="optim-saved" style="margin-top:8px;font-size:12px">Смена требует пароль</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА 4: Пользователи (только суперадмин) -->
|
||||
<div class="tab-panel" data-panel="users" data-min-role="superadmin" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Пользователи и роли</h2>
|
||||
<button id="add-user">+ Добавить пользователя</button>
|
||||
</div>
|
||||
<table class="cams">
|
||||
<thead>
|
||||
<tr><th>Логин</th><th style="width:160px">Роль</th><th style="width:220px">Пароль</th><th style="width:120px"></th></tr>
|
||||
</thead>
|
||||
<tbody id="user-rows"></tbody>
|
||||
</table>
|
||||
<div class="muted" id="users-msg" style="margin-top:10px;font-size:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hevc.js (WASM, тайлы) — CoRE.Vision</title>
|
||||
<style>
|
||||
body { background:#0d0d0d; color:#ddd; font:13px system-ui,Arial; margin:0; padding:12px; }
|
||||
#cv { background:#000; border:1px solid #333; display:block; margin:10px 0; width:100%; max-width:900px; height:auto; }
|
||||
#log { white-space:pre-wrap; font:12px Consolas,monospace; color:#9f9; max-height:240px; overflow:auto; }
|
||||
button { font:13px system-ui; padding:4px 10px; }
|
||||
#stat { color:#ffd479; font-weight:bold; margin-left:12px; }
|
||||
</style>
|
||||
<!-- index.js статически импортит "mp4box" (для FMP4Demuxer, не используем) → подменяем заглушкой -->
|
||||
<script type="importmap">
|
||||
{ "imports": { "mp4box": "/static/vendor/hevcjs/mp4box-stub.js" } }
|
||||
</script>
|
||||
<!-- glue эмскриптена: UMD, выставляет глобал HEVCDecoderModule; create() его подхватит -->
|
||||
<script src="/static/vendor/hevcjs/dist/wasm/hevc-decode.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<button id="gomain">▶ main 3200×1800</button>
|
||||
<button id="gosub">▶ sub 640×360</button>
|
||||
<button id="stop">⏹ Stop</button>
|
||||
<span id="stat"></span>
|
||||
</div>
|
||||
<canvas id="cv" width="960" height="540"></canvas>
|
||||
<div id="log"></div>
|
||||
|
||||
<script type="module">
|
||||
import { HEVCDecoder, FrameRenderer } from "/static/vendor/hevcjs/hevc-core.js";
|
||||
|
||||
const logEl = document.getElementById("log"), statEl = document.getElementById("stat");
|
||||
const log = (...a) => { logEl.textContent += a.map((x) => typeof x === "object" ? JSON.stringify(x) : String(x)).join(" ") + "\n"; logEl.scrollTop = 1e9; console.log("[hevctest]", ...a); };
|
||||
window.onerror = (m, s, l) => log("‼ onerror", m, "@", l);
|
||||
window.onunhandledrejection = (e) => log("‼ promise", e.reason && (e.reason.message || e.reason));
|
||||
|
||||
// ── строка кодека из hvcC (для лога) ──
|
||||
function hevcCodecFromHvcC(hvcC) {
|
||||
try { const p = hvcC[1] & 0x1f, t = ((hvcC[1] >> 5) & 1) ? "H" : "L", lv = hvcC[12] || 153; return `hvc1.${p}.6.${t}${lv}.B0`; }
|
||||
catch (e) { return "hvc1.1.6.L153.B0"; }
|
||||
}
|
||||
// ── параметр-сеты (VPS/SPS/PPS) из hvcC → Annex-B ──
|
||||
function hvccParamSetsToAnnexB(hvcC) {
|
||||
if (hvcC.length < 23) return new Uint8Array(0);
|
||||
let o = 22; const numArrays = hvcC[o++]; const chunks = [];
|
||||
for (let a = 0; a < numArrays; a++) {
|
||||
o++; // байт: array_completeness(1)|reserved(1)|NAL_unit_type(6) — тип не нужен
|
||||
const num = (hvcC[o] << 8) | hvcC[o + 1]; o += 2;
|
||||
for (let n = 0; n < num; n++) {
|
||||
const len = (hvcC[o] << 8) | hvcC[o + 1]; o += 2;
|
||||
chunks.push(hvcC.subarray(o, o + len)); o += len;
|
||||
}
|
||||
}
|
||||
let total = 0; for (const c of chunks) total += 4 + c.length;
|
||||
const out = new Uint8Array(total); let p = 0;
|
||||
for (const c of chunks) { out[p + 3] = 1; p += 4; out.set(c, p); p += c.length; }
|
||||
return out;
|
||||
}
|
||||
// ── length-prefixed (4 байта длины) NAL → Annex-B (00 00 00 01), размер не меняется ──
|
||||
function toAnnexB(u8) {
|
||||
const out = new Uint8Array(u8.length); out.set(u8);
|
||||
let i = 0;
|
||||
while (i + 4 <= out.length) {
|
||||
const len = ((u8[i] << 24) | (u8[i + 1] << 16) | (u8[i + 2] << 8) | u8[i + 3]) >>> 0;
|
||||
out[i] = 0; out[i + 1] = 0; out[i + 2] = 0; out[i + 3] = 1;
|
||||
if (len === 0) break; i += 4 + len;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── демуксер FLV (enhanced-RTMP) → hvcC + length-prefixed HEVC-кадры (из app.js) ──
|
||||
class FlvHevcDemuxer {
|
||||
constructor({ onConfig, onSample }) {
|
||||
this.onConfig = onConfig; this.onSample = onSample;
|
||||
this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false;
|
||||
}
|
||||
push(ab) {
|
||||
const inc = new Uint8Array(ab);
|
||||
if (this.buf.length === 0) { this.buf = inc; }
|
||||
else { const m = new Uint8Array(this.buf.length + inc.length); m.set(this.buf, 0); m.set(inc, this.buf.length); this.buf = m; }
|
||||
this._parse();
|
||||
}
|
||||
_u32(o) { return ((this.buf[o] << 24) | (this.buf[o + 1] << 16) | (this.buf[o + 2] << 8) | this.buf[o + 3]) >>> 0; }
|
||||
_parse() {
|
||||
const b = this.buf, len = b.length;
|
||||
if (!this.headerDone) {
|
||||
if (len < 9) return;
|
||||
const dataOffset = this._u32(5);
|
||||
this.buf = b.slice(dataOffset); this.headerDone = true;
|
||||
return this._parse();
|
||||
}
|
||||
let o = 0;
|
||||
while (true) {
|
||||
if (o + 4 + 11 > len) break;
|
||||
const ts = o + 4;
|
||||
const tagType = b[ts] & 0x1f;
|
||||
const dataSize = (b[ts + 1] << 16) | (b[ts + 2] << 8) | b[ts + 3];
|
||||
const bodyStart = ts + 11, bodyEnd = bodyStart + dataSize;
|
||||
if (bodyEnd > len) break;
|
||||
if (tagType === 9) {
|
||||
const tsMs = ((b[ts + 7] << 24) >>> 0) | (b[ts + 4] << 16) | (b[ts + 5] << 8) | b[ts + 6];
|
||||
this._videoTag(b.subarray(bodyStart, bodyEnd), tsMs);
|
||||
}
|
||||
o = bodyEnd;
|
||||
}
|
||||
if (o > 0) this.buf = b.slice(o);
|
||||
}
|
||||
_videoTag(body, tsMs) {
|
||||
if (body.length < 5) return;
|
||||
const b0 = body[0], isEx = (b0 & 0x80) !== 0;
|
||||
if (isEx) {
|
||||
const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f;
|
||||
const fourcc = String.fromCharCode(body[1], body[2], body[3], body[4]);
|
||||
if (fourcc !== "hvc1" && fourcc !== "hev1") return;
|
||||
let off = 5;
|
||||
if (packetType === 1) off += 3;
|
||||
const payload = body.subarray(off);
|
||||
if (packetType === 0) this._config(payload);
|
||||
else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs);
|
||||
} else {
|
||||
const codecId = b0 & 0x0f, frameType = (b0 >> 4) & 0x0f;
|
||||
if (codecId !== 12 && codecId !== 7) return;
|
||||
const pkt = body[1], payload = body.subarray(5);
|
||||
if (pkt === 0) this._config(payload);
|
||||
else if (pkt === 1) this._frames(payload, frameType === 1, tsMs);
|
||||
}
|
||||
}
|
||||
_config(hvcC) {
|
||||
if (this.gotConfig || hvcC.length < 13) return;
|
||||
this.gotConfig = true;
|
||||
this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC));
|
||||
}
|
||||
_frames(payload, isKey, tsMs) {
|
||||
if (!this.gotConfig || payload.length === 0) return;
|
||||
this.onSample({ data: payload.slice(), type: isKey ? "key" : "delta", timestampUs: tsMs * 1000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── состояние ──
|
||||
let decoder = null, renderer = null, demux = null, ws = null;
|
||||
let statTimer = null, rafId = 0, latest = null;
|
||||
let fedAU = 0, decoded = 0, rendered = 0, gotInfo = false;
|
||||
|
||||
function stop() {
|
||||
if (statTimer) { clearInterval(statTimer); statTimer = null; }
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
||||
try { ws && ws.close(); } catch (e) {} ws = null;
|
||||
try { renderer && renderer.destroy(); } catch (e) {} renderer = null;
|
||||
try { decoder && decoder.destroy(); } catch (e) {} decoder = null;
|
||||
demux = null; latest = null;
|
||||
fedAU = decoded = rendered = 0; gotInfo = false;
|
||||
statEl.textContent = "";
|
||||
}
|
||||
|
||||
async function start(stream) {
|
||||
stop();
|
||||
const cv = document.getElementById("cv");
|
||||
log("создаю декодер…");
|
||||
try {
|
||||
decoder = await HEVCDecoder.create({
|
||||
wasmUrl: "/static/vendor/hevcjs/dist/wasm/hevc-decode.js",
|
||||
wasmBinaryUrl: "/static/vendor/hevcjs/dist/wasm/hevc-decode.wasm",
|
||||
});
|
||||
} catch (e) { log("‼ create:", e.message || e); return; }
|
||||
log("декодер готов");
|
||||
|
||||
renderer = new FrameRenderer();
|
||||
renderer.initCanvas(cv); // принудительно WebGL-путь (без MediaStreamTrackGenerator)
|
||||
log("FrameRenderer WebGL ок; supportsMediaStream=", FrameRenderer.supportsMediaStream);
|
||||
|
||||
demux = new FlvHevcDemuxer({
|
||||
onConfig: (hvcC, codec) => {
|
||||
log("config: codec=", codec, "hvcClen=", hvcC.length);
|
||||
const ps = hvccParamSetsToAnnexB(hvcC);
|
||||
log("param-sets Annex-B=", ps.length, "байт");
|
||||
try { if (ps.length) { decoder.feed(ps); pump(); } } catch (e) { log("‼ feed(ps):", e.message || e); }
|
||||
},
|
||||
onSample: ({ data, type, timestampUs }) => {
|
||||
fedAU++;
|
||||
try { decoder.feed(toAnnexB(data)); } catch (e) { log("‼ feed:", e.message || e); return; }
|
||||
pump();
|
||||
},
|
||||
});
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${proto}://${location.host}/api/cameras/cam32/flv?stream=${stream}`;
|
||||
log("WS →", url);
|
||||
ws = new WebSocket(url); ws.binaryType = "arraybuffer";
|
||||
ws.onopen = () => log("WS open");
|
||||
ws.onmessage = (e) => { try { demux.push(e.data); } catch (err) { log("‼ demux:", err.message || err); } };
|
||||
ws.onerror = () => log("WS error");
|
||||
ws.onclose = (ev) => log("WS closed", ev.code);
|
||||
|
||||
|
||||
statTimer = setInterval(() => {
|
||||
if (!gotInfo && decoder && decoder.info) { gotInfo = true; log("INFO:", JSON.stringify(decoder.info)); }
|
||||
statEl.textContent = `AU fed=${fedAU} декод=${decoded}/с показ=${rendered}/с`;
|
||||
log(`декод=${decoded}/с показ=${rendered}/с (AU=${fedAU})`);
|
||||
decoded = 0; rendered = 0;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function pump() {
|
||||
let frames;
|
||||
try { frames = decoder.drain(); } catch (e) { log("‼ drain:", e.message || e); return; }
|
||||
for (const f of frames) { decoded++; try { renderer.renderFrame(f, decoded * 40000); rendered++; } catch (e) { log("‼ render:", e.message || e); } }
|
||||
}
|
||||
|
||||
document.getElementById("gomain").onclick = () => start("main");
|
||||
document.getElementById("gosub").onclick = () => start("sub");
|
||||
document.getElementById("stop").onclick = stop;
|
||||
log("загружено. secure=", isSecureContext, "| COI=", crossOriginIsolated, "| HEVCDecoderModule=", typeof globalThis.HEVCDecoderModule);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hevc.js Worker (OffscreenCanvas) — CoRE.Vision</title>
|
||||
<style>
|
||||
body { background:#0d0d0d; color:#ddd; font:13px system-ui,Arial; margin:0; padding:12px; }
|
||||
#cv { background:#000; border:1px solid #333; display:block; margin:10px 0; width:100%; max-width:900px; height:auto; }
|
||||
#log { white-space:pre-wrap; font:12px Consolas,monospace; color:#9f9; max-height:240px; overflow:auto; }
|
||||
button { font:13px system-ui; padding:4px 10px; }
|
||||
#stat { color:#ffd479; font-weight:bold; margin-left:12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<button id="gomain">▶ main 3200×1800</button>
|
||||
<button id="gosub">▶ sub 640×360</button>
|
||||
<button id="stop">⏹ Stop</button>
|
||||
<span id="stat"></span>
|
||||
</div>
|
||||
<canvas id="cv" width="1600" height="900"></canvas>
|
||||
<div id="log"></div>
|
||||
<script>
|
||||
const logEl = document.getElementById("log"), statEl = document.getElementById("stat");
|
||||
const log = (...a) => { logEl.textContent += a.map((x) => typeof x === "object" ? JSON.stringify(x) : String(x)).join(" ") + "\n"; logEl.scrollTop = 1e9; console.log("[hevcw]", ...a); };
|
||||
window.onerror = (m, s, l) => log("‼ onerror", m, "@", l);
|
||||
|
||||
let worker = null, ws = null;
|
||||
|
||||
function stop() {
|
||||
try { ws && ws.close(); } catch (e) {} ws = null;
|
||||
if (worker) { try { worker.postMessage({ type: "stop" }); } catch (e) {} worker.terminate(); worker = null; }
|
||||
statEl.textContent = "";
|
||||
}
|
||||
|
||||
function start(stream) {
|
||||
stop();
|
||||
// canvas можно отдать в OffscreenCanvas только один раз → пересоздаём элемент при каждом старте
|
||||
const old = document.getElementById("cv");
|
||||
const cv = document.createElement("canvas");
|
||||
cv.id = "cv";
|
||||
cv.width = stream === "main" ? 1600 : 640;
|
||||
cv.height = stream === "main" ? 900 : 360;
|
||||
old.replaceWith(cv);
|
||||
const off = cv.transferControlToOffscreen();
|
||||
|
||||
worker = new Worker("/static/vendor/hevcjs/hevc-player-worker.js", { type: "module" });
|
||||
worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === "ready") { log("worker ready → открываю WS"); openWS(stream); }
|
||||
else if (m.type === "stat") { statEl.textContent = "декод/показ = " + m.fps + "/с"; if (m.fps) log("fps≈", m.fps); }
|
||||
else if (m.type === "err") { log("‼ worker:", m.msg); }
|
||||
};
|
||||
worker.onerror = (e) => log("‼ worker.onerror:", e.message, "@", (e.filename || "") + ":" + (e.lineno || ""));
|
||||
log("init worker (canvas " + cv.width + "x" + cv.height + ")");
|
||||
worker.postMessage({
|
||||
type: "init",
|
||||
canvas: off,
|
||||
wasmUrl: "/static/vendor/hevcjs/hevc-decode.esm.js",
|
||||
wasmBinaryUrl: "/static/vendor/hevcjs/dist/wasm/hevc-decode.wasm",
|
||||
}, [off]);
|
||||
}
|
||||
|
||||
function openWS(stream) {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${proto}://${location.host}/api/cameras/cam32/flv?stream=${stream}`;
|
||||
log("WS →", url);
|
||||
ws = new WebSocket(url); ws.binaryType = "arraybuffer";
|
||||
ws.onopen = () => log("WS open");
|
||||
ws.onmessage = (e) => { if (worker) worker.postMessage({ type: "data", buf: e.data }, [e.data]); };
|
||||
ws.onclose = (ev) => log("WS closed", ev.code);
|
||||
ws.onerror = () => log("WS error");
|
||||
}
|
||||
|
||||
document.getElementById("gomain").onclick = () => start("main");
|
||||
document.getElementById("gosub").onclick = () => start("sub");
|
||||
document.getElementById("stop").onclick = stop;
|
||||
log("загружено. secure=", isSecureContext, "| COI=", crossOriginIsolated);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,448 @@
|
||||
/* NVR UI — чистый тёмный интерфейс. Прототип компоновки iVMS-4200,
|
||||
но без перегруза: только то, что нужно для live и архива. */
|
||||
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #161a22;
|
||||
--panel-2: #1d2230;
|
||||
--border: #272d3a;
|
||||
--text: #e6e9ef;
|
||||
--text-dim: #8b93a7;
|
||||
--accent: #3f0303; /* акцент: тёмно-красный */
|
||||
--accent-2: #2a0202; /* затемнённый вариант */
|
||||
--ok: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--err: #ef4444;
|
||||
--header-h: 48px;
|
||||
--sidebar-w: 240px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0; height: 100%;
|
||||
background: var(--bg); color: var(--text);
|
||||
font: 14px/1.4 -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Шапка ── */
|
||||
header {
|
||||
height: var(--header-h); display: flex; align-items: center;
|
||||
padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
gap: 18px;
|
||||
}
|
||||
.brand { font-weight: 700; letter-spacing: .5px; color: #fff; }
|
||||
.brand span { color: #c0392b; }
|
||||
nav { display: flex; gap: 4px; }
|
||||
nav a {
|
||||
color: var(--text-dim); text-decoration: none; padding: 7px 14px;
|
||||
border-radius: 7px; font-weight: 500;
|
||||
}
|
||||
nav a:hover { background: var(--panel-2); color: var(--text); }
|
||||
nav a.active { background: var(--accent); color: #fff; }
|
||||
.spacer { flex: 1; }
|
||||
.stats { display: flex; gap: 16px; color: var(--text-dim); font-size: 12px; }
|
||||
.stats b { color: var(--text); font-weight: 600; }
|
||||
.clock { font-variant-numeric: tabular-nums; color: var(--text); }
|
||||
|
||||
/* ── Раскладка ── */
|
||||
.layout { display: flex; height: calc(100% - var(--header-h)); }
|
||||
aside {
|
||||
width: var(--sidebar-w); background: var(--panel); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.aside-title {
|
||||
padding: 12px 14px 8px; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: .8px; color: var(--text-dim);
|
||||
}
|
||||
/* ── Навигация в сайдбаре ── */
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||||
color: var(--text-dim); text-decoration: none; font-weight: 500; font-size: 14px;
|
||||
border-left: 3px solid transparent; background: none; border-top: 0; border-right: 0;
|
||||
border-bottom: 0; cursor: pointer; width: 100%; text-align: left; font-family: inherit;
|
||||
}
|
||||
.nav-item:hover { background: var(--panel-2); color: var(--text); }
|
||||
.nav-item.active { background: var(--panel-2); color: #fff; border-left-color: #c0392b; }
|
||||
.nav-item .ico { font-size: 17px; line-height: 1; width: 20px; text-align: center; }
|
||||
.aside-spacer { flex: 1; }
|
||||
|
||||
/* Иконка настройки раскладки рядом с Live view */
|
||||
.navline { display: flex; align-items: center; }
|
||||
.nav-gear { padding: 12px 14px; color: var(--text-dim); text-decoration: none; font-size: 16px; }
|
||||
.nav-gear:hover { color: #fff; background: var(--panel-2); }
|
||||
.nav-gear.active { color: #fff; }
|
||||
|
||||
/* Подменю раскладок под "Live view" */
|
||||
.subnav { display: flex; flex-direction: column; padding: 2px 0 6px; }
|
||||
.subitem {
|
||||
padding: 6px 16px 6px 50px; color: var(--text-dim); text-decoration: none;
|
||||
font-size: 13px; border-left: 3px solid transparent;
|
||||
}
|
||||
.subitem:hover { background: var(--panel-2); color: var(--text); }
|
||||
.subitem.active { color: #fff; border-left-color: #c0392b; background: var(--panel-2); }
|
||||
.subitem.subfs { padding-left: 16px; margin-top: 4px; border-top: 1px solid var(--border); padding-top: 8px; }
|
||||
.subitem.subplay { padding-left: 16px; font-weight: 600; color: var(--text); margin-bottom: 4px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
.app-version { padding: 8px 16px; font-size: 11px; color: var(--text-dim); }
|
||||
.nav-item.logout { color: var(--text-dim); border-top: 1px solid var(--border); }
|
||||
.nav-item.logout:hover { color: #e57373; }
|
||||
|
||||
/* ── Чипы камер (Live toolbar) ── */
|
||||
.cam-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px;
|
||||
border: 1px solid var(--border); border-radius: 15px; background: var(--panel-2);
|
||||
cursor: pointer; font-size: 13px; color: var(--text);
|
||||
}
|
||||
.chip:hover { border-color: #c0392b; }
|
||||
.chip.active { background: var(--accent); border-color: #c0392b; color: #fff; }
|
||||
|
||||
#cam-list { flex: 1; overflow-y: auto; } /* legacy, на случай переиспользования */
|
||||
main { flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* ── Список камер ── */
|
||||
.cam-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 9px 14px;
|
||||
cursor: pointer; border-left: 3px solid transparent;
|
||||
}
|
||||
.cam-item:hover { background: var(--panel-2); }
|
||||
.cam-item.active { background: var(--panel-2); border-left-color: var(--accent); }
|
||||
.cam-item .name { flex: 1; font-weight: 500; }
|
||||
.cam-item .ip { font-size: 11px; color: var(--text-dim); }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--text-dim); flex: none; }
|
||||
.dot.online { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
||||
.dot.retrying { background: var(--warn); animation: pulse 1.2s infinite; }
|
||||
.dot.offline, .dot.starting { background: var(--text-dim); }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
|
||||
/* ── Тулбар ── */
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 14px;
|
||||
background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.toolbar .group { display: flex; gap: 4px; }
|
||||
button, .btn {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 6px 11px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover, .btn:hover { border-color: var(--accent); }
|
||||
button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.toggle { display: flex; align-items: center; gap: 10px; color: var(--text-dim); cursor: pointer; }
|
||||
|
||||
/* Переключатели в виде тумблеров (off — серый, on — зелёный) */
|
||||
input[type="checkbox"] {
|
||||
appearance: none; -webkit-appearance: none; margin: 0;
|
||||
width: 40px; height: 22px; flex: none; border-radius: 11px;
|
||||
background: var(--panel-2); border: 1px solid var(--border);
|
||||
position: relative; cursor: pointer; transition: background .15s, border-color .15s;
|
||||
}
|
||||
input[type="checkbox"]::before {
|
||||
content: ""; position: absolute; top: 2px; left: 2px;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--text-dim); transition: transform .15s, background .15s;
|
||||
}
|
||||
input[type="checkbox"]:checked {
|
||||
background: rgba(34, 197, 94, .25); border-color: var(--ok);
|
||||
}
|
||||
input[type="checkbox"]:checked::before { transform: translateX(18px); background: var(--ok); }
|
||||
input[type="checkbox"]:focus-visible { outline: 2px solid var(--ok); outline-offset: 2px; }
|
||||
input[type="date"], select {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 6px 8px; border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ── Сетка видео ── */
|
||||
.grid {
|
||||
flex: 1; min-height: 0; min-width: 0; display: grid; gap: 4px; padding: 4px;
|
||||
background: #000; overflow: hidden;
|
||||
grid-auto-rows: 1fr; grid-auto-columns: 1fr; /* все дорожки строго равны */
|
||||
}
|
||||
.tile {
|
||||
position: relative; background: #05070a; border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; overflow: hidden;
|
||||
min-height: 0; min-width: 0; /* не растягиваться под видео */
|
||||
}
|
||||
.tile .hevc-player { position: absolute; inset: 0; width: 100%; height: 100%; background: #000; }
|
||||
.tile .hevc-player canvas { width: 100%; height: 100%; display: block; }
|
||||
/* canvas WASM/WebCodecs-плеера: вписываем кадр в ячейку с сохранением пропорций */
|
||||
.wc-canvas { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; }
|
||||
.tile video, .tile video-stream {
|
||||
display: block; width: 100%; height: 100%; max-width: 100%; max-height: 100%;
|
||||
object-fit: contain; background: #000;
|
||||
}
|
||||
.tile .label {
|
||||
position: absolute; left: 0; top: 0; padding: 3px 9px; font-size: 12px;
|
||||
background: rgba(0,0,0,.55); border-bottom-right-radius: 6px; pointer-events: none;
|
||||
}
|
||||
.tile .label .dot { display: inline-block; vertical-align: middle; margin-right: 6px; }
|
||||
.tile .empty { color: var(--text-dim); font-size: 13px; }
|
||||
.tile .no-link {
|
||||
font-family: Georgia, "Times New Roman", serif; font-weight: 700;
|
||||
font-size: clamp(16px, 3.2vw, 40px); letter-spacing: 3px; color: #9a9a9a;
|
||||
text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,.6); user-select: none;
|
||||
}
|
||||
.tile-btn {
|
||||
position: absolute; top: 4px; right: 4px; z-index: 2; padding: 2px 8px; font-size: 11px;
|
||||
background: rgba(0,0,0,.55); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 5px; cursor: pointer; opacity: .7;
|
||||
}
|
||||
.tile-btn:hover { opacity: 1; border-color: #c0392b; }
|
||||
.tile-btn.active { background: var(--accent); border-color: #c0392b; color: #fff; opacity: 1; }
|
||||
.tile:fullscreen { border: none; background: #000; }
|
||||
.tile:fullscreen video, .tile:fullscreen video-stream { object-fit: contain; }
|
||||
#grid:fullscreen { width: 100vw; height: 100vh; background: var(--bg); padding: 0; }
|
||||
.hevc-hint {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
|
||||
background: rgba(63, 3, 3, .88); color: #fff; font-size: 11px;
|
||||
padding: 5px 8px; text-align: center; line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ── Архив ── */
|
||||
.archive-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.player-wrap { flex: 1; background: #000; display: flex; align-items: center; justify-content: center; min-height: 0; }
|
||||
.player-wrap video { max-width: 100%; max-height: 100%; }
|
||||
.player-wrap .hint { color: var(--text-dim); }
|
||||
|
||||
/* ── плеер архива в стиле Playerjs ── */
|
||||
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
|
||||
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
|
||||
/* центр: большая кнопка play (на паузе) + спиннер (при буферизации) */
|
||||
.pjs-center { position: absolute; inset: 0; pointer-events: none; }
|
||||
.pjs-bigplay {
|
||||
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%;
|
||||
border: none; cursor: pointer; pointer-events: auto; background: rgba(10,12,18,.55); color: #fff;
|
||||
font-size: 26px; padding-left: 5px; opacity: 0; transition: opacity .15s, transform .15s;
|
||||
transform: translate(-50%, -50%) scale(.9);
|
||||
}
|
||||
.pjs.paused:not(.buffering) .pjs-bigplay { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
.pjs-bigplay:hover { background: rgba(10,12,18,.82); }
|
||||
.pjs-spinner {
|
||||
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
|
||||
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
|
||||
}
|
||||
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
|
||||
@keyframes pjs-spin { to { transform: rotate(360deg); } }
|
||||
/* нижняя панель управления */
|
||||
.pjs-bar {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
|
||||
display: flex; align-items: center; gap: 10px; padding: 26px 14px 11px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,.8));
|
||||
opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.pjs.show .pjs-bar, .pjs:hover .pjs-bar, .pjs.paused .pjs-bar { opacity: 1; transform: none; }
|
||||
.pjs-btn {
|
||||
background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; line-height: 1;
|
||||
padding: 5px 7px; border-radius: 5px; opacity: .9; transition: background .12s, opacity .12s;
|
||||
}
|
||||
.pjs-btn:hover { opacity: 1; background: rgba(255,255,255,.14); }
|
||||
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; opacity: .92; }
|
||||
/* шкала перемотки */
|
||||
.pjs-seek { position: relative; flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
|
||||
.pjs-seek:hover { height: 7px; }
|
||||
.pjs-buf { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: rgba(255,255,255,.35); border-radius: 3px; }
|
||||
.pjs-prog { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #e23b3b; border-radius: 3px; }
|
||||
.pjs-knob {
|
||||
position: absolute; top: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%;
|
||||
background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%) scale(0); transition: transform .12s;
|
||||
}
|
||||
.pjs-seek:hover .pjs-knob, .pjs.show .pjs-knob { transform: translateY(-50%) scale(1); }
|
||||
.pjs.fs:not(.show) { cursor: none; }
|
||||
|
||||
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
|
||||
.timeline {
|
||||
height: 96px; background: var(--panel); border-top: 1px solid var(--border);
|
||||
overflow: hidden; position: relative; cursor: grab; user-select: none; touch-action: none;
|
||||
}
|
||||
.timeline.grabbing { cursor: grabbing; }
|
||||
.tl-track { position: absolute; top: 0; bottom: 0; left: 0; will-change: transform; }
|
||||
/* деления времени */
|
||||
.tl-tick { position: absolute; top: 0; bottom: 0; width: 0; border-left: 1px solid var(--border); }
|
||||
.tl-tick.hour { border-left-color: #3a4254; }
|
||||
.tl-tick > span {
|
||||
position: absolute; top: 7px; left: 5px; font-size: 11px; color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||
}
|
||||
.tl-tick.hour > span { color: var(--text); }
|
||||
/* полоса записей (жёлтая) */
|
||||
.tl-seg {
|
||||
position: absolute; top: 36px; height: 40px; background: #e9c46a; border-radius: 2px;
|
||||
}
|
||||
.tl-seg.active { background: #f4a261; }
|
||||
/* маркер «эфир» (текущее время; движется вместе с лентой) */
|
||||
.tl-now { position: absolute; top: 0; bottom: 0; width: 0; border-left: 2px dashed var(--ok); z-index: 3; }
|
||||
.tl-now > span {
|
||||
position: absolute; top: 7px; left: 4px; font-size: 10px; font-weight: 700; color: var(--ok); white-space: nowrap;
|
||||
}
|
||||
/* фиксированный красный указатель по центру (playhead) */
|
||||
.tl-center { position: absolute; left: 50%; top: 0; bottom: 0; width: 0; border-left: 2px solid #e03131; z-index: 6; pointer-events: none; }
|
||||
.tl-center::after {
|
||||
content: ""; position: absolute; top: 0; left: 0; transform: translateX(-50%);
|
||||
border: 6px solid transparent; border-top-color: #e03131; border-bottom: 0;
|
||||
}
|
||||
.tl-center > span {
|
||||
position: absolute; top: 6px; left: 0; transform: translateX(-50%);
|
||||
font-size: 11px; font-weight: 700; color: #fff; background: #e03131;
|
||||
padding: 1px 6px; border-radius: 3px; white-space: nowrap; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tl-empty {
|
||||
position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%);
|
||||
text-align: center; color: var(--text-dim); pointer-events: none;
|
||||
}
|
||||
|
||||
.muted { color: var(--text-dim); }
|
||||
.empty-state { padding: 40px; text-align: center; color: var(--text-dim); }
|
||||
|
||||
/* ── Редактор раскладки ── */
|
||||
.layout-grid { display: grid; gap: 8px; }
|
||||
.lcell { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px; }
|
||||
.lcell-no { font-size: 11px; color: var(--text-dim); margin-bottom: 6px; }
|
||||
.lcell select { width: 100%; }
|
||||
|
||||
/* ── Страница входа ── */
|
||||
.login-body {
|
||||
display: flex; align-items: center; justify-content: center; height: 100%;
|
||||
background: radial-gradient(circle at 50% 30%, #1a1014, var(--bg));
|
||||
}
|
||||
.login-card {
|
||||
width: 320px; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 28px 26px; display: flex; flex-direction: column; gap: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.5);
|
||||
}
|
||||
.login-brand { font-size: 26px; text-align: center; }
|
||||
.login-sub { text-align: center; color: var(--text-dim); font-size: 13px; margin-bottom: 8px; }
|
||||
.login-card input {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 11px 13px; border-radius: 7px; font-size: 14px;
|
||||
}
|
||||
.login-card input:focus { outline: none; border-color: #c0392b; }
|
||||
.login-card button {
|
||||
background: var(--accent); border: 1px solid #c0392b; color: #fff; font-weight: 600;
|
||||
padding: 11px; border-radius: 7px; cursor: pointer; margin-top: 4px;
|
||||
}
|
||||
.login-card button:hover { background: var(--accent-2); }
|
||||
.login-err { color: #e57373; font-size: 13px; text-align: center; min-height: 18px; }
|
||||
|
||||
/* ── Настройки ── */
|
||||
.settings-main { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
.settings-main .wrap { max-width: 920px; margin: 0 auto; }
|
||||
|
||||
/* Вкладки (как в браузере) */
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
|
||||
.tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent; border-radius: 0;
|
||||
color: var(--text-dim); padding: 10px 18px; cursor: pointer; font-size: 14px; font-weight: 500;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: #fff; border-bottom-color: #c0392b; }
|
||||
.tab-panel[hidden] { display: none; }
|
||||
.tr-row { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; padding-left: 28px; }
|
||||
.tr-row[hidden] { display: none; }
|
||||
.tr-row label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--text-dim); }
|
||||
.tr-row select { display: block; }
|
||||
/* общие пресеты перекодирования (SOFT / MEDIUM / HARD) */
|
||||
.tr-presets { flex-basis: 100%; display: flex; align-items: center; gap: 8px; padding-left: 28px; }
|
||||
.tr-preset {
|
||||
padding: 4px 12px; font-size: 12px; font-weight: 600; border-radius: 6px;
|
||||
background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); cursor: pointer;
|
||||
}
|
||||
.tr-preset[data-preset="SOFT"] { color: #e03131; }
|
||||
.tr-preset[data-preset="MEDIUM"] { color: #e9c46a; }
|
||||
.tr-preset[data-preset="HARD"] { color: #ffffff; }
|
||||
.tr-preset.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
/* блок профиля: режим (свой плеер / перекодировать) + ряд параметров */
|
||||
.tr-block { padding: 12px 0; border-top: 1px solid var(--border); }
|
||||
.tr-block:first-of-type { border-top: none; padding-top: 0; }
|
||||
.tr-block h3 { margin: 0 0 8px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
/* 3-позиционный сегментированный переключатель режима */
|
||||
.seg-toggle {
|
||||
display: inline-flex; border: 1px solid var(--border); border-radius: 8px;
|
||||
overflow: hidden; background: var(--panel-2);
|
||||
}
|
||||
.seg-toggle label {
|
||||
display: flex; align-items: center; cursor: pointer; border-right: 1px solid var(--border);
|
||||
}
|
||||
.seg-toggle label:last-child { border-right: none; }
|
||||
.seg-toggle input[type="radio"] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; pointer-events: none; }
|
||||
.seg-toggle span {
|
||||
padding: 7px 14px; font-size: 13px; color: var(--text-dim);
|
||||
white-space: nowrap; transition: background .12s, color .12s;
|
||||
}
|
||||
.seg-toggle label:hover span { color: var(--text); }
|
||||
.seg-toggle input:checked + span { background: var(--accent); color: #fff; }
|
||||
.tab[hidden], .card[hidden] { display: none; }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 16px 18px; margin-bottom: 18px;
|
||||
}
|
||||
.card h2 { margin: 0 0 14px; font-size: 15px; font-weight: 600; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.card-head h2 { margin: 0; }
|
||||
|
||||
/* ── Модальное окно ── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 50;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal {
|
||||
width: 440px; max-width: calc(100vw - 32px); background: var(--panel);
|
||||
border: 1px solid var(--border); border-radius: 12px; padding: 22px 24px;
|
||||
box-shadow: 0 16px 50px rgba(0,0,0,.55);
|
||||
}
|
||||
.modal h3 { margin: 0 0 18px; font-size: 17px; }
|
||||
.modal .field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 12px; }
|
||||
.modal .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.modal label.fl { color: var(--text-dim); font-size: 12px; }
|
||||
.modal input[type="text"], .modal input[type="password"] {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 9px 11px; border-radius: 7px; font-size: 14px; width: 100%;
|
||||
}
|
||||
.modal input[type="text"]:focus, .modal input[type="password"]:focus { outline: none; border-color: #c0392b; }
|
||||
.modal input:disabled { opacity: .5; }
|
||||
.modal .switch-field { flex-direction: row; align-items: center; gap: 10px; }
|
||||
.modal .err { color: #e57373; font-size: 13px; min-height: 18px; margin-bottom: 4px; }
|
||||
.modal .actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.modal #modal-save { background: var(--accent); border-color: #c0392b; color: #fff; font-weight: 600; }
|
||||
.modal #modal-save:hover { background: var(--accent-2); }
|
||||
.kv { display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; }
|
||||
.kv .k { color: var(--text-dim); }
|
||||
.kv .v { font-variant-numeric: tabular-nums; }
|
||||
table.cams { width: 100%; border-collapse: collapse; }
|
||||
table.cams th, table.cams td {
|
||||
text-align: left; padding: 2px 10px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; line-height: 1.35;
|
||||
}
|
||||
table.cams th { color: var(--text-dim); font-weight: 500; font-size: 11px; padding: 4px 10px; }
|
||||
table.cams td.actions { white-space: nowrap; text-align: right; }
|
||||
table.cams tr.slot-empty td { opacity: .5; }
|
||||
/* строка-контейнер лога не должна занимать высоту, пока лог скрыт */
|
||||
table.cams tr.logrow > td { padding: 0; border: none; }
|
||||
|
||||
/* ── Инлайн-редактор строки (вместо всплывающего окна) ── */
|
||||
.cam-edit { background: var(--panel-2); padding: 12px 12px 14px; }
|
||||
.cam-edit-grid { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; }
|
||||
.cam-edit-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--text-dim); }
|
||||
.cam-edit-grid input[type="text"], .cam-edit-grid input[type="password"] {
|
||||
background: var(--panel); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 7px 9px; border-radius: 6px; font-size: 13px; width: 150px;
|
||||
}
|
||||
.cam-edit-grid #e-id, .cam-edit-grid #e-ip { width: 130px; }
|
||||
.cam-edit-grid #e-main, .cam-edit-grid #e-sub { width: 178px; }
|
||||
.cam-edit-grid input:focus { outline: none; border-color: #c0392b; }
|
||||
.cam-edit-grid input:disabled { opacity: .5; }
|
||||
.cam-edit-grid label.sw { flex-direction: row; align-items: center; gap: 8px; color: var(--text); }
|
||||
.cam-edit-foot { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
||||
.cam-edit-foot .err { color: #e57373; font-size: 12px; flex: 1; }
|
||||
.cam-edit-foot [data-act="save-edit"] { background: var(--accent); border-color: #c0392b; color: #fff; font-weight: 600; }
|
||||
.cam-bulk-head { font-size: 11px; color: var(--text-dim); margin-bottom: 8px; letter-spacing: .3px; }
|
||||
button.primary { background: var(--accent); border-color: #c0392b; color: #fff; font-weight: 600; }
|
||||
table.cams td.actions button { padding: 2px 8px; font-size: 12px; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
|
||||
border-radius: 11px; font-size: 12px; background: var(--panel-2);
|
||||
}
|
||||
.logbox {
|
||||
margin: 4px 10px 12px; padding: 10px; background: #05070a; border: 1px solid var(--border);
|
||||
border-radius: 6px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
font-size: 12px; color: var(--text-dim); white-space: pre-wrap; max-height: 220px; overflow: auto;
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import type { HEVCFrame, HEVCStreamInfo, DecodeResult, DecoderOptions } from "./types.js";
|
||||
/**
|
||||
* HEVC/H.265 Decoder — JavaScript wrapper for the WASM module.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const decoder = await HEVCDecoder.create();
|
||||
* const { frames, info } = decoder.decode(bitstreamBytes);
|
||||
* console.log(`${info.width}x${info.height}, ${frames.length} frames`);
|
||||
* decoder.destroy();
|
||||
* ```
|
||||
*/
|
||||
export declare class HEVCDecoder {
|
||||
private _m;
|
||||
private _api;
|
||||
private _dec;
|
||||
private constructor();
|
||||
/**
|
||||
* Create a new decoder instance. Loads the WASM module.
|
||||
*/
|
||||
static create(options?: DecoderOptions): Promise<HEVCDecoder>;
|
||||
/**
|
||||
* Decode a complete HEVC bitstream.
|
||||
* @param data Raw .265 bitstream bytes
|
||||
*/
|
||||
decode(data: Uint8Array): DecodeResult;
|
||||
/** Number of decoded frames available */
|
||||
get frameCount(): number;
|
||||
/** Get stream info (available after decode) */
|
||||
get info(): HEVCStreamInfo | null;
|
||||
private _extractFrame;
|
||||
private _extractDrainedFrame;
|
||||
private _readFrameFromPtr;
|
||||
private _extractInfo;
|
||||
/**
|
||||
* Feed a chunk of data containing one or more complete NAL units.
|
||||
* The decoder accumulates parameter sets and decodes pictures incrementally.
|
||||
* Call drain() after each feed() to retrieve output-ready frames.
|
||||
*/
|
||||
feed(data: Uint8Array): void;
|
||||
/**
|
||||
* Drain output-ready frames from the decoder (§C.5.2 bumping process).
|
||||
* Returns frames in display order, only when ready per DPB constraints.
|
||||
* Frames are valid until the next feed() or destroy() call.
|
||||
*/
|
||||
drain(): HEVCFrame[];
|
||||
/**
|
||||
* Flush all remaining frames from the DPB (call at end of stream).
|
||||
* Returns all buffered frames in display order.
|
||||
*/
|
||||
flush(): HEVCFrame[];
|
||||
/** Release decoder resources */
|
||||
destroy(): void;
|
||||
}
|
||||
//# sourceMappingURL=decoder.d.ts.map
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
export { HEVCDecoder } from "./decoder.js";
|
||||
export type { HEVCFrame, HEVCStreamInfo, DecodeResult, DecoderOptions, WorkerRequest, WorkerResponse, } from "./types.js";
|
||||
export { H264Encoder } from "./h264-encoder.js";
|
||||
export type { H264EncoderConfig, EncodedChunk } from "./h264-encoder.js";
|
||||
export { FrameRenderer } from "./renderer.js";
|
||||
export { FMP4Demuxer } from "./fmp4-demuxer.js";
|
||||
export type { DemuxedSample, VideoTrackInfo } from "./fmp4-demuxer.js";
|
||||
export { FMP4Muxer } from "./fmp4-muxer.js";
|
||||
export type { MuxerInitConfig, MuxerSample } from "./fmp4-muxer.js";
|
||||
export { MSEController } from "./mse-controller.js";
|
||||
export { TranscodePipeline } from "./transcode-pipeline.js";
|
||||
export type { TranscodePipelineConfig } from "./transcode-pipeline.js";
|
||||
export { setLogLevel } from "./log.js";
|
||||
export type { LogLevel } from "./log.js";
|
||||
export { installMSEIntercept, uninstallMSEIntercept } from "./mse-intercept.js";
|
||||
export type { MSEInterceptConfig } from "./mse-intercept.js";
|
||||
export { SegmentTranscoder } from "./segment-transcoder.js";
|
||||
export type { SegmentTranscoderConfig, TranscodedInit } from "./segment-transcoder.js";
|
||||
export { TranscodeWorkerClient } from "./transcode-worker-client.js";
|
||||
export type { TranscodeWorkerClientConfig } from "./transcode-worker-client.js";
|
||||
export { hevcMimeToH264Codec } from "./codec-mapping.js";
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
+2227
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* YUV Frame Renderer — converts decoded YUV frames to displayable video.
|
||||
*
|
||||
* Uses VideoFrame + MediaStreamTrackGenerator when available (Chrome 94+),
|
||||
* falls back to WebGL canvas rendering.
|
||||
*/
|
||||
import type { HEVCFrame } from "./types.js";
|
||||
/**
|
||||
* Renderer that converts YUV frames to a <video>-compatible MediaStream
|
||||
* or renders directly to a canvas.
|
||||
*/
|
||||
export declare class FrameRenderer {
|
||||
private _generator;
|
||||
private _writer;
|
||||
private _canvas;
|
||||
private _gl;
|
||||
private _program;
|
||||
private _texY;
|
||||
private _texCb;
|
||||
private _texCr;
|
||||
/**
|
||||
* Check if MediaStreamTrackGenerator is available (Chrome 94+).
|
||||
* When available, frames can be piped to a <video> element.
|
||||
*/
|
||||
static get supportsMediaStream(): boolean;
|
||||
/**
|
||||
* Get a MediaStream that can be assigned to a <video>.srcObject.
|
||||
* Only available when supportsMediaStream is true.
|
||||
*/
|
||||
getMediaStream(): MediaStream | null;
|
||||
/**
|
||||
* Render a decoded YUV frame.
|
||||
*
|
||||
* If MediaStreamTrackGenerator is available, creates a VideoFrame and writes it.
|
||||
* Otherwise, renders to the provided canvas via WebGL.
|
||||
*/
|
||||
renderFrame(frame: HEVCFrame, timestamp: number): Promise<void>;
|
||||
/**
|
||||
* Initialize WebGL canvas fallback.
|
||||
* Call this if MediaStreamTrackGenerator is not supported.
|
||||
*/
|
||||
initCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): void;
|
||||
private _renderToVideoFrame;
|
||||
private _initWebGL;
|
||||
private _renderToWebGL;
|
||||
/** Release all resources */
|
||||
destroy(): void;
|
||||
}
|
||||
//# sourceMappingURL=renderer.d.ts.map
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/** Decoded YUV frame — planes are copied out of WASM heap */
|
||||
export interface HEVCFrame {
|
||||
/** Luma plane (packed, no stride) */
|
||||
y: Uint16Array;
|
||||
/** Chroma Cb plane */
|
||||
cb: Uint16Array;
|
||||
/** Chroma Cr plane */
|
||||
cr: Uint16Array;
|
||||
/** Luma width (display, after conformance crop) */
|
||||
width: number;
|
||||
/** Luma height (display) */
|
||||
height: number;
|
||||
/** Chroma plane width */
|
||||
chromaWidth: number;
|
||||
/** Chroma plane height */
|
||||
chromaHeight: number;
|
||||
/** Bit depth (8 or 10) */
|
||||
bitDepth: number;
|
||||
/** Picture Order Count (display order) */
|
||||
poc: number;
|
||||
}
|
||||
/** Stream metadata — available after first decode */
|
||||
export interface HEVCStreamInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
bitDepth: number;
|
||||
/** 0=mono, 1=4:2:0, 2=4:2:2, 3=4:4:4 */
|
||||
chromaFormat: number;
|
||||
/** Profile IDC (1=Main, 2=Main10) */
|
||||
profile: number;
|
||||
/** Level IDC (e.g. 93 = Level 3.1) */
|
||||
level: number;
|
||||
}
|
||||
/** Result of a decode call */
|
||||
export interface DecodeResult {
|
||||
frames: HEVCFrame[];
|
||||
info: HEVCStreamInfo | null;
|
||||
}
|
||||
/** Options for creating a decoder */
|
||||
export interface DecoderOptions {
|
||||
/** URL to the hevc-decode.js WASM glue file. Auto-resolved if omitted. */
|
||||
wasmUrl?: string;
|
||||
/** URL to the .wasm binary. Auto-resolved if omitted. */
|
||||
wasmBinaryUrl?: string;
|
||||
}
|
||||
/** Worker message types (main → worker) */
|
||||
export type WorkerRequest = {
|
||||
type: "init";
|
||||
wasmUrl: string;
|
||||
} | {
|
||||
type: "decode";
|
||||
data: ArrayBuffer;
|
||||
} | {
|
||||
type: "feed";
|
||||
data: ArrayBuffer;
|
||||
} | {
|
||||
type: "drain";
|
||||
} | {
|
||||
type: "flush";
|
||||
} | {
|
||||
type: "destroy";
|
||||
};
|
||||
/** Worker message types (worker → main) */
|
||||
export type WorkerResponse = {
|
||||
type: "ready";
|
||||
} | {
|
||||
type: "info";
|
||||
info: HEVCStreamInfo;
|
||||
} | {
|
||||
type: "frame";
|
||||
index: number;
|
||||
frame: HEVCFrame;
|
||||
} | {
|
||||
type: "done";
|
||||
frameCount: number;
|
||||
} | {
|
||||
type: "drained";
|
||||
frames: HEVCFrame[];
|
||||
} | {
|
||||
type: "flushed";
|
||||
frames: HEVCFrame[];
|
||||
} | {
|
||||
type: "error";
|
||||
message: string;
|
||||
};
|
||||
//# sourceMappingURL=types.d.ts.map
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+408
@@ -0,0 +1,408 @@
|
||||
// src/decoder.ts
|
||||
var HEVCDecoder = class _HEVCDecoder {
|
||||
constructor(module) {
|
||||
this._m = module;
|
||||
this._api = {
|
||||
create: module.cwrap("hevc_decoder_create", "number", []),
|
||||
destroy: module.cwrap("hevc_decoder_destroy", null, ["number"]),
|
||||
decode: module.cwrap("hevc_decoder_decode", "number", ["number", "number", "number"]),
|
||||
getFrameCount: module.cwrap("hevc_decoder_get_frame_count", "number", ["number"]),
|
||||
getFrame: module.cwrap("hevc_decoder_get_frame", "number", ["number", "number", "number"]),
|
||||
getInfo: module.cwrap("hevc_decoder_get_info", "number", ["number", "number"]),
|
||||
feed: module.cwrap("hevc_decoder_feed", "number", ["number", "number", "number"]),
|
||||
drain: module.cwrap("hevc_decoder_drain", "number", ["number", "number"]),
|
||||
getDrainedFrame: module.cwrap("hevc_decoder_get_drained_frame", "number", ["number", "number", "number"]),
|
||||
flush: module.cwrap("hevc_decoder_flush", "number", ["number"])
|
||||
};
|
||||
this._dec = this._api.create();
|
||||
if (!this._dec) throw new Error("Failed to create HEVC decoder");
|
||||
}
|
||||
/**
|
||||
* Create a new decoder instance. Loads the WASM module.
|
||||
*/
|
||||
static async create(options) {
|
||||
const factoryOpts = {};
|
||||
if (options?.wasmBinaryUrl) {
|
||||
factoryOpts.locateFile = () => options.wasmBinaryUrl;
|
||||
}
|
||||
const g = globalThis;
|
||||
if (typeof g.HEVCDecoderModule === "function") {
|
||||
const module2 = await g.HEVCDecoderModule(factoryOpts);
|
||||
return new _HEVCDecoder(module2);
|
||||
}
|
||||
const wasmUrl = options?.wasmUrl ?? "./wasm/hevc-decode.js";
|
||||
const mod = await import(
|
||||
/* @vite-ignore */
|
||||
wasmUrl
|
||||
);
|
||||
const fn = mod.default ?? mod;
|
||||
const module = await fn(factoryOpts);
|
||||
return new _HEVCDecoder(module);
|
||||
}
|
||||
/**
|
||||
* Decode a complete HEVC bitstream.
|
||||
* @param data Raw .265 bitstream bytes
|
||||
*/
|
||||
decode(data) {
|
||||
const m = this._m;
|
||||
const ptr = m._malloc(data.length);
|
||||
try {
|
||||
m.HEAPU8.set(data, ptr);
|
||||
const ret = this._api.decode(this._dec, ptr, data.length);
|
||||
if (ret !== 0) throw new Error(`Decode failed (code ${ret})`);
|
||||
const count = this._api.getFrameCount(this._dec);
|
||||
const frames = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const frame = this._extractFrame(i);
|
||||
if (frame) frames.push(frame);
|
||||
}
|
||||
const info = this._extractInfo();
|
||||
return { frames, info };
|
||||
} finally {
|
||||
m._free(ptr);
|
||||
}
|
||||
}
|
||||
/** Number of decoded frames available */
|
||||
get frameCount() {
|
||||
return this._api.getFrameCount(this._dec);
|
||||
}
|
||||
/** Get stream info (available after decode) */
|
||||
get info() {
|
||||
return this._extractInfo();
|
||||
}
|
||||
_extractFrame(index) {
|
||||
const m = this._m;
|
||||
const framePtr = m._malloc(48);
|
||||
try {
|
||||
const ret = this._api.getFrame(this._dec, index, framePtr);
|
||||
if (ret !== 0) return null;
|
||||
return this._readFrameFromPtr(framePtr);
|
||||
} finally {
|
||||
m._free(framePtr);
|
||||
}
|
||||
}
|
||||
_extractDrainedFrame(index) {
|
||||
const m = this._m;
|
||||
const framePtr = m._malloc(48);
|
||||
try {
|
||||
const ret = this._api.getDrainedFrame(this._dec, index, framePtr);
|
||||
if (ret !== 0) return null;
|
||||
return this._readFrameFromPtr(framePtr);
|
||||
} finally {
|
||||
m._free(framePtr);
|
||||
}
|
||||
}
|
||||
_readFrameFromPtr(framePtr) {
|
||||
const m = this._m;
|
||||
const yPtr = m.getValue(framePtr, "*");
|
||||
const cbPtr = m.getValue(framePtr + 4, "*");
|
||||
const crPtr = m.getValue(framePtr + 8, "*");
|
||||
const width = m.getValue(framePtr + 12, "i32");
|
||||
const height = m.getValue(framePtr + 16, "i32");
|
||||
const strideY = m.getValue(framePtr + 20, "i32");
|
||||
const strideC = m.getValue(framePtr + 24, "i32");
|
||||
const cw = m.getValue(framePtr + 28, "i32");
|
||||
const ch = m.getValue(framePtr + 32, "i32");
|
||||
const bd = m.getValue(framePtr + 36, "i32");
|
||||
const poc = m.getValue(framePtr + 40, "i32");
|
||||
const y = copyPlane(m, yPtr, width, height, strideY);
|
||||
const cb = copyPlane(m, cbPtr, cw, ch, strideC);
|
||||
const cr = copyPlane(m, crPtr, cw, ch, strideC);
|
||||
return { y, cb, cr, width, height, chromaWidth: cw, chromaHeight: ch, bitDepth: bd, poc };
|
||||
}
|
||||
_extractInfo() {
|
||||
const m = this._m;
|
||||
const infoPtr = m._malloc(24);
|
||||
try {
|
||||
const ret = this._api.getInfo(this._dec, infoPtr);
|
||||
if (ret !== 0) return null;
|
||||
return {
|
||||
width: m.getValue(infoPtr, "i32"),
|
||||
height: m.getValue(infoPtr + 4, "i32"),
|
||||
bitDepth: m.getValue(infoPtr + 8, "i32"),
|
||||
chromaFormat: m.getValue(infoPtr + 12, "i32"),
|
||||
profile: m.getValue(infoPtr + 16, "i32"),
|
||||
level: m.getValue(infoPtr + 20, "i32")
|
||||
};
|
||||
} finally {
|
||||
m._free(infoPtr);
|
||||
}
|
||||
}
|
||||
// --- Incremental API (streaming) ---
|
||||
/**
|
||||
* Feed a chunk of data containing one or more complete NAL units.
|
||||
* The decoder accumulates parameter sets and decodes pictures incrementally.
|
||||
* Call drain() after each feed() to retrieve output-ready frames.
|
||||
*/
|
||||
feed(data) {
|
||||
const m = this._m;
|
||||
const ptr = m._malloc(data.length);
|
||||
try {
|
||||
m.HEAPU8.set(data, ptr);
|
||||
const ret = this._api.feed(this._dec, ptr, data.length);
|
||||
if (ret !== 0) throw new Error(`Feed failed (code ${ret})`);
|
||||
} finally {
|
||||
m._free(ptr);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Drain output-ready frames from the decoder (§C.5.2 bumping process).
|
||||
* Returns frames in display order, only when ready per DPB constraints.
|
||||
* Frames are valid until the next feed() or destroy() call.
|
||||
*/
|
||||
drain() {
|
||||
const m = this._m;
|
||||
const countPtr = m._malloc(4);
|
||||
try {
|
||||
const ret = this._api.drain(this._dec, countPtr);
|
||||
if (ret !== 0) return [];
|
||||
const count = m.getValue(countPtr, "i32");
|
||||
const frames = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const frame = this._extractDrainedFrame(i);
|
||||
if (frame) frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
} finally {
|
||||
m._free(countPtr);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Flush all remaining frames from the DPB (call at end of stream).
|
||||
* Returns all buffered frames in display order.
|
||||
*/
|
||||
flush() {
|
||||
const ret = this._api.flush(this._dec);
|
||||
if (ret !== 0) return [];
|
||||
const m = this._m;
|
||||
const countPtr = m._malloc(4);
|
||||
try {
|
||||
const frames = [];
|
||||
const framePtr = m._malloc(48);
|
||||
try {
|
||||
for (let i = 0; ; i++) {
|
||||
const r = this._api.getDrainedFrame(this._dec, i, framePtr);
|
||||
if (r !== 0) break;
|
||||
frames.push(this._readFrameFromPtr(framePtr));
|
||||
}
|
||||
} finally {
|
||||
m._free(framePtr);
|
||||
}
|
||||
return frames;
|
||||
} finally {
|
||||
m._free(countPtr);
|
||||
}
|
||||
}
|
||||
/** Release decoder resources */
|
||||
destroy() {
|
||||
if (this._dec) {
|
||||
this._api.destroy(this._dec);
|
||||
this._dec = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
function copyPlane(m, ptr, width, height, stride) {
|
||||
const out = new Uint16Array(width * height);
|
||||
const base = ptr >> 1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
out.set(m.HEAPU16.subarray(base + y * stride, base + y * stride + width), y * width);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// src/renderer.ts
|
||||
var FrameRenderer = class _FrameRenderer {
|
||||
constructor() {
|
||||
this._generator = null;
|
||||
this._writer = null;
|
||||
this._canvas = null;
|
||||
this._gl = null;
|
||||
this._program = null;
|
||||
this._texY = null;
|
||||
this._texCb = null;
|
||||
this._texCr = null;
|
||||
}
|
||||
/**
|
||||
* Check if MediaStreamTrackGenerator is available (Chrome 94+).
|
||||
* When available, frames can be piped to a <video> element.
|
||||
*/
|
||||
static get supportsMediaStream() {
|
||||
return typeof MediaStreamTrackGenerator !== "undefined";
|
||||
}
|
||||
/**
|
||||
* Get a MediaStream that can be assigned to a <video>.srcObject.
|
||||
* Only available when supportsMediaStream is true.
|
||||
*/
|
||||
getMediaStream() {
|
||||
if (!_FrameRenderer.supportsMediaStream) return null;
|
||||
if (!this._generator) {
|
||||
this._generator = new MediaStreamTrackGenerator({ kind: "video" });
|
||||
this._writer = this._generator.writable.getWriter();
|
||||
}
|
||||
return new MediaStream([this._generator]);
|
||||
}
|
||||
/**
|
||||
* Render a decoded YUV frame.
|
||||
*
|
||||
* If MediaStreamTrackGenerator is available, creates a VideoFrame and writes it.
|
||||
* Otherwise, renders to the provided canvas via WebGL.
|
||||
*/
|
||||
async renderFrame(frame, timestamp) {
|
||||
if (this._writer) {
|
||||
await this._renderToVideoFrame(frame, timestamp);
|
||||
} else if (this._gl) {
|
||||
this._renderToWebGL(frame);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Initialize WebGL canvas fallback.
|
||||
* Call this if MediaStreamTrackGenerator is not supported.
|
||||
*/
|
||||
initCanvas(canvas) {
|
||||
this._canvas = canvas;
|
||||
const gl = canvas.getContext("webgl");
|
||||
if (!gl) throw new Error("WebGL not supported");
|
||||
this._gl = gl;
|
||||
this._initWebGL(gl);
|
||||
}
|
||||
async _renderToVideoFrame(frame, timestamp) {
|
||||
const w = frame.width;
|
||||
const h = frame.height;
|
||||
const cw = frame.chromaWidth;
|
||||
const ch = frame.chromaHeight;
|
||||
const shift = frame.bitDepth > 8 ? frame.bitDepth - 8 : 0;
|
||||
const i420 = new Uint8Array(w * h + cw * ch * 2);
|
||||
let dst = 0;
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
i420[dst++] = frame.y[i] >> shift;
|
||||
}
|
||||
for (let i = 0; i < cw * ch; i++) {
|
||||
i420[dst++] = frame.cb[i] >> shift;
|
||||
}
|
||||
for (let i = 0; i < cw * ch; i++) {
|
||||
i420[dst++] = frame.cr[i] >> shift;
|
||||
}
|
||||
const videoFrame = new VideoFrame(i420, {
|
||||
format: "I420",
|
||||
codedWidth: w,
|
||||
codedHeight: h,
|
||||
timestamp
|
||||
});
|
||||
await this._writer.write(videoFrame);
|
||||
videoFrame.close();
|
||||
}
|
||||
_initWebGL(gl) {
|
||||
const vs = gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.shaderSource(vs, VERTEX_SRC);
|
||||
gl.compileShader(vs);
|
||||
const fs = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
gl.shaderSource(fs, FRAGMENT_SRC);
|
||||
gl.compileShader(fs);
|
||||
this._program = gl.createProgram();
|
||||
gl.attachShader(this._program, vs);
|
||||
gl.attachShader(this._program, fs);
|
||||
gl.linkProgram(this._program);
|
||||
gl.useProgram(this._program);
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, 1, 1, 1, 0]),
|
||||
gl.STATIC_DRAW
|
||||
);
|
||||
const aPos = gl.getAttribLocation(this._program, "a_pos");
|
||||
const aTex = gl.getAttribLocation(this._program, "a_tex");
|
||||
gl.enableVertexAttribArray(aPos);
|
||||
gl.enableVertexAttribArray(aTex);
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
|
||||
gl.vertexAttribPointer(aTex, 2, gl.FLOAT, false, 16, 8);
|
||||
this._texY = createTexture(gl, 0);
|
||||
this._texCb = createTexture(gl, 1);
|
||||
this._texCr = createTexture(gl, 2);
|
||||
gl.uniform1i(gl.getUniformLocation(this._program, "u_texY"), 0);
|
||||
gl.uniform1i(gl.getUniformLocation(this._program, "u_texCb"), 1);
|
||||
gl.uniform1i(gl.getUniformLocation(this._program, "u_texCr"), 2);
|
||||
}
|
||||
_renderToWebGL(frame) {
|
||||
const gl = this._gl;
|
||||
const canvas = this._canvas;
|
||||
// ВАЖНО: в Web Worker нет HTMLCanvasElement (DOM-интерфейс главного потока) —
|
||||
// голый `instanceof HTMLCanvasElement` кинул бы ReferenceError. Для OffscreenCanvas
|
||||
// размер не трогаем (его задаёт главный поток), рисуем по текущему canvas.width/height.
|
||||
if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
|
||||
canvas.width = frame.width;
|
||||
canvas.height = frame.height;
|
||||
}
|
||||
// viewport по размеру КАНВАСА (не кадра): для OffscreenCanvas в воркере это
|
||||
// позволяет рисовать в уменьшённый буфер, GPU сам масштабирует текстуру (LINEAR).
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
const shift = frame.bitDepth > 8 ? frame.bitDepth - 8 : 0;
|
||||
uploadPlane(gl, this._texY, 0, frame.y, frame.width, frame.height, shift);
|
||||
uploadPlane(gl, this._texCb, 1, frame.cb, frame.chromaWidth, frame.chromaHeight, shift);
|
||||
uploadPlane(gl, this._texCr, 2, frame.cr, frame.chromaWidth, frame.chromaHeight, shift);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
/** Release all resources */
|
||||
destroy() {
|
||||
this._writer?.close();
|
||||
this._generator?.stop();
|
||||
this._generator = null;
|
||||
this._writer = null;
|
||||
this._gl = null;
|
||||
this._canvas = null;
|
||||
}
|
||||
};
|
||||
function createTexture(gl, unit) {
|
||||
gl.activeTexture(gl.TEXTURE0 + unit);
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
return tex;
|
||||
}
|
||||
function uploadPlane(gl, tex, unit, data, width, height, shift) {
|
||||
gl.activeTexture(gl.TEXTURE0 + unit);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
if (shift === 0) {
|
||||
// data — Uint16Array со значениями 0..255 (little-endian: байт[2i]=значение, байт[2i+1]=0).
|
||||
// Грузим СЫРЫЕ байты как LUMINANCE_ALPHA (2 байта/тексель): шейдер берёт .r = L = младший байт = значение.
|
||||
// Так убираем per-pixel JS-цикл 16→8 бит — главный тормоз показа на 3200×1800.
|
||||
const bytes = new Uint8Array(data.buffer, data.byteOffset, width * height * 2);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE_ALPHA, width, height, 0, gl.LUMINANCE_ALPHA, gl.UNSIGNED_BYTE, bytes);
|
||||
} else {
|
||||
const u8 = new Uint8Array(width * height);
|
||||
for (let i = 0; i < width * height; i++) u8[i] = Math.min(255, data[i] >> shift);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, u8);
|
||||
}
|
||||
}
|
||||
var VERTEX_SRC = `
|
||||
attribute vec2 a_pos;
|
||||
attribute vec2 a_tex;
|
||||
varying vec2 v_tex;
|
||||
void main() {
|
||||
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||||
v_tex = a_tex;
|
||||
}
|
||||
`;
|
||||
var FRAGMENT_SRC = `
|
||||
precision mediump float;
|
||||
varying vec2 v_tex;
|
||||
uniform sampler2D u_texY;
|
||||
uniform sampler2D u_texCb;
|
||||
uniform sampler2D u_texCr;
|
||||
void main() {
|
||||
float y = texture2D(u_texY, v_tex).r;
|
||||
float cb = texture2D(u_texCb, v_tex).r - 0.5;
|
||||
float cr = texture2D(u_texCr, v_tex).r - 0.5;
|
||||
float r = y + 1.5748 * cr;
|
||||
float g = y - 0.1873 * cb - 0.4681 * cr;
|
||||
float b = y + 1.8556 * cb;
|
||||
gl_FragColor = vec4(clamp(r, 0.0, 1.0), clamp(g, 0.0, 1.0), clamp(b, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
export { HEVCDecoder, FrameRenderer };
|
||||
File diff suppressed because one or more lines are too long
+147
@@ -0,0 +1,147 @@
|
||||
// hevc-player-worker.js — HEVC-декод + рендер в OffscreenCanvas в отдельном потоке.
|
||||
// Главный поток только открывает WS и перекидывает сюда байты (transfer) — UI не блокируется.
|
||||
import { HEVCDecoder, FrameRenderer } from "./hevc-core.js";
|
||||
|
||||
// поймать тихие ошибки (в т.ч. из async renderFrame) и переслать в главный поток
|
||||
self.addEventListener("unhandledrejection", (e) => {
|
||||
self.postMessage({ type: "err", msg: "promise: " + String((e.reason && e.reason.message) || e.reason) });
|
||||
});
|
||||
|
||||
// ── строка кодека из hvcC (для информации) ──
|
||||
function hevcCodecFromHvcC(hvcC) {
|
||||
try { const p = hvcC[1] & 0x1f, t = ((hvcC[1] >> 5) & 1) ? "H" : "L", lv = hvcC[12] || 153; return `hvc1.${p}.6.${t}${lv}.B0`; }
|
||||
catch (e) { return "hvc1.1.6.L153.B0"; }
|
||||
}
|
||||
// ── VPS/SPS/PPS из hvcC → Annex-B ──
|
||||
function hvccParamSetsToAnnexB(hvcC) {
|
||||
if (hvcC.length < 23) return new Uint8Array(0);
|
||||
let o = 22; const numArrays = hvcC[o++]; const chunks = [];
|
||||
for (let a = 0; a < numArrays; a++) {
|
||||
o++;
|
||||
const num = (hvcC[o] << 8) | hvcC[o + 1]; o += 2;
|
||||
for (let n = 0; n < num; n++) { const len = (hvcC[o] << 8) | hvcC[o + 1]; o += 2; chunks.push(hvcC.subarray(o, o + len)); o += len; }
|
||||
}
|
||||
let total = 0; for (const c of chunks) total += 4 + c.length;
|
||||
const out = new Uint8Array(total); let p = 0;
|
||||
for (const c of chunks) { out[p + 3] = 1; p += 4; out.set(c, p); p += c.length; }
|
||||
return out;
|
||||
}
|
||||
// ── length-prefixed NAL → Annex-B (размер не меняется) ──
|
||||
function toAnnexB(u8) {
|
||||
const out = new Uint8Array(u8.length); out.set(u8);
|
||||
let i = 0;
|
||||
while (i + 4 <= out.length) {
|
||||
const len = ((u8[i] << 24) | (u8[i + 1] << 16) | (u8[i + 2] << 8) | u8[i + 3]) >>> 0;
|
||||
out[i] = 0; out[i + 1] = 0; out[i + 2] = 0; out[i + 3] = 1;
|
||||
if (len === 0) break; i += 4 + len;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── демуксер FLV (enhanced-RTMP) → hvcC + length-prefixed HEVC-кадры ──
|
||||
class FlvHevcDemuxer {
|
||||
constructor({ onConfig, onSample }) {
|
||||
this.onConfig = onConfig; this.onSample = onSample;
|
||||
this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false;
|
||||
}
|
||||
push(ab) {
|
||||
const inc = new Uint8Array(ab);
|
||||
if (this.buf.length === 0) { this.buf = inc; }
|
||||
else { const m = new Uint8Array(this.buf.length + inc.length); m.set(this.buf, 0); m.set(inc, this.buf.length); this.buf = m; }
|
||||
this._parse();
|
||||
}
|
||||
_u32(o) { return ((this.buf[o] << 24) | (this.buf[o + 1] << 16) | (this.buf[o + 2] << 8) | this.buf[o + 3]) >>> 0; }
|
||||
_parse() {
|
||||
const b = this.buf, len = b.length;
|
||||
if (!this.headerDone) {
|
||||
if (len < 9) return;
|
||||
const dataOffset = this._u32(5);
|
||||
this.buf = b.slice(dataOffset); this.headerDone = true;
|
||||
return this._parse();
|
||||
}
|
||||
let o = 0;
|
||||
while (true) {
|
||||
if (o + 4 + 11 > len) break;
|
||||
const ts = o + 4;
|
||||
const tagType = b[ts] & 0x1f;
|
||||
const dataSize = (b[ts + 1] << 16) | (b[ts + 2] << 8) | b[ts + 3];
|
||||
const bodyStart = ts + 11, bodyEnd = bodyStart + dataSize;
|
||||
if (bodyEnd > len) break;
|
||||
if (tagType === 9) {
|
||||
const tsMs = ((b[ts + 7] << 24) >>> 0) | (b[ts + 4] << 16) | (b[ts + 5] << 8) | b[ts + 6];
|
||||
this._videoTag(b.subarray(bodyStart, bodyEnd), tsMs);
|
||||
}
|
||||
o = bodyEnd;
|
||||
}
|
||||
if (o > 0) this.buf = b.slice(o);
|
||||
}
|
||||
_videoTag(body, tsMs) {
|
||||
if (body.length < 5) return;
|
||||
const b0 = body[0], isEx = (b0 & 0x80) !== 0;
|
||||
if (isEx) {
|
||||
const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f;
|
||||
const fourcc = String.fromCharCode(body[1], body[2], body[3], body[4]);
|
||||
if (fourcc !== "hvc1" && fourcc !== "hev1") return;
|
||||
let off = 5;
|
||||
if (packetType === 1) off += 3;
|
||||
const payload = body.subarray(off);
|
||||
if (packetType === 0) this._config(payload);
|
||||
else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs);
|
||||
} else {
|
||||
const codecId = b0 & 0x0f, frameType = (b0 >> 4) & 0x0f;
|
||||
if (codecId !== 12 && codecId !== 7) return;
|
||||
const pkt = body[1], payload = body.subarray(5);
|
||||
if (pkt === 0) this._config(payload);
|
||||
else if (pkt === 1) this._frames(payload, frameType === 1, tsMs);
|
||||
}
|
||||
}
|
||||
_config(hvcC) {
|
||||
if (this.gotConfig || hvcC.length < 13) return;
|
||||
this.gotConfig = true;
|
||||
this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC));
|
||||
}
|
||||
_frames(payload, isKey, tsMs) {
|
||||
if (!this.gotConfig || payload.length === 0) return;
|
||||
this.onSample({ data: payload.slice(), type: isKey ? "key" : "delta", timestampUs: tsMs * 1000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── состояние воркера ──
|
||||
let decoder = null, renderer = null, demux = null, canvas = null;
|
||||
let decoded = 0, statTimer = null;
|
||||
|
||||
function err(e) { self.postMessage({ type: "err", msg: String((e && e.message) || e) }); }
|
||||
|
||||
function pump() {
|
||||
let fr; try { fr = decoder.drain(); } catch (e) { return; }
|
||||
for (const f of fr) { decoded++; renderer.renderFrame(f, decoded * 40000); }
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (statTimer) { clearInterval(statTimer); statTimer = null; }
|
||||
try { renderer && renderer.destroy(); } catch (e) {}
|
||||
try { decoder && decoder.destroy(); } catch (e) {}
|
||||
renderer = null; decoder = null; demux = null; canvas = null; decoded = 0;
|
||||
}
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const m = e.data;
|
||||
try {
|
||||
if (m.type === "init") {
|
||||
canvas = m.canvas;
|
||||
decoder = await HEVCDecoder.create({ wasmUrl: m.wasmUrl, wasmBinaryUrl: m.wasmBinaryUrl });
|
||||
renderer = new FrameRenderer();
|
||||
renderer.initCanvas(canvas);
|
||||
demux = new FlvHevcDemuxer({
|
||||
onConfig: (hvcC) => { const ps = hvccParamSetsToAnnexB(hvcC); if (ps.length) { try { decoder.feed(ps); pump(); } catch (er) { err(er); } } },
|
||||
onSample: ({ data }) => { try { decoder.feed(toAnnexB(data)); pump(); } catch (er) { err(er); } },
|
||||
});
|
||||
if (statTimer) clearInterval(statTimer);
|
||||
statTimer = setInterval(() => { self.postMessage({ type: "stat", fps: decoded }); decoded = 0; }, 1000);
|
||||
self.postMessage({ type: "ready" });
|
||||
} else if (m.type === "data") {
|
||||
if (demux) demux.push(m.buf);
|
||||
} else if (m.type === "stop") {
|
||||
cleanup();
|
||||
}
|
||||
} catch (er) { err(er); }
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// Заглушка: dist/index.js статически импортит "mp4box" ради FMP4Demuxer,
|
||||
// который мы НЕ используем (у нас свой FLV-демукс). Import-map подменяет
|
||||
// "mp4box" на этот файл, чтобы модуль index.js загрузился без bare-specifier.
|
||||
export function createFile() {
|
||||
throw new Error("mp4box stub: FMP4Demuxer не используется в этом проекте");
|
||||
}
|
||||
export default { createFile };
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# go2rtc — единая точка подключения к камерам (рестрим).
|
||||
# <cam>_main — основной поток (101): единственный источник записи и live-HD.
|
||||
# <cam> — субпоток (102) для живой сетки; если сабпотока нет — ссылка на <cam>_main.
|
||||
# <cam>_h264 — субпоток в H.264 (WebRTC/браузеры без HEVC).
|
||||
# Бэкенд перегенерирует этот файл при изменении камер из UI.
|
||||
|
||||
api:
|
||||
listen: ":1984"
|
||||
rtsp:
|
||||
listen: ":8554"
|
||||
webrtc:
|
||||
listen: ":8555"
|
||||
log:
|
||||
level: info
|
||||
|
||||
streams:
|
||||
cam31_main:
|
||||
- rtsp://admin:GPTsklad2024@192.168.63.31:554/Streaming/Channels/101
|
||||
cam31:
|
||||
- rtsp://admin:GPTsklad2024@192.168.63.31:554/Streaming/Channels/102
|
||||
cam31_h264:
|
||||
- "ffmpeg:cam31#video=h264#audio=copy"
|
||||
|
||||
cam32_main:
|
||||
- rtsp://admin:GPTsklad2024@192.168.63.32:554/Streaming/Channels/101
|
||||
cam32:
|
||||
- rtsp://admin:GPTsklad2024@192.168.63.32:554/Streaming/Channels/102
|
||||
cam32_h264:
|
||||
- "ffmpeg:cam32#video=h264#audio=copy"
|
||||
|
||||
cam33_main:
|
||||
- rtsp://admin:GPTsklad2024@192.168.63.33:554/Streaming/Channels/101
|
||||
cam33:
|
||||
- rtsp://127.0.0.1:8554/cam33_main
|
||||
cam33_h264:
|
||||
- "ffmpeg:cam33#video=h264#audio=copy"
|
||||
|
||||
cam34_main:
|
||||
- rtsp://admin:GPTsklad2024@192.168.63.34:554/Streaming/Channels/101
|
||||
cam34:
|
||||
- rtsp://admin:GPTsklad2024@192.168.63.34:554/Streaming/Channels/102
|
||||
cam34_h264:
|
||||
- "ffmpeg:cam34#video=h264#audio=copy"
|
||||
Reference in New Issue
Block a user