This commit is contained in:
2026-05-30 00:08:59 +05:00
commit d3afd698b6
52 changed files with 8150 additions and 0 deletions
+19
View File
@@ -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
}
}
+3
View File
@@ -0,0 +1,3 @@
DESIGN.md
README.md
cameras.yaml
+1
View File
@@ -0,0 +1 @@
0.0.112
+17
View File
@@ -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"]
View File
View File
+282
View File
@@ -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}
+99
View File
@@ -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")
+60
View File
@@ -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}
+64
View File
@@ -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)
+247
View File
@@ -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}
+115
View File
@@ -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()
+131
View File
@@ -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}
+136
View File
@@ -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")
+44
View File
@@ -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
+104
View File
@@ -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)
View File
+61
View File
@@ -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
+109
View File
@@ -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
+139
View File
@@ -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)
+80
View File
@@ -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)
+24
View File
@@ -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()
View File
+122
View File
@@ -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))
+101
View File
@@ -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
+39
View File
@@ -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,
}
+88
View File
@@ -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)
+5
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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
+50
View File
@@ -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>
+46
View File
@@ -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>
+43
View File
@@ -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>
+66
View File
@@ -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>
+41
View File
@@ -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>
+225
View File
@@ -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
+216
View File
@@ -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>
+81
View File
@@ -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>
+448
View File
@@ -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
View File
@@ -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
View File
@@ -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
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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); }
};
+7
View File
@@ -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
View File
@@ -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"