Compare commits
45 Commits
21b26d73bd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa290a51b7 | |||
| 847c87fd7e | |||
| f263d84a8d | |||
| 6f9142a795 | |||
| cfb125784d | |||
| c408fcbe92 | |||
| 126b588dc7 | |||
| c91f82bf28 | |||
| 157893b516 | |||
| 4a8eddf1d8 | |||
| 74236fdbb0 | |||
| 0216d2c1f1 | |||
| c2b698e1f4 | |||
| ffddaaa50f | |||
| dcc4cc046f | |||
| ab817e0d31 | |||
| 02efe2ab4b | |||
| 5b8f11dbb6 | |||
| c6cdcf55b2 | |||
| b29b808c6d | |||
| a519b78eb2 | |||
| e2e03054ac | |||
| b07bf2d874 | |||
| ec41267444 | |||
| 1e6974acb8 | |||
| 20dfed0bae | |||
| abc726b5a7 | |||
| 421f873160 | |||
| 8e4844111b | |||
| a64749ccc8 | |||
| 24155a8492 | |||
| 7060f26e86 | |||
| 81a5b58eb9 | |||
| 90e216e30b | |||
| 650a531f21 | |||
| 44f5aa920f | |||
| c876a46e61 | |||
| 5828873910 | |||
| fcddc0c470 | |||
| d1ef54af27 | |||
| 00cfca1a57 | |||
| 346d3b5b96 | |||
| 3dc2bdd631 | |||
| 1da4f2e1b6 | |||
| 32f1c82e2b |
@@ -0,0 +1,2 @@
|
||||
# shell-скрипты всегда с LF — CRLF ломает shebang/exec в Linux-контейнере
|
||||
*.sh text eol=lf
|
||||
@@ -1,3 +1,2 @@
|
||||
DESIGN.md
|
||||
README.md
|
||||
cameras.yaml
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -12,7 +13,7 @@ from ..config import Camera, save_config
|
||||
from ..runtime import runtime
|
||||
from ..services import go2rtc
|
||||
from ..transcode import (encode_args, norm_profile, norm_bitrate, norm_preset,
|
||||
norm_keyint, norm_codec)
|
||||
norm_keyint, norm_codec, norm_rc, norm_qp)
|
||||
|
||||
router = APIRouter(prefix="/api/cameras", tags=["cameras"])
|
||||
|
||||
@@ -32,11 +33,13 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera:
|
||||
id=cam_id,
|
||||
name=(body.get("name") or cam_id).strip(),
|
||||
enabled=bool(body.get("enabled", True)),
|
||||
record=bool(body.get("record", True)),
|
||||
ip=ip,
|
||||
user=user,
|
||||
password=password,
|
||||
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
|
||||
sub_path=sub,
|
||||
storage_id=(body.get("storage_id") or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
@@ -49,11 +52,13 @@ def _camera_payload(cam_id: str) -> dict:
|
||||
"id": cam.id,
|
||||
"name": cam.name,
|
||||
"enabled": cam.enabled,
|
||||
"record": cam.record,
|
||||
"ip": cam.ip,
|
||||
"user": cam.user,
|
||||
"password": cam.password,
|
||||
"main_path": cam.main_path,
|
||||
"sub_path": cam.sub_path,
|
||||
"storage_id": cam.storage_id,
|
||||
"has_sub": has_sub,
|
||||
"status": status.to_dict() if status else None,
|
||||
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
|
||||
@@ -86,6 +91,8 @@ async def camera_live_transcoded(
|
||||
preset: str = Query(None),
|
||||
keyint: str = Query(None),
|
||||
codec: str = Query(None),
|
||||
rc: str = Query(None),
|
||||
qp: str = Query(None),
|
||||
stream: str = Query("sub"),
|
||||
):
|
||||
"""Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента.
|
||||
@@ -103,7 +110,8 @@ async def camera_live_transcoded(
|
||||
"-fflags", "nobuffer", "-flags", "low_delay",
|
||||
"-rtsp_transport", "tcp", "-i", src,
|
||||
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate),
|
||||
low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)),
|
||||
low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint),
|
||||
rc=norm_rc(rc), qp=norm_qp(qp)),
|
||||
"-an",
|
||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||
"-f", "mp4", "pipe:1",
|
||||
@@ -251,6 +259,7 @@ async def create_camera(request: Request) -> dict:
|
||||
runtime.config.cameras.append(cam)
|
||||
save_config(runtime.config)
|
||||
runtime.supervisor.add_camera(cam)
|
||||
if cam.enabled:
|
||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||
return _camera_payload(cam_id)
|
||||
|
||||
@@ -266,19 +275,29 @@ async def update_camera(cam_id: str, request: Request) -> dict:
|
||||
runtime.config.cameras[idx] = cam
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.replace_camera(cam)
|
||||
if cam.enabled:
|
||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||
else:
|
||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||
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):
|
||||
cams = runtime.config.cameras
|
||||
idx = next((i for i, c in enumerate(cams) if c.id == cam_id), None)
|
||||
if idx is None:
|
||||
raise HTTPException(404, "camera not found")
|
||||
runtime.config.cameras = [c for c in runtime.config.cameras if c.id != cam_id]
|
||||
# удаление из середины сдвигает слоты последующих камер → их папки записи
|
||||
# (номер слота) меняются. Перезапускаем запись, чтобы ffmpeg писал в новую папку.
|
||||
shifts = idx < len(cams) - 1
|
||||
runtime.config.cameras = [c for c in cams 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)
|
||||
if shifts:
|
||||
await runtime.supervisor.restart_all()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -295,3 +314,38 @@ async def clear_cameras(request: Request) -> dict:
|
||||
await runtime.supervisor.remove_camera(cam_id)
|
||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||
return {"ok": True, "removed": len(ids)}
|
||||
|
||||
|
||||
@router.post("/{cam_id}/toggle")
|
||||
async def toggle_camera(cam_id: str, request: Request) -> dict:
|
||||
"""Тумблеры камеры: enabled (вкл/выкл камеру) и/или record (REC — запись на диск).
|
||||
|
||||
enabled=False — камера выключена полностью: убираем go2rtc-потоки → нет live и записи
|
||||
(камера остаётся зарегистрированной). record управляет только записью. go2rtc трогаем
|
||||
лишь при смене enabled, поэтому переключение REC не мигает live."""
|
||||
require_role(request, "admin")
|
||||
cam = runtime.config.camera(cam_id)
|
||||
if not cam:
|
||||
raise HTTPException(404, "camera not found")
|
||||
body = await request.json()
|
||||
kw = {}
|
||||
if "enabled" in body:
|
||||
kw["enabled"] = bool(body["enabled"])
|
||||
if "record" in body:
|
||||
kw["record"] = bool(body["record"])
|
||||
if "storage_id" in body: # смена хранилища записи камеры
|
||||
kw["storage_id"] = (body["storage_id"] or "").strip()
|
||||
if not kw:
|
||||
raise HTTPException(400, "ожидается enabled, record и/или storage_id")
|
||||
enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled
|
||||
new = replace(cam, **kw)
|
||||
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
|
||||
runtime.config.cameras[idx] = new
|
||||
save_config(runtime.config)
|
||||
if enabled_changed: # потоки go2rtc только при смене вкл/выкл камеры
|
||||
if new.enabled:
|
||||
await go2rtc.add_camera_streams(runtime.config, new)
|
||||
else:
|
||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||
await runtime.supervisor.replace_camera(new) # рекордер: репойнт на новое хранилище / enabled AND record
|
||||
return {"ok": True, "enabled": new.enabled, "record": new.record, "storage_id": new.storage_id}
|
||||
|
||||
@@ -9,7 +9,8 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from ..runtime import runtime
|
||||
from ..transcode import (H264_PROFILES, BITRATES, encode_args, norm_profile,
|
||||
norm_bitrate, norm_preset, norm_keyint, norm_codec)
|
||||
norm_bitrate, norm_preset, norm_keyint, norm_codec,
|
||||
norm_rc, norm_qp)
|
||||
|
||||
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
|
||||
|
||||
@@ -53,6 +54,8 @@ async def recording_transcoded(
|
||||
preset: str = Query(None),
|
||||
keyint: str = Query(None),
|
||||
codec: str = Query(None),
|
||||
rc: str = Query(None),
|
||||
qp: str = Query(None),
|
||||
start: float = Query(0.0),
|
||||
):
|
||||
"""Перекодирование H.265→H.264 на лету (fragmented mp4) — для браузеров без HEVC.
|
||||
@@ -71,7 +74,8 @@ async def recording_transcoded(
|
||||
*(["-ss", f"{start:.3f}"] if start and start > 0 else []),
|
||||
"-i", rec.path,
|
||||
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate),
|
||||
preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)),
|
||||
preset=norm_preset(preset), keyint_sec=norm_keyint(keyint),
|
||||
rc=norm_rc(rc), qp=norm_qp(qp)),
|
||||
"-an",
|
||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||
"-f", "mp4", "pipe:1",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""REST: управление хранилищами записи — список, CRUD, выбор активного, политика.
|
||||
|
||||
Несколько хранилищ (local-папка или NAS/SMB) + одно активное (куда пишем новые записи).
|
||||
Квота — на каждом хранилище. NAS монтируется на ХОСТЕ (backend не монтирует сам), здесь
|
||||
лишь хранятся параметры и генерируется готовая команда mount/fstab."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from ..auth import require_role
|
||||
from ..config import StorageItem, save_config
|
||||
from ..runtime import runtime
|
||||
from ..services.system import is_mounted, storage_disk
|
||||
|
||||
router = APIRouter(prefix="/api/storages", tags=["storages"])
|
||||
|
||||
ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||
STORAGES_ROOT = "/storages" # общий bind-mount корень для доп. хранилищ
|
||||
HOST_STORAGES_ROOT = "/opt/nvr/storages" # он же на хосте (для команды монтирования NAS)
|
||||
ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────
|
||||
async def _item_payload(item: StorageItem, active: str) -> dict:
|
||||
nas = item.type == "nas"
|
||||
return {
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"path": item.path,
|
||||
"quota_gb": item.quota_gb,
|
||||
"server": item.server,
|
||||
"share": item.share,
|
||||
"username": item.username,
|
||||
"password": item.password,
|
||||
"subpath": item.subpath,
|
||||
"enabled": item.enabled,
|
||||
"active": item.id == active,
|
||||
"mounted": is_mounted(item.path) if nas else True,
|
||||
"used_bytes": await runtime.db.total_size_under(item.path),
|
||||
"disk": storage_disk(item.path),
|
||||
}
|
||||
|
||||
|
||||
def _nas_mount_help(item: StorageItem) -> dict:
|
||||
"""Готовая команда монтирования SMB на хосте + строка fstab (монтаж — на хосте)."""
|
||||
host_path = f"{HOST_STORAGES_ROOT}/{item.id}"
|
||||
src = f"//{item.server}/{item.share}"
|
||||
if item.subpath:
|
||||
src += "/" + item.subpath.strip("/")
|
||||
cred = f"username={item.username},password={item.password}"
|
||||
opts = f"{cred},uid=0,gid=0,vers=3.0,iocharset=utf8"
|
||||
return {
|
||||
"host_path": host_path,
|
||||
"mount_cmd": f"sudo mkdir -p {host_path} && sudo mount -t cifs {src} {host_path} -o {opts}",
|
||||
"fstab": f"{src} {host_path} cifs {opts},_netdev,nofail 0 0",
|
||||
"note": "После монтирования на хосте выполните `docker compose up -d` (или включите "
|
||||
"rshared-проброс), чтобы том стал виден в контейнере.",
|
||||
}
|
||||
|
||||
|
||||
# ── список ──────────────────────────────────────────────────────
|
||||
@router.get("")
|
||||
async def list_storages() -> dict:
|
||||
cfg = runtime.config
|
||||
active = cfg.storage.active
|
||||
items = [await _item_payload(s, active) for s in cfg.storage.items]
|
||||
return {
|
||||
"storages": items,
|
||||
"active": active,
|
||||
"retention_days": cfg.storage.retention_days,
|
||||
"segment_seconds": cfg.storage.segment_seconds,
|
||||
}
|
||||
|
||||
|
||||
# ── политика (глобально): срок хранения и длина сегмента ─────────
|
||||
@router.post("/policy")
|
||||
async def update_policy(request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
body = await request.json()
|
||||
st = runtime.config.storage
|
||||
kw: dict = {}
|
||||
seg_changed = False
|
||||
if "segment_seconds" in body:
|
||||
try:
|
||||
seg = int(body["segment_seconds"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "segment_seconds должен быть числом")
|
||||
if seg not in ALLOWED_SEGMENTS:
|
||||
raise HTTPException(400, "допустимо 60 / 120 / 300 / 600 / 900 секунд")
|
||||
kw["segment_seconds"] = seg
|
||||
seg_changed = seg != st.segment_seconds
|
||||
if "retention_days" in body:
|
||||
try:
|
||||
rd = int(body["retention_days"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "retention_days должен быть числом")
|
||||
if rd < 0 or rd > 3650:
|
||||
raise HTTPException(400, "retention_days в диапазоне 0..3650")
|
||||
kw["retention_days"] = rd
|
||||
if not kw:
|
||||
raise HTTPException(400, "ожидается segment_seconds и/или retention_days")
|
||||
runtime.config.storage = replace(st, **kw)
|
||||
save_config(runtime.config)
|
||||
if seg_changed:
|
||||
await runtime.supervisor.restart_all()
|
||||
return {
|
||||
"ok": True,
|
||||
"segment_seconds": runtime.config.storage.segment_seconds,
|
||||
"retention_days": runtime.config.storage.retention_days,
|
||||
}
|
||||
|
||||
|
||||
# ── CRUD ────────────────────────────────────────────────────────
|
||||
@router.post("")
|
||||
async def create_storage(request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
body = await request.json()
|
||||
sid = (body.get("id") or "").strip()
|
||||
if not ID_RE.match(sid):
|
||||
raise HTTPException(400, "id: разрешены латиница, цифры, _ и - (до 32 символов)")
|
||||
if runtime.config.storage_by_id(sid):
|
||||
raise HTTPException(409, "хранилище с таким id уже существует")
|
||||
stype = body.get("type", "local")
|
||||
if stype not in ("local", "nas"):
|
||||
raise HTTPException(400, "type: local | nas")
|
||||
name = (body.get("name") or sid).strip()
|
||||
quota = max(0, int(body.get("quota_gb") or 0))
|
||||
helper = None
|
||||
|
||||
if stype == "local":
|
||||
# путь = подпапка общего корня /storages/<id>; допускаем явный path (напр. /records)
|
||||
path = (body.get("path") or "").strip() or f"{STORAGES_ROOT}/{sid}"
|
||||
item = StorageItem(id=sid, name=name, type="local", path=path, quota_gb=quota)
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise HTTPException(400, f"не удалось создать папку {path}: {exc}")
|
||||
else:
|
||||
server = (body.get("server") or "").strip()
|
||||
share = (body.get("share") or "").strip()
|
||||
if not server or not share:
|
||||
raise HTTPException(400, "для NAS обязательны server и share")
|
||||
path = f"{STORAGES_ROOT}/{sid}"
|
||||
item = StorageItem(
|
||||
id=sid, name=name, type="nas", path=path, quota_gb=quota,
|
||||
server=server, share=share,
|
||||
username=(body.get("username") or "").strip(),
|
||||
password=body.get("password") or "",
|
||||
subpath=(body.get("subpath") or "").strip(),
|
||||
)
|
||||
# создаём точку монтирования на хосте (через bind-mount /storages) — писать туда
|
||||
# рекордер начнёт только когда SMB реально смонтируют (guard по os.path.ismount)
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
helper = _nas_mount_help(item)
|
||||
|
||||
new_items = runtime.config.storage.items + (item,)
|
||||
runtime.config.storage = replace(runtime.config.storage, items=new_items)
|
||||
save_config(runtime.config)
|
||||
out = await _item_payload(item, runtime.config.storage.active)
|
||||
if helper:
|
||||
out["mount_help"] = helper
|
||||
return out
|
||||
|
||||
|
||||
@router.put("/{sid}")
|
||||
async def update_storage(sid: str, request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
item = runtime.config.storage_by_id(sid)
|
||||
if not item:
|
||||
raise HTTPException(404, "storage not found")
|
||||
body = await request.json()
|
||||
kw: dict = {}
|
||||
if "name" in body:
|
||||
kw["name"] = (body.get("name") or item.name).strip()
|
||||
if "quota_gb" in body:
|
||||
kw["quota_gb"] = max(0, int(body.get("quota_gb") or 0))
|
||||
if "enabled" in body:
|
||||
kw["enabled"] = bool(body["enabled"])
|
||||
for f in ("server", "share", "username", "subpath"): # метаданные NAS
|
||||
if f in body:
|
||||
kw[f] = (body.get(f) or "").strip()
|
||||
if "password" in body:
|
||||
kw["password"] = body.get("password") or ""
|
||||
if not kw:
|
||||
raise HTTPException(400, "нет полей для изменения")
|
||||
# id/type/path неизменны (привязаны к записям на диске → не ломаем архив)
|
||||
new = replace(item, **kw)
|
||||
items = list(runtime.config.storage.items)
|
||||
idx = next(i for i, s in enumerate(items) if s.id == sid)
|
||||
items[idx] = new
|
||||
runtime.config.storage = replace(runtime.config.storage, items=tuple(items))
|
||||
save_config(runtime.config)
|
||||
# quota применится при следующем проходе retention; path не менялся → рекордеры не трогаем
|
||||
out = await _item_payload(new, runtime.config.storage.active)
|
||||
if new.type == "nas":
|
||||
out["mount_help"] = _nas_mount_help(new)
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/{sid}")
|
||||
async def delete_storage(sid: str, request: Request) -> dict:
|
||||
"""Убирает хранилище из списка. Файлы и записи в БД НЕ трогаются (архив сохраняется)."""
|
||||
require_role(request, "admin")
|
||||
if not runtime.config.storage_by_id(sid):
|
||||
raise HTTPException(404, "storage not found")
|
||||
if sid == runtime.config.storage.active:
|
||||
raise HTTPException(409, "нельзя удалить активное хранилище — сначала выберите другое")
|
||||
if len(runtime.config.storage.items) <= 1:
|
||||
raise HTTPException(409, "нельзя удалить последнее хранилище")
|
||||
items = tuple(s for s in runtime.config.storage.items if s.id != sid)
|
||||
runtime.config.storage = replace(runtime.config.storage, items=items)
|
||||
save_config(runtime.config)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{sid}/activate")
|
||||
async def activate_storage(sid: str, request: Request) -> dict:
|
||||
"""Делает хранилище активным (новые записи идут туда). Перезапускает рекордеры."""
|
||||
require_role(request, "admin")
|
||||
item = runtime.config.storage_by_id(sid)
|
||||
if not item:
|
||||
raise HTTPException(404, "storage not found")
|
||||
runtime.config.storage = replace(runtime.config.storage, active=sid)
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.restart_all() # репойнт рекордеров на новый путь
|
||||
mounted = is_mounted(item.path) if item.type == "nas" else True
|
||||
return {"ok": True, "active": sid, "mounted": mounted}
|
||||
@@ -3,16 +3,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -36,25 +33,3 @@ async def get_system() -> dict:
|
||||
@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}
|
||||
|
||||
+162
-11
@@ -18,6 +18,8 @@ class Camera:
|
||||
password: str
|
||||
main_path: str
|
||||
sub_path: str | None = None
|
||||
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
|
||||
storage_id: str = "" # в какое хранилище писать ("" = активное/по умолчанию)
|
||||
|
||||
def rtsp_url(self, path: str) -> str:
|
||||
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
|
||||
@@ -28,12 +30,45 @@ class Camera:
|
||||
return self.rtsp_url(self.main_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageItem:
|
||||
"""Одно хранилище записи: локальная папка или сетевой диск (SMB/NAS).
|
||||
|
||||
`path` — путь ВНУТРИ контейнера, куда пишутся сегменты. `quota_gb` — лимит
|
||||
размера ЭТОГО хранилища (0 = без лимита). Для NAS поля server/share/username/
|
||||
password/subpath — метаданные (монтаж делается на хосте, backend ими не
|
||||
монтирует, только показывает статус и генерирует команду mount).
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
type: str # "local" | "nas"
|
||||
path: str
|
||||
quota_gb: int = 0
|
||||
server: str = ""
|
||||
share: str = ""
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
subpath: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Storage:
|
||||
path: str = "/records"
|
||||
quota_gb: int = 50
|
||||
"""Глобальная политика записи + список хранилищ и id активного.
|
||||
|
||||
Квота — на каждом StorageItem (не здесь). retention_days/segment_seconds —
|
||||
глобальные: срок хранения по возрасту и длина сегмента для всех хранилищ.
|
||||
"""
|
||||
active: str = "local"
|
||||
retention_days: int = 7
|
||||
segment_seconds: int = 600
|
||||
items: tuple[StorageItem, ...] = ()
|
||||
|
||||
|
||||
# Дефолтное хранилище (когда конфиг пуст / битый) — совпадает с историческим /records.
|
||||
_DEFAULT_STORAGE = StorageItem(
|
||||
id="local", name="Локальное хранилище", type="local", path="/records", quota_gb=30,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -54,28 +89,127 @@ class Config:
|
||||
def enabled_cameras(self) -> list[Camera]:
|
||||
return [c for c in self.cameras if c.enabled]
|
||||
|
||||
# ── слоты записи ────────────────────────────────────────────
|
||||
# Папка камеры в хранилище = номер её слота (позиция в списке, 1-based) с
|
||||
# ведущим нулём: слот 5 → «05», слот 12 → «12». Так совпадает со столбцом ID в UI.
|
||||
def slot_of(self, cam_id: str) -> int | None:
|
||||
for i, c in enumerate(self.cameras, start=1):
|
||||
if c.id == cam_id:
|
||||
return i
|
||||
return None
|
||||
|
||||
def archive_dir(self, cam_id: str) -> str:
|
||||
"""Имя подпапки записи камеры в хранилище. Откат на id, если камеры нет в списке."""
|
||||
slot = self.slot_of(cam_id)
|
||||
return f"{slot:02d}" if slot is not None else cam_id
|
||||
|
||||
def camera_by_archive_dir(self, name: str) -> Camera | None:
|
||||
"""Обратное: имя папки-слота («05») → камера в этом слоте. None для нечисловых
|
||||
(легаси-папки по старому id индексируются под своим именем как раньше)."""
|
||||
if name.isdigit():
|
||||
idx = int(name)
|
||||
if 1 <= idx <= len(self.cameras):
|
||||
return self.cameras[idx - 1]
|
||||
return None
|
||||
|
||||
# ── хранилища ───────────────────────────────────────────────
|
||||
def storage_by_id(self, sid: str) -> StorageItem | None:
|
||||
return next((s for s in self.storage.items if s.id == sid), None)
|
||||
|
||||
def active_storage(self) -> StorageItem:
|
||||
"""Хранилище, куда пишутся новые записи (fallback — первый/дефолтный)."""
|
||||
return (
|
||||
self.storage_by_id(self.storage.active)
|
||||
or (self.storage.items[0] if self.storage.items else _DEFAULT_STORAGE)
|
||||
)
|
||||
|
||||
def storage_paths(self) -> list[str]:
|
||||
"""Пути всех хранилищ — индексатор сканирует их все (старые записи не теряются)."""
|
||||
return [s.path for s in self.storage.items] or [_DEFAULT_STORAGE.path]
|
||||
|
||||
|
||||
def load_config(path: str | None = None) -> Config:
|
||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||
# Толерантно к отсутствию/повреждению: если файла нет, на его месте каталог
|
||||
# (битый bind-mount) или YAML невалиден — стартуем без камер (добавляются из UI),
|
||||
# а не падаем с ошибкой на старте.
|
||||
raw: dict = {}
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
raw = {}
|
||||
|
||||
storage = Storage(**(raw.get("storage") or {}))
|
||||
storage = _storage_from_raw(raw.get("storage") or {})
|
||||
live = Live(**(raw.get("live") or {}))
|
||||
cameras = [
|
||||
Camera(
|
||||
cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])]
|
||||
return Config(storage=storage, live=live, cameras=cameras)
|
||||
|
||||
|
||||
def _camera_from_raw(c: dict) -> Camera:
|
||||
# Миграция со старой схемы: раньше `enabled` означал «писать на диск» (live был всегда),
|
||||
# отдельного «камера выключена» не существовало. Старый файл (без ключа record) трактуем
|
||||
# так: запись = старый enabled, а сама камера включена. Новый файл (с record) — как есть.
|
||||
if "record" in c:
|
||||
enabled, record = bool(c.get("enabled", True)), bool(c["record"])
|
||||
else:
|
||||
enabled, record = True, bool(c.get("enabled", True))
|
||||
return Camera(
|
||||
id=c["id"],
|
||||
name=c.get("name", c["id"]),
|
||||
enabled=c.get("enabled", True),
|
||||
enabled=enabled,
|
||||
record=record,
|
||||
ip=c["ip"],
|
||||
user=c["user"],
|
||||
password=c["password"],
|
||||
main_path=c["main_path"],
|
||||
sub_path=c.get("sub_path"),
|
||||
storage_id=c.get("storage_id", "") or "",
|
||||
)
|
||||
|
||||
|
||||
def _storage_item_from_raw(s: dict) -> StorageItem:
|
||||
return StorageItem(
|
||||
id=s["id"],
|
||||
name=s.get("name", s["id"]),
|
||||
type=s.get("type", "local"),
|
||||
path=s["path"],
|
||||
quota_gb=int(s.get("quota_gb", 0) or 0),
|
||||
server=s.get("server", "") or "",
|
||||
share=s.get("share", "") or "",
|
||||
username=s.get("username", "") or "",
|
||||
password=s.get("password", "") or "",
|
||||
subpath=s.get("subpath", "") or "",
|
||||
enabled=bool(s.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
def _storage_from_raw(raw: dict) -> Storage:
|
||||
# Новая схема: есть список items.
|
||||
if raw.get("items"):
|
||||
items = tuple(_storage_item_from_raw(s) for s in raw["items"])
|
||||
active = raw.get("active") or (items[0].id if items else "local")
|
||||
return Storage(
|
||||
active=active,
|
||||
retention_days=int(raw.get("retention_days", 7)),
|
||||
segment_seconds=int(raw.get("segment_seconds", 600)),
|
||||
items=items,
|
||||
)
|
||||
# Миграция со старой одиночной схемы {path, quota_gb, retention_days, segment_seconds}:
|
||||
# один локальный item, старый глобальный quota → квота этого хранилища.
|
||||
legacy_path = raw.get("path") or "/records"
|
||||
legacy_quota = int(raw.get("quota_gb", 30) or 30)
|
||||
local = StorageItem(
|
||||
id="local", name="Локальное хранилище", type="local",
|
||||
path=legacy_path, quota_gb=legacy_quota,
|
||||
)
|
||||
return Storage(
|
||||
active="local",
|
||||
retention_days=int(raw.get("retention_days", 7)),
|
||||
segment_seconds=int(raw.get("segment_seconds", 600)),
|
||||
items=(local,),
|
||||
)
|
||||
for c in (raw.get("cameras") or [])
|
||||
]
|
||||
return Config(storage=storage, live=live, cameras=cameras)
|
||||
|
||||
|
||||
def camera_to_yaml(c: Camera) -> dict:
|
||||
@@ -83,23 +217,40 @@ def camera_to_yaml(c: Camera) -> dict:
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"enabled": c.enabled,
|
||||
"record": c.record,
|
||||
"ip": c.ip,
|
||||
"user": c.user,
|
||||
"password": c.password,
|
||||
"main_path": c.main_path,
|
||||
"sub_path": c.sub_path,
|
||||
"storage_id": c.storage_id,
|
||||
}
|
||||
|
||||
|
||||
def storage_item_to_yaml(s: StorageItem) -> dict:
|
||||
d = {
|
||||
"id": s.id, "name": s.name, "type": s.type,
|
||||
"path": s.path, "quota_gb": s.quota_gb,
|
||||
}
|
||||
if s.type == "nas":
|
||||
d.update({
|
||||
"server": s.server, "share": s.share, "username": s.username,
|
||||
"password": s.password, "subpath": s.subpath,
|
||||
})
|
||||
if not s.enabled:
|
||||
d["enabled"] = False
|
||||
return d
|
||||
|
||||
|
||||
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,
|
||||
"active": config.storage.active,
|
||||
"retention_days": config.storage.retention_days,
|
||||
"segment_seconds": config.storage.segment_seconds,
|
||||
"items": [storage_item_to_yaml(s) for s in config.storage.items],
|
||||
},
|
||||
"live": {"go2rtc_url": config.live.go2rtc_url},
|
||||
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
||||
|
||||
@@ -8,6 +8,14 @@ import aiosqlite
|
||||
|
||||
from .models import Recording
|
||||
|
||||
|
||||
def _like_prefix(prefix: str) -> str:
|
||||
"""LIKE-шаблон для путей внутри хранилища: '<path>/%' с экранированием %, _, \\."""
|
||||
p = prefix.rstrip("/") + "/"
|
||||
p = p.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
return p + "%"
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -105,6 +113,24 @@ class Database:
|
||||
rows = await cur.fetchall()
|
||||
return [Recording(**dict(r)) for r in rows]
|
||||
|
||||
# ── per-storage (по префиксу пути) — для квоты на каждое хранилище ──
|
||||
async def total_size_under(self, prefix: str) -> int:
|
||||
cur = await self.conn.execute(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM recordings WHERE path LIKE ? ESCAPE '\\'",
|
||||
(_like_prefix(prefix),),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
async def oldest_recordings_under(self, prefix: str, limit: int = 50) -> list[Recording]:
|
||||
cur = await self.conn.execute(
|
||||
"SELECT * FROM recordings WHERE path LIKE ? ESCAPE '\\' "
|
||||
"ORDER BY started_at ASC LIMIT ?",
|
||||
(_like_prefix(prefix), 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,)
|
||||
|
||||
+23
-7
@@ -17,7 +17,7 @@ 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 import cameras, recordings, storages, system, ws
|
||||
from .api.ws import ConnectionManager
|
||||
from .services import go2rtc
|
||||
|
||||
@@ -28,6 +28,13 @@ logging.basicConfig(
|
||||
log = logging.getLogger("nvr")
|
||||
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||
# Мобильный dashboard: тот же бэкенд слушает один порт, но в docker проброшен второй
|
||||
# хост-порт (8443→8090). Запрос на него приходит с Host ".. :8443" → отдаём mobile.html.
|
||||
MOBILE_PORT = os.environ.get("NVR_MOBILE_PORT", "8443")
|
||||
|
||||
|
||||
def _is_mobile(request: Request) -> bool:
|
||||
return request.headers.get("host", "").endswith(":" + MOBILE_PORT)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -46,25 +53,31 @@ async def lifespan(app: FastAPI):
|
||||
runtime.indexer = Indexer(runtime.config, runtime.db)
|
||||
runtime.retention = Retention(runtime.config, runtime.db)
|
||||
|
||||
os.makedirs(runtime.config.storage.path, exist_ok=True)
|
||||
# каталоги локальных хранилищ; NAS не создаём — ждём монтаж на хосте (guard в recorder_task)
|
||||
for st in runtime.config.storage.items:
|
||||
if st.type == "local":
|
||||
os.makedirs(st.path, exist_ok=True)
|
||||
await go2rtc.sync_all(runtime.config) # потоки (вкл. <cam>_main) в go2rtc для рестрима
|
||||
runtime.reconciler = go2rtc.Reconciler(runtime.config) # самовосстановление потоков go2rtc
|
||||
runtime.supervisor.start_all()
|
||||
runtime.indexer.start()
|
||||
runtime.retention.start()
|
||||
log.info("NVR запущен: камер=%d, хранилище=%s",
|
||||
len(runtime.config.cameras), runtime.config.storage.path)
|
||||
runtime.reconciler.start()
|
||||
log.info("NVR запущен: камер=%d, активное хранилище=%s",
|
||||
len(runtime.config.cameras), runtime.config.active_storage().path)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
log.info("остановка NVR…")
|
||||
await runtime.reconciler.stop()
|
||||
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)
|
||||
app = FastAPI(title="CoRE.Vizion+", version="1.0", lifespan=lifespan)
|
||||
|
||||
# публичные пути (без авторизации)
|
||||
PUBLIC_PATHS = {"/login", "/api/login", "/api/health"}
|
||||
@@ -96,14 +109,17 @@ app.include_router(auth.router)
|
||||
app.include_router(prefs.router)
|
||||
app.include_router(cameras.router)
|
||||
app.include_router(recordings.router)
|
||||
app.include_router(storages.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"))
|
||||
async def index(request: Request) -> FileResponse:
|
||||
# на мобильном порту — мобильный dashboard (Live одной камеры + Архив)
|
||||
page = "mobile.html" if _is_mobile(request) else "index.html"
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, page))
|
||||
|
||||
|
||||
@app.get("/archive")
|
||||
|
||||
@@ -5,19 +5,22 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from ..config import Camera, Storage
|
||||
from ..config import Camera
|
||||
|
||||
|
||||
def record_args(cam: Camera, storage: Storage, input_url: str | None = None) -> list[str]:
|
||||
def record_args(cam: Camera, out_root: str, segment_seconds: int,
|
||||
input_url: str | None = None, sub_dir: str | None = None) -> list[str]:
|
||||
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
||||
|
||||
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
||||
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
||||
sub_dir — имя подпапки камеры (номер слота, напр. «05»); по умолчанию — id камеры.
|
||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
||||
"""
|
||||
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
|
||||
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
|
||||
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
|
||||
out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
|
||||
# Подпапка даты: <root>/<sub_dir>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
|
||||
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
|
||||
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
|
||||
out_template = os.path.join(out_root, sub_dir or cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
@@ -29,7 +32,7 @@ def record_args(cam: Camera, storage: Storage, input_url: str | None = None) ->
|
||||
"-an", # звук не пишем (камеры обычно без него)
|
||||
"-c:v", "copy",
|
||||
"-f", "segment",
|
||||
"-segment_time", str(storage.segment_seconds),
|
||||
"-segment_time", str(segment_seconds),
|
||||
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
|
||||
"-segment_clocktime_offset", "0",
|
||||
"-segment_format", "mp4",
|
||||
|
||||
@@ -10,7 +10,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..config import Config
|
||||
from ..db import Database
|
||||
@@ -32,22 +32,34 @@ def _parse_started_at(path: str) -> int:
|
||||
|
||||
|
||||
def _scan_segments(root: str) -> dict[str, list[str]]:
|
||||
"""Возвращает {camera_id: [пути mp4, отсортированы]}."""
|
||||
"""Возвращает {имя_папки: [пути mp4, отсортированы]} (папка = номер слота или
|
||||
легаси-id камеры — сопоставление в камеру делает вызывающий).
|
||||
|
||||
Файлы лежат в подпапке даты <folder>/<YYYY-MM-DD>/*.mp4; старые «плоские»
|
||||
<folder>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
|
||||
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
|
||||
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)
|
||||
for folder in os.listdir(root):
|
||||
cam_dir = os.path.join(root, folder)
|
||||
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()
|
||||
files: list[str] = []
|
||||
for entry in os.listdir(cam_dir):
|
||||
p = os.path.join(cam_dir, entry)
|
||||
if entry.endswith(".mp4") and os.path.isfile(p):
|
||||
files.append(p) # старый плоский формат
|
||||
elif os.path.isdir(p): # подпапка даты
|
||||
for fn in os.listdir(p):
|
||||
if fn.endswith(".mp4"):
|
||||
files.append(os.path.join(p, fn))
|
||||
# сортируем по ИМЕНИ файла (YYYY-MM-DD_HH-MM-SS) — хронологически независимо от того,
|
||||
# лежит файл плоско или в подпапке даты (полный путь сортировал бы '/' < '_' неверно,
|
||||
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
|
||||
files.sort(key=os.path.basename)
|
||||
if files:
|
||||
result[cam_id] = files
|
||||
result[folder] = files
|
||||
return result
|
||||
|
||||
|
||||
@@ -74,20 +86,54 @@ class Indexer:
|
||||
async def _loop(self) -> None:
|
||||
while not self._stopping:
|
||||
try:
|
||||
self._ensure_date_dirs()
|
||||
await self.scan_once()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("indexer scan failed")
|
||||
await asyncio.sleep(SCAN_INTERVAL)
|
||||
|
||||
def _ensure_date_dirs(self) -> None:
|
||||
"""Создаёт каталоги даты (сегодня+завтра) для пишущих камер заранее: ffmpeg
|
||||
не умеет -strftime_mkdir, без готового каталога запись встала бы в полночь."""
|
||||
today = datetime.now()
|
||||
days = [today.strftime("%Y-%m-%d"), (today + timedelta(days=1)).strftime("%Y-%m-%d")]
|
||||
for cam in self.config.cameras:
|
||||
if not (cam.enabled and cam.record):
|
||||
continue
|
||||
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
||||
if store.type == "nas" and not os.path.ismount(store.path):
|
||||
continue
|
||||
sub = self.config.archive_dir(cam.id) # папка-слот, напр. «05»
|
||||
for d in days:
|
||||
try:
|
||||
os.makedirs(os.path.join(store.path, sub, d), exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def scan_once(self) -> int:
|
||||
root = self.config.storage.path
|
||||
by_cam = _scan_segments(root)
|
||||
active_path = self.config.active_storage().path
|
||||
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 root in self.config.storage_paths():
|
||||
by_dir = _scan_segments(root)
|
||||
is_active = (root == active_path)
|
||||
for folder, files in by_dir.items():
|
||||
# Сопоставление папки с камерой по приоритету:
|
||||
# 1) точное совпадение с id камеры — легаси-папка по id (в т.ч. числовому),
|
||||
# иначе папка камеры с id «5» спуталась бы со слотом 5;
|
||||
# 2) папка-слот «NN» → камера в этом слоте (новая схема);
|
||||
# 3) иначе оставляем имя папки (осиротевший архив индексируется как раньше).
|
||||
cam = self.config.camera(folder) or self.config.camera_by_archive_dir(folder)
|
||||
cam_id = cam.id if cam else folder
|
||||
# последний файл ещё пишется только в активной папке-слоте ТЕКУЩЕЙ камеры;
|
||||
# легаси-папки и неактивное хранилище дописаны — индексируем целиком.
|
||||
being_written = (
|
||||
is_active and cam is not None
|
||||
and self.config.archive_dir(cam.id) == folder
|
||||
)
|
||||
complete = files[:-1] if being_written else files
|
||||
for path in complete:
|
||||
if path in known:
|
||||
continue
|
||||
|
||||
@@ -6,8 +6,9 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..config import Camera, Storage
|
||||
from ..config import Camera, StorageItem
|
||||
from ..models import CameraState, CameraStatus
|
||||
from .ffmpeg import record_args
|
||||
|
||||
@@ -21,9 +22,12 @@ MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => сч
|
||||
class RecorderTask:
|
||||
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
||||
|
||||
def __init__(self, cam: Camera, storage: Storage, on_status=None, input_url: str | None = None):
|
||||
def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int,
|
||||
on_status=None, input_url: str | None = None, sub_dir: str | None = None):
|
||||
self.cam = cam
|
||||
self.storage = storage
|
||||
self.store = store # активное хранилище (куда пишем)
|
||||
self.segment_seconds = segment_seconds
|
||||
self.sub_dir = sub_dir or cam.id # папка камеры в хранилище (номер слота, напр. «05»)
|
||||
self.input_url = input_url # источник записи (рестрим go2rtc)
|
||||
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
||||
self.status = CameraStatus(
|
||||
@@ -69,11 +73,33 @@ class RecorderTask:
|
||||
# ── основной цикл ───────────────────────────────────────────
|
||||
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:
|
||||
# NAS: пишем только если том реально смонтирован на хосте. Иначе запись
|
||||
# ушла бы в overlay контейнера (потеря при рестарте) — ждём монтаж, каталог
|
||||
# тоже не создаём.
|
||||
if self.store.type == "nas" and not os.path.ismount(self.store.path):
|
||||
self._set_state(CameraState.RETRYING, "NAS не смонтирован")
|
||||
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
try:
|
||||
# подпапки даты <slot>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
|
||||
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
|
||||
base = os.path.join(self.store.path, self.sub_dir)
|
||||
now = datetime.now()
|
||||
for d in (now, now + timedelta(days=1)):
|
||||
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
|
||||
except OSError as exc:
|
||||
self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
|
||||
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
self._set_state(CameraState.STARTING)
|
||||
args = record_args(self.cam, self.storage, self.input_url)
|
||||
args = record_args(self.cam, self.store.path, self.segment_seconds,
|
||||
self.input_url, sub_dir=self.sub_dir)
|
||||
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
|
||||
try:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
|
||||
@@ -21,16 +21,23 @@ class Supervisor:
|
||||
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
|
||||
return go2rtc.restream_main_url(self.config, cam.id)
|
||||
|
||||
def _new_task(self, cam: Camera) -> RecorderTask:
|
||||
# хранилище камеры: её storage_id, иначе активное (по умолчанию). Несуществующий
|
||||
# id (удалённое хранилище) → тоже откат на активное.
|
||||
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
||||
return RecorderTask(cam, store, self.config.storage.segment_seconds,
|
||||
on_status=self.on_status, input_url=self._input_url(cam),
|
||||
sub_dir=self.config.archive_dir(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))
|
||||
task = self._new_task(cam)
|
||||
self.tasks[cam.id] = task
|
||||
if cam.enabled:
|
||||
if cam.enabled and cam.record:
|
||||
task.start()
|
||||
log.info("recorder started: %s", cam.id)
|
||||
else:
|
||||
log.info("recorder skipped (disabled): %s", cam.id)
|
||||
log.info("recorder skipped (enabled=%s record=%s): %s", cam.enabled, cam.record, cam.id)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
for task in self.tasks.values():
|
||||
@@ -57,10 +64,9 @@ class Supervisor:
|
||||
|
||||
def add_camera(self, cam: Camera) -> None:
|
||||
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
|
||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
||||
input_url=self._input_url(cam))
|
||||
task = self._new_task(cam)
|
||||
self.tasks[cam.id] = task
|
||||
if cam.enabled:
|
||||
if cam.enabled and cam.record:
|
||||
task.start()
|
||||
log.info("recorder added: %s", cam.id)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
|
||||
from .recorder.indexer import Indexer
|
||||
from .recorder.supervisor import Supervisor
|
||||
from .services.retention import Retention
|
||||
from .services.go2rtc import Reconciler
|
||||
from .api.ws import ConnectionManager
|
||||
|
||||
|
||||
@@ -18,6 +19,7 @@ class Runtime:
|
||||
supervisor: "Supervisor"
|
||||
indexer: "Indexer"
|
||||
retention: "Retention"
|
||||
reconciler: "Reconciler"
|
||||
ws: "ConnectionManager"
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ go2rtc-потоки на камеру:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
@@ -27,6 +28,7 @@ from ..config import Camera, Config
|
||||
log = logging.getLogger("nvr.go2rtc")
|
||||
|
||||
GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml")
|
||||
RECONCILE_INTERVAL = 30 # сек — период сверки рантайма go2rtc с конфигом
|
||||
|
||||
|
||||
def _rtsp_host(config: Config) -> str:
|
||||
@@ -58,6 +60,8 @@ def _streams_for(cam: Camera) -> dict[str, str]:
|
||||
def build_streams(config: Config) -> dict:
|
||||
streams: dict = {}
|
||||
for c in config.cameras:
|
||||
if not c.enabled: # выключенная камера — без потоков (ни live, ни записи)
|
||||
continue
|
||||
for name, src in _streams_for(c).items():
|
||||
streams[name] = [src]
|
||||
return streams
|
||||
@@ -118,5 +122,80 @@ async def sync_all(config: Config) -> None:
|
||||
write_go2rtc_yaml(config)
|
||||
base = config.live.go2rtc_url.rstrip("/")
|
||||
for cam in config.cameras:
|
||||
if not cam.enabled:
|
||||
continue
|
||||
for name, src in _streams_for(cam).items():
|
||||
await _call("PUT", _put(base, name, src))
|
||||
|
||||
|
||||
def _get_stream_names(base: str) -> set[str]:
|
||||
"""Имена потоков, реально загруженных в рантайм go2rtc (/api/streams)."""
|
||||
req = urllib.request.Request(f"{base}/api/streams", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8") or "{}")
|
||||
return set(data.keys())
|
||||
|
||||
|
||||
async def reconcile(config: Config) -> int:
|
||||
"""Дотягивает в go2rtc недостающие потоки (самовосстановление).
|
||||
|
||||
go2rtc читает go2rtc.yaml только при старте, а рантайм-таблицу держит через API.
|
||||
Если её опустошить (DELETE при чистке камер, перезапуск go2rtc со старым/пустым
|
||||
файлом, молча провалившийся стартовый sync_all) — рестрим начинает отдавать 404,
|
||||
и live, и запись «слепнут». Здесь сверяем желаемый набор с /api/streams и PUT-им
|
||||
ТОЛЬКО отсутствующие — существующие не трогаем, чтобы не рвать активных
|
||||
producer'ов/consumer'ов. Возвращает число восстановленных потоков."""
|
||||
if not config.cameras:
|
||||
return 0
|
||||
base = config.live.go2rtc_url.rstrip("/")
|
||||
try:
|
||||
existing = await asyncio.to_thread(_get_stream_names, base)
|
||||
except Exception as exc: # noqa: BLE001 — go2rtc может быть недоступен/перезапускаться
|
||||
log.debug("go2rtc reconcile: /api/streams недоступен: %s", exc)
|
||||
return 0
|
||||
added = 0
|
||||
for cam in config.cameras:
|
||||
if not cam.enabled: # выключенные камеры в go2rtc не держим
|
||||
continue
|
||||
for name, src in _streams_for(cam).items():
|
||||
if name not in existing:
|
||||
await _call("PUT", _put(base, name, src))
|
||||
added += 1
|
||||
if added:
|
||||
log.info("go2rtc reconcile: восстановлено потоков %d", added)
|
||||
write_go2rtc_yaml(config) # и файл приводим в соответствие — для будущего рестарта
|
||||
return added
|
||||
|
||||
|
||||
class Reconciler:
|
||||
"""Фоновая сверка потоков go2rtc с конфигом каждые RECONCILE_INTERVAL секунд.
|
||||
|
||||
Держит ссылку на тот же объект Config, что и остальной рантайм (его .cameras
|
||||
мутируется in-place при add/clear), поэтому всегда видит актуальный список."""
|
||||
|
||||
def __init__(self, config: Config, interval: int = RECONCILE_INTERVAL):
|
||||
self.config = config
|
||||
self.interval = interval
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stopping = False
|
||||
|
||||
def start(self) -> None:
|
||||
self._stopping = False
|
||||
self._task = asyncio.create_task(self._loop(), name="go2rtc-reconcile")
|
||||
|
||||
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:
|
||||
await asyncio.sleep(self.interval)
|
||||
try:
|
||||
await reconcile(self.config)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("go2rtc reconcile failed")
|
||||
|
||||
@@ -61,19 +61,21 @@ class Retention:
|
||||
return len(old)
|
||||
|
||||
async def _enforce_quota(self) -> int:
|
||||
quota = self.config.storage.quota_gb * 1024 ** 3
|
||||
if not quota:
|
||||
return 0
|
||||
# квота — на КАЖДОЕ хранилище отдельно (0 = без лимита). Для каждого удаляем
|
||||
# самые старые записи ВНУТРИ него, пока его размер не уложится в его квоту.
|
||||
removed = 0
|
||||
# удаляем самые старые, пока суммарный размер не уложится в квоту
|
||||
while await self.db.total_size() > quota:
|
||||
batch = await self.db.oldest_recordings(limit=20)
|
||||
for item in self.config.storage.items:
|
||||
if not item.quota_gb:
|
||||
continue
|
||||
quota = item.quota_gb * 1024 ** 3
|
||||
while await self.db.total_size_under(item.path) > quota:
|
||||
batch = await self.db.oldest_recordings_under(item.path, 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:
|
||||
if await self.db.total_size_under(item.path) <= quota:
|
||||
break
|
||||
return removed
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
@@ -11,19 +12,31 @@ from ..config import Config
|
||||
_START = time.time()
|
||||
|
||||
|
||||
def system_stats(config: Config) -> dict:
|
||||
path = config.storage.path
|
||||
def storage_disk(path: str) -> dict:
|
||||
"""Использование диска, на котором лежит path (для одного хранилища)."""
|
||||
try:
|
||||
usage = shutil.disk_usage(path)
|
||||
disk = {
|
||||
return {
|
||||
"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}
|
||||
return {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
|
||||
|
||||
|
||||
def is_mounted(path: str) -> bool:
|
||||
"""Точка монтирования? (для NAS — признак, что том реально подключён)."""
|
||||
try:
|
||||
return os.path.ismount(path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def system_stats(config: Config) -> dict:
|
||||
active = config.active_storage()
|
||||
path = active.path
|
||||
vm = psutil.virtual_memory()
|
||||
return {
|
||||
"uptime_s": int(time.time() - _START),
|
||||
@@ -32,8 +45,8 @@ def system_stats(config: Config) -> dict:
|
||||
"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,
|
||||
"disk": storage_disk(path),
|
||||
"quota_gb": active.quota_gb,
|
||||
"retention_days": config.storage.retention_days,
|
||||
"segment_seconds": config.storage.segment_seconds,
|
||||
}
|
||||
|
||||
+63
-22
@@ -1,17 +1,25 @@
|
||||
"""Общие параметры перекодирования H.264 (профиль, битрейт, пресет, GOP)."""
|
||||
"""Общие параметры перекодирования H.264/VP9: профиль, битрейт, пресет, GOP, режим RC."""
|
||||
from __future__ import annotations
|
||||
|
||||
H264_PROFILES = ("baseline", "main", "high")
|
||||
BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k")
|
||||
PRESETS = ("ultrafast", "superfast")
|
||||
PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium")
|
||||
KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды
|
||||
CODECS = ("h264", "vp9")
|
||||
RC_MODES = ("vbr", "cbr", "qp") # контроль битрейта: переменный / постоянный / постоянный квантизатор
|
||||
|
||||
DEFAULT_PROFILE = "main"
|
||||
DEFAULT_BITRATE = "2000k"
|
||||
DEFAULT_PRESET = "superfast"
|
||||
DEFAULT_KEYINT = 2
|
||||
DEFAULT_CODEC = "h264"
|
||||
DEFAULT_RC = "vbr"
|
||||
DEFAULT_QP = 23
|
||||
QP_MIN, QP_MAX = 0, 51
|
||||
|
||||
# пресет x264 → cpu-used VP9 (больше число = быстрее кодирование, ниже качество)
|
||||
_VP9_CPU = {"ultrafast": "8", "superfast": "8", "veryfast": "7",
|
||||
"faster": "6", "fast": "5", "medium": "4"}
|
||||
|
||||
|
||||
def norm_profile(p: str | None) -> str:
|
||||
@@ -30,6 +38,10 @@ def norm_preset(p: str | None) -> str:
|
||||
return p if p in PRESETS else DEFAULT_PRESET
|
||||
|
||||
|
||||
def norm_rc(m: str | None) -> str:
|
||||
return m if m in RC_MODES else DEFAULT_RC
|
||||
|
||||
|
||||
def norm_keyint(k) -> int:
|
||||
try:
|
||||
k = int(k)
|
||||
@@ -38,51 +50,80 @@ def norm_keyint(k) -> int:
|
||||
return k if k in KEYINTS else DEFAULT_KEYINT
|
||||
|
||||
|
||||
def x264_args(profile: str, bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None) -> list[str]:
|
||||
"""Аргументы libx264: профиль, битрейт, пресет и интервал ключевых кадров.
|
||||
def norm_qp(q) -> int:
|
||||
try:
|
||||
q = int(q)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_QP
|
||||
return max(QP_MIN, min(QP_MAX, q))
|
||||
|
||||
Ключевые кадры расставляются по времени (через -force_key_frames), поэтому
|
||||
интервал в секундах соблюдается независимо от fps камеры."""
|
||||
bufsize = f"{int(bitrate[:-1]) * 2}k"
|
||||
|
||||
def _kbps(bitrate: str) -> int:
|
||||
return int(bitrate[:-1])
|
||||
|
||||
|
||||
def x264_args(profile: str, bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None,
|
||||
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
|
||||
"""Аргументы libx264: профиль, пресет, GOP и режим контроля битрейта.
|
||||
|
||||
rc: vbr — целевой битрейт с потолком (-b:v/-maxrate); cbr — постоянный
|
||||
битрейт (nal-hrd=cbr, min=max=b:v); qp — постоянный квантизатор (-qp, без
|
||||
битрейта). Ключевые кадры расставляются по времени (-force_key_frames)."""
|
||||
preset = norm_preset(preset)
|
||||
sec = norm_keyint(keyint_sec)
|
||||
rc = norm_rc(rc)
|
||||
args = [
|
||||
"-c:v", "libx264",
|
||||
"-preset", preset,
|
||||
"-profile:v", profile,
|
||||
"-b:v", bitrate,
|
||||
"-maxrate", bitrate,
|
||||
"-bufsize", bufsize,
|
||||
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
||||
"-pix_fmt", "yuv420p",
|
||||
]
|
||||
if rc == "qp":
|
||||
args += ["-qp", str(norm_qp(qp))]
|
||||
elif rc == "cbr":
|
||||
kb = _kbps(bitrate)
|
||||
args += ["-b:v", bitrate, "-minrate", bitrate, "-maxrate", bitrate,
|
||||
"-bufsize", f"{kb}k", "-x264-params", "nal-hrd=cbr"]
|
||||
else: # vbr
|
||||
kb = _kbps(bitrate)
|
||||
args += ["-b:v", bitrate, "-maxrate", bitrate, "-bufsize", f"{kb * 2}k"]
|
||||
if low_latency:
|
||||
args += ["-tune", "zerolatency"]
|
||||
return args
|
||||
|
||||
|
||||
def vp9_args(bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None) -> list[str]:
|
||||
"""Аргументы кодировщика libvpx-VP9 (профиль H.264 неприменим)."""
|
||||
preset: str | None = None, keyint_sec=None,
|
||||
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
|
||||
"""Аргументы libvpx-VP9 (профиль H.264 неприменим).
|
||||
|
||||
rc: vbr/cbr — по битрейту; qp — постоянное качество (-crf N -b:v 0, у VP9
|
||||
нет прямого -qp). cpu-used маппится из пресета. realtime для live."""
|
||||
sec = norm_keyint(keyint_sec)
|
||||
# быстрый пресет → выше cpu-used (быстрее, ниже качество); realtime для live
|
||||
cpu = "8" if norm_preset(preset) == "ultrafast" else "6"
|
||||
return [
|
||||
rc = norm_rc(rc)
|
||||
args = [
|
||||
"-c:v", "libvpx-vp9",
|
||||
"-b:v", bitrate,
|
||||
"-maxrate", bitrate,
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", cpu,
|
||||
"-cpu-used", _VP9_CPU.get(norm_preset(preset), "6"),
|
||||
"-row-mt", "1",
|
||||
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
||||
"-pix_fmt", "yuv420p",
|
||||
]
|
||||
if rc == "qp":
|
||||
args += ["-crf", str(norm_qp(qp)), "-b:v", "0"]
|
||||
elif rc == "cbr":
|
||||
args += ["-b:v", bitrate, "-minrate", bitrate, "-maxrate", bitrate]
|
||||
else: # vbr
|
||||
args += ["-b:v", bitrate, "-maxrate", bitrate]
|
||||
return args
|
||||
|
||||
|
||||
def encode_args(codec: str, profile: str, bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None) -> list[str]:
|
||||
preset: str | None = None, keyint_sec=None,
|
||||
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
|
||||
"""Аргументы видеокодировщика под выбранный кодек (h264 | vp9)."""
|
||||
if norm_codec(codec) == "vp9":
|
||||
return vp9_args(bitrate, low_latency, preset, keyint_sec)
|
||||
return x264_args(profile, bitrate, low_latency, preset, keyint_sec)
|
||||
return vp9_args(bitrate, low_latency, preset, keyint_sec, rc, qp)
|
||||
return x264_args(profile, bitrate, low_latency, preset, keyint_sec, rc, qp)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDNzCCAh+gAwIBAgIUNxd5IcLXh/oNSAiUMUwDm/zwLD0wDQYJKoZIhvcNAQEL
|
||||
BQAwGjEYMBYGA1UEAwwPQ29SRS5WaXNpb24gTlZSMB4XDTI2MDUyOTExNTQzMloX
|
||||
DTM2MDUyNjExNTQzMlowGjEYMBYGA1UEAwwPQ29SRS5WaXNpb24gTlZSMIIBIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtL5c7I4d0aAardYj4BhHPfN6UxbP
|
||||
wJegCMLXP5SCTzx1S9XvMCVmZuGdtc2/lLkHR80Z4fco1fOL+sUVxyvEcnLziaNq
|
||||
lYlzLRNCMcvgBQHaKm3YxlR68g3ELD9GqdcwhL3hsVTl6EJhanWSUPhgqmkUL51p
|
||||
J1EUZAbr3eR1vI3l/sN3xXpmUnxYkqzlTcyOpJxAiFSAqr77x4D8YMMnonukyFRh
|
||||
vEAzJAWSsG9naB8Qg7IbMkTKu1omM7xvAnPi6Bfq+YU2TcTtDEcOOXInOudR/9Fl
|
||||
ZVVe2uUh51mI87d8d7MCTDHzpk5p/DR/ImDd2X/+rf2eTLkifnAjuowlZQIDAQAB
|
||||
o3UwczAdBgNVHQ4EFgQU8KrHNWn07q6EFNuB2pi7Q7hEQTswHwYDVR0jBBgwFoAU
|
||||
8KrHNWn07q6EFNuB2pi7Q7hEQTswDwYDVR0TAQH/BAUwAwEB/zAgBgNVHREEGTAX
|
||||
hwTAqIJPhwTAqIJTgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggEBAJ+GWCMw
|
||||
kp+W+KaFgIuWiN/t/bqSTheqdw0Qvwg9tm6+WSwEClKn9Nj9TKOaDqIxYVQ/BQbE
|
||||
r6GbPeFyx33ThMeGlHC5DznMFptVF4LTam89wchw+Kz3ZASryAZurHhbGUeTtWZb
|
||||
fHeNgY9cRKpi+/Z5U4x2jHrg8o3eZjPeWlgby6aDvIpAAdvmYXy1aQrfUYJThkeG
|
||||
4lwjinL6zwB+/13n3gqBR6SXfDbUu4W21BYqR+KuuqnCaUvs44WLST79xsP4JuJu
|
||||
F7y2JpQEV/l2WmKO/wVWQxV+2cfQb1e5QQ81Q2ZVoQyxl4l8HekfTDGdXDbj1y2u
|
||||
O0FJGZanhcCNmIg=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0vlzsjh3RoBqt
|
||||
1iPgGEc983pTFs/Al6AIwtc/lIJPPHVL1e8wJWZm4Z21zb+UuQdHzRnh9yjV84v6
|
||||
xRXHK8RycvOJo2qViXMtE0Ixy+AFAdoqbdjGVHryDcQsP0ap1zCEveGxVOXoQmFq
|
||||
dZJQ+GCqaRQvnWknURRkBuvd5HW8jeX+w3fFemZSfFiSrOVNzI6knECIVICqvvvH
|
||||
gPxgwyeie6TIVGG8QDMkBZKwb2doHxCDshsyRMq7WiYzvG8Cc+LoF+r5hTZNxO0M
|
||||
Rw45cic651H/0WVlVV7a5SHnWYjzt3x3swJMMfOmTmn8NH8iYN3Zf/6t/Z5MuSJ+
|
||||
cCO6jCVlAgMBAAECggEAEZPt6kehWMYPB4Gq+DFZmbR4h2TRjEkdaZy2swVDbbyo
|
||||
f66Xq4Fc+yZ4CxAD0M1J4E7EdKMal+wusehsR3Qsj8uO1H+jGatGG0fN3pVAUocC
|
||||
llvGgvYBBadsa7ffrHGJfhoQErSkrg4+oSGRZZ8yRNXL/nF694roG8OcjsuYM7UY
|
||||
e0Gi8In+n6Y56ZTT/UibdeGyezrBtr9D5fhNsBXVXxgYBvQhNSSRWhJ684jKBz/4
|
||||
K5e+EOXdrBjYcaGWFiRFN0cJ1hgneXbtZY3j86TgvFtwyrgig58bXiH3O+LmNM1B
|
||||
/0ZqkDrqQJ47locyX/K0UVHvxTpyxRN7prO8wzbQxQKBgQDrTrodQ+ZEVlFMdBVO
|
||||
1kptIRDF+hxwccvMMDHyakRPi/9lBfExH2ldd//fLUI1KZZWfVo8C0J5+dXx5d5A
|
||||
5Ct0raM+jrRd6vBf3ec9tpC4VU+tPSnOrn1AiW7AmECjvnbuiQ3hW7N8vYGRHeyj
|
||||
AUJbd9xXRXDOBO4ypiJHo0aESwKBgQDEo0k4yX2RFUObHZXFUmKSQGHW7NooxR05
|
||||
9SdJ/1nfqyiRCdyoFHlIhNdSohnQYwEVx6Ko4+HaN2Fz+UdUrKr35//olFjZSPYK
|
||||
n+xfYmg516y5cGYtSkrwHlhtyv0EVa/jIMt6xsvv8jFYgnwlQ7YwfzDRXHAplkCJ
|
||||
0VcGiL4PDwKBgCgXS8J8tRjjlApwpMi/3gJl5dO1X28RFGX/uCLTVDwxYBw4PPXf
|
||||
ojYYofHGZkdkhIbL6LneOT9K/9atEYcA2R6SDwQzkCuIQdgzmJh9KH8fmemsSBk8
|
||||
xX5fbA1IY4sCgoT1uPWyiAwyxYaSEKVdK48mBtafsC6JzIO4ppKKEROtAoGBAIuM
|
||||
jjRc2l4SRy5YKqgktYuxYT4UTSbN7NXq430iWPfhEiMMaqpmDUSn8d/U5pj7ChQY
|
||||
35kYUHp6/xA9AqBXAeZ5oSW/0eYyX1pe8HMo9WrHYu8fk4Pky5XpEzyn6DQhU3fP
|
||||
GFkDqEubB+YWhGA106BLQ6vw/DCnTxn/lNEwTiGFAoGAHejoj3dx/6LEYa2J28Lr
|
||||
4Eh8s0ag0Lg6OpzDft1teZE1v4SPMWymqducECkTLh3JwkBm9adOXckRdK3D4HSX
|
||||
Mm06s1+hF6NzLGv5OEySGxpKJOXYRvzMUQAyu6ZgafNDfvGGZ658OH+dvJYB1zLH
|
||||
ecnSb37SiQb0slju08Bo+g8=
|
||||
-----END PRIVATE KEY-----
|
||||
+8
-4
@@ -10,7 +10,8 @@ services:
|
||||
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://<сервер>
|
||||
- "443:8090" # десктоп-дашборд на https://<сервер>
|
||||
- "8443:8090" # мобильный dashboard https://<сервер>:8443 (тот же бэкенд, mobile.html по Host)
|
||||
volumes:
|
||||
- ./certs:/app/certs # self-signed TLS-сертификат (rw: entrypoint генерирует при отсутствии)
|
||||
- ./VERSION:/app/VERSION:ro # номер версии (показывается в UI)
|
||||
@@ -20,10 +21,13 @@ services:
|
||||
- ./backend/app:/app/app # код бэкенда (правка без пересборки образа)
|
||||
- ./frontend:/app/frontend # фронтенд (правка без пересборки)
|
||||
- ./data:/data # SQLite-индекс архива
|
||||
# Запись. Локальная папка ./records ИЛИ смонтированный на хост NAS (SMB/NFS).
|
||||
# Для NAS: смонтируйте на хосте (например /mnt/nas/cctv) и замените левую часть:
|
||||
# - /mnt/nas/cctv:/records
|
||||
# Запись. ./records — дефолтное локальное хранилище (id=local).
|
||||
- ./records:/records
|
||||
# Общий корень доп. хранилищ (вкладка «Хранилища»): локальные подпапки
|
||||
# /storages/<id> и точки монтирования NAS. NAS монтируется на ХОСТЕ в
|
||||
# ./storages/<id>; после `mount` на хосте выполните `docker compose up -d`,
|
||||
# чтобы том стал виден здесь (или добавьте :rshared для live-подхвата).
|
||||
- ./storages:/storages
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
NVR_CONFIG: /app/cameras.yaml
|
||||
|
||||
+18
-22
@@ -3,48 +3,44 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Архив</title>
|
||||
<title>CoRE.Vizion+ — Архив</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/player.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">
|
||||
<aside class="arch-side">
|
||||
<div class="arch-cams" id="arch-cams"></div>
|
||||
<div class="arch-date">
|
||||
<input type="date" id="date" class="arch-dateinput">
|
||||
<div class="arch-cal" id="calendar"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="arch-content" id="arch-content">
|
||||
<div class="player-wrap" id="player">
|
||||
<div class="hint">Кликните по таймлайну ниже, чтобы смотреть запись</div>
|
||||
</div>
|
||||
<div class="timeline" id="timeline"></div>
|
||||
<footer class="grid-bar" id="arch-bar">
|
||||
<div class="gb-left"></div>
|
||||
<div class="gb-center"></div>
|
||||
<div class="gb-right">
|
||||
<button class="gb-btn" id="arch-fs" title="Полный экран" aria-label="Полный экран">⛶</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/player.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-8
@@ -3,17 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Камеры</title>
|
||||
<title>CoRE.Vizion+ — Камеры</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>
|
||||
|
||||
+26
-20
@@ -3,38 +3,44 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Live view</title>
|
||||
<title>CoRE.Vizion+ — 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>
|
||||
<main id="live-main">
|
||||
<div class="grid g4" id="grid"></div>
|
||||
<footer class="grid-bar" id="grid-bar">
|
||||
<div class="gb-left"></div>
|
||||
<div class="gb-center">
|
||||
<button class="gb-btn" id="gb-prev" title="Предыдущая страница" aria-label="Предыдущая страница">⏮</button>
|
||||
<button class="gb-btn gb-play" id="gb-play" title="Просмотр / стоп" aria-label="Просмотр / стоп">⏸</button>
|
||||
<button class="gb-btn" id="gb-next" title="Следующая страница" aria-label="Следующая страница">⏭</button>
|
||||
</div>
|
||||
<div class="gb-right">
|
||||
<div class="gb-split">
|
||||
<button class="gb-btn" id="gb-split" title="Раскладка сетки" aria-label="Раскладка сетки">▦</button>
|
||||
<div class="gb-split-menu" id="gb-split-menu">
|
||||
<button data-grid="1">1</button>
|
||||
<button data-grid="2">4</button>
|
||||
<button data-grid="3">9</button>
|
||||
<button data-grid="4">16</button>
|
||||
<button data-grid="5">25</button>
|
||||
<button data-grid="6">36</button>
|
||||
<button data-grid="7">49</button>
|
||||
<button data-grid="8">64</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="gb-btn" id="gb-fs" title="Полный экран" aria-label="Полный экран">⛶</button>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-21
@@ -3,32 +3,13 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Раскладка</title>
|
||||
<title>CoRE.Vizion+ — Раскладка</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="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<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>
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Вход</title>
|
||||
<title>CoRE.Vizion+ — Вход</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>
|
||||
<div class="brand login-brand">CoRE<span>.Vizion+</span></div>
|
||||
<input type="text" id="username" placeholder="Логин" autocomplete="username" autofocus>
|
||||
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
|
||||
<button type="submit">Войти</button>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#161a22">
|
||||
<title>CoRE.Vizion+ — Mobile</title>
|
||||
<link rel="stylesheet" href="/static/player.css">
|
||||
<link rel="stylesheet" href="/static/mobile.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="m-app">
|
||||
<header class="m-head">
|
||||
<button class="m-burger" id="m-burger" aria-label="Меню">☰</button>
|
||||
<span class="m-title" id="m-title">Live</span>
|
||||
</header>
|
||||
|
||||
<!-- бутерброд-меню: выбор режима (Live / Архив) -->
|
||||
<div class="m-scrim" id="m-scrim" hidden></div>
|
||||
<aside class="m-drawer" id="m-drawer">
|
||||
<div class="m-drawer-head"><span class="m-brand">CoRE<span>.Vizion+</span></span></div>
|
||||
<button class="m-nav active" data-view="live"><span class="i">▦</span>Live</button>
|
||||
<button class="m-nav" data-view="arch"><span class="i">🗄</span>Архив</button>
|
||||
</aside>
|
||||
|
||||
<!-- LIVE: одна камера + выбор камеры (плеер только пауза/плей) -->
|
||||
<section class="m-view" id="view-live">
|
||||
<div class="m-bar">
|
||||
<select id="m-live-cam" class="m-sel" title="Камера"></select>
|
||||
</div>
|
||||
<div class="m-stage"><div class="player-wrap" id="m-live-box"></div></div>
|
||||
</section>
|
||||
|
||||
<!-- АРХИВ: камера + дата + список записей (плеер с перемоткой) -->
|
||||
<section class="m-view" id="view-arch" hidden>
|
||||
<div class="m-bar">
|
||||
<select id="m-arch-cam" class="m-sel" title="Камера"></select>
|
||||
<input type="date" id="m-arch-date" class="m-date" title="Дата">
|
||||
</div>
|
||||
<div class="m-stage"><div class="player-wrap" id="m-arch-box"></div></div>
|
||||
<div class="m-seglist" id="m-seglist"></div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="/static/player.js"></script>
|
||||
<script src="/static/mobile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+113
-60
@@ -3,29 +3,13 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Настройки</title>
|
||||
<title>CoRE.Vizion+ — Настройки</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>
|
||||
@@ -34,7 +18,9 @@
|
||||
<main class="settings-main">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="cams" data-min-role="admin">Камеры</button>
|
||||
<button class="tab" data-tab="storage" data-min-role="admin">Хранилища</button>
|
||||
<button class="tab" data-tab="layout">Раскладка</button>
|
||||
<button class="tab" data-tab="player" data-min-role="operator">Плеер</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>
|
||||
@@ -48,56 +34,137 @@
|
||||
<div style="display:flex;gap:8px">
|
||||
<button id="bulk-edit">Изменить</button>
|
||||
<button id="bulk-cancel" hidden>Отмена</button>
|
||||
<button id="add-cam">+ Добавить камеру</button>
|
||||
<button id="clear-cams" class="danger" title="Удалить все камеры (архив не трогается)">Очистить все</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="cams">
|
||||
<table class="cams cams-fixed">
|
||||
<thead>
|
||||
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th>Статус</th><th></th></tr>
|
||||
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th style="width:56px">Вкл</th><th style="width:64px">Запись</th><th style="width:150px">Хранилище</th><th data-bulk-label="Логин / пароль">Статус</th><th style="width:62px"></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">
|
||||
<!-- ВКЛАДКА: Хранилища (управление записью) -->
|
||||
<div class="tab-panel" data-panel="storage" data-min-role="admin" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Хранилища записи</h2>
|
||||
<button id="stor-add">+ Добавить хранилище</button>
|
||||
</div>
|
||||
<div class="muted" style="margin-bottom:12px">
|
||||
Активное хранилище — куда пишутся новые записи. Квота задаётся на каждое хранилище
|
||||
(0 = без лимита) и правится прямо в таблице. После смены активного старые записи
|
||||
остаются доступны в Архиве. Удаление хранилища убирает его из списка — файлы записей
|
||||
НЕ удаляются.
|
||||
</div>
|
||||
<table class="cams">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:74px">Активно</th>
|
||||
<th style="width:90px">ID</th>
|
||||
<th>Название</th>
|
||||
<th style="width:56px">Тип</th>
|
||||
<th>Путь</th>
|
||||
<th style="width:130px">Квота, ГБ</th>
|
||||
<th style="width:150px">Занято / диск</th>
|
||||
<th style="width:130px">Статус</th>
|
||||
<th style="width:48px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stor-rows"></tbody>
|
||||
</table>
|
||||
<div class="muted" id="stor-msg" style="margin-top:10px;font-size:12px"></div>
|
||||
</div>
|
||||
|
||||
<!-- форма добавления (скрыта по умолчанию) -->
|
||||
<div class="card" id="stor-form-card" hidden>
|
||||
<h2>Новое хранилище</h2>
|
||||
<div class="cam-edit-grid">
|
||||
<label>ID<input type="text" id="sf-id" placeholder="arch2"></label>
|
||||
<label>Название<input type="text" id="sf-name" placeholder="Архив 2"></label>
|
||||
<label>Тип
|
||||
<select id="sf-type">
|
||||
<option value="local">Локальное (папка)</option>
|
||||
<option value="nas">NAS (SMB)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Квота, ГБ (0 = без лимита)<input type="number" id="sf-quota" min="0" value="0"></label>
|
||||
<label class="sf-local">Папка<input type="text" id="sf-path" placeholder="/storages/arch2 (по умолчанию)"></label>
|
||||
<label class="sf-nas" hidden>SMB сервер (IP)<input type="text" id="sf-server" placeholder="192.168.1.10"></label>
|
||||
<label class="sf-nas" hidden>Шара (share)<input type="text" id="sf-share" placeholder="cctv"></label>
|
||||
<label class="sf-nas" hidden>Логин<input type="text" id="sf-user"></label>
|
||||
<label class="sf-nas" hidden>Пароль<input type="password" id="sf-pass"></label>
|
||||
<label class="sf-nas" hidden>Подпапка (необяз.)<input type="text" id="sf-subpath" placeholder="cam-archive"></label>
|
||||
</div>
|
||||
<div class="cam-edit-foot">
|
||||
<span class="err" id="sf-err"></span>
|
||||
<button id="sf-save" class="primary">Создать</button>
|
||||
<button id="sf-cancel">Отмена</button>
|
||||
</div>
|
||||
<div class="muted" id="sf-mount" style="margin-top:10px;font-size:12px;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Политика записи <span class="muted" style="font-weight:400;font-size:12px">— общая для всех хранилищ</span></h2>
|
||||
<div class="cam-edit-grid">
|
||||
<label>Длина сегмента
|
||||
<select id="pol-segment">
|
||||
<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>
|
||||
</label>
|
||||
<label>Срок хранения, дней (0 = не удалять по возрасту)<input type="number" id="pol-retention" min="0" max="3650"></label>
|
||||
</div>
|
||||
<div style="margin-top:12px"><button id="pol-save">Сохранить</button></div>
|
||||
<div class="muted" id="pol-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="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА 2: Система -->
|
||||
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h2>Система</h2>
|
||||
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА: Плеер и перекодирование (этот браузер) -->
|
||||
<div class="tab-panel" data-panel="player" data-min-role="operator" hidden>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h2>Live view <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
|
||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||
<input type="checkbox" id="pref-autostart">
|
||||
<span>Показывать камеры сразу при входе в Live view
|
||||
<span class="muted">— иначе просмотр стартует после нажатия Play (по умолчанию)</span></span>
|
||||
</label>
|
||||
</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 сервера, для слабых клиентов).
|
||||
Для каждого потока выберите способ воспроизведения: <b>встроенный плеер</b> —
|
||||
декодирует поток прямо в браузере без нагрузки на сервер (ffmpeg -c copy):
|
||||
H.265/H.265+ — софтом (WASM hevc.js в Web Worker), H.264/H.264+ — средствами
|
||||
браузера; тянет и основной поток (3200×1800, включая тайлы), и субпоток
|
||||
(декод нагружает 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>
|
||||
@@ -106,9 +173,7 @@
|
||||
<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>
|
||||
@@ -117,7 +182,6 @@
|
||||
<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>
|
||||
@@ -127,22 +191,6 @@
|
||||
<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>
|
||||
|
||||
@@ -204,6 +252,11 @@
|
||||
|
||||
<!-- ВКЛАДКА 4: Пользователи (только суперадмин) -->
|
||||
<div class="tab-panel" data-panel="users" data-min-role="superadmin" hidden>
|
||||
<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 class="card">
|
||||
<div class="card-head">
|
||||
<h2>Пользователи и роли</h2>
|
||||
|
||||
+794
-424
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
/* CoRE.Vizion+ — мобильный dashboard (отдельный порт). Тёмная тема, бутерброд-меню, фирменный плеер. */
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body {
|
||||
margin: 0; height: 100%; background: #0f1115; color: #e6e9ef;
|
||||
font: 15px/1.4 -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-app { display: flex; flex-direction: column; height: 100%; }
|
||||
|
||||
/* ── шапка с бутербродом ── */
|
||||
.m-head {
|
||||
height: 48px; flex: none; display: flex; align-items: center; gap: 10px;
|
||||
padding: 0 8px; background: #161a22; border-bottom: 1px solid #272d3a;
|
||||
position: relative; z-index: 30;
|
||||
}
|
||||
.m-burger { background: none; border: 0; color: #e6e9ef; font-size: 22px; line-height: 1; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
|
||||
.m-burger:active { background: #1d2230; }
|
||||
.m-title { font-weight: 600; font-size: 16px; letter-spacing: .3px; }
|
||||
.m-brand { font-weight: 700; font-size: 17px; letter-spacing: .3px; }
|
||||
.m-brand span { color: #c0392b; }
|
||||
|
||||
/* ── бутерброд-меню: боковая шторка + затемнение ── */
|
||||
.m-scrim { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 40; }
|
||||
.m-scrim[hidden] { display: none; }
|
||||
.m-drawer {
|
||||
position: fixed; top: 0; left: 0; bottom: 0; width: 240px; max-width: 80vw; z-index: 50;
|
||||
background: #161a22; border-right: 1px solid #272d3a;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(-100%); transition: transform .22s ease;
|
||||
}
|
||||
.m-drawer.open { transform: translateX(0); }
|
||||
.m-drawer-head { height: 56px; flex: none; display: flex; align-items: center; padding: 0 18px; border-bottom: 1px solid #272d3a; }
|
||||
.m-nav {
|
||||
display: flex; align-items: center; gap: 14px; width: 100%; text-align: left;
|
||||
background: none; border: 0; border-left: 3px solid transparent; color: #cdd3e0;
|
||||
padding: 16px 18px; font: 600 16px/1 inherit; cursor: pointer;
|
||||
}
|
||||
.m-nav .i { font-size: 20px; width: 24px; text-align: center; }
|
||||
.m-nav:active { background: #1d2230; }
|
||||
.m-nav.active { color: #fff; background: #1d2230; border-left-color: #c0392b; }
|
||||
|
||||
/* ── режимы ── */
|
||||
.m-view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.m-view[hidden] { display: none; } /* hidden должен реально скрывать (иначе display:flex перебивает) */
|
||||
|
||||
.m-bar { flex: none; display: flex; gap: 8px; padding: 8px; background: #161a22; border-bottom: 1px solid #272d3a; }
|
||||
.m-sel, .m-date {
|
||||
flex: 1; min-width: 0; background: #1d2230; color: #e6e9ef; border: 1px solid #272d3a;
|
||||
border-radius: 8px; padding: 10px 12px; font-size: 15px; -webkit-appearance: none; appearance: none;
|
||||
}
|
||||
.m-date { flex: 0 0 auto; }
|
||||
|
||||
/* сцена плеера: live — на всё доступное место, архив — 16:9 (ниже список записей) */
|
||||
.m-stage { background: #000; position: relative; min-height: 0; }
|
||||
#view-live .m-stage { flex: 1; }
|
||||
#view-arch .m-stage { flex: none; aspect-ratio: 16 / 9; }
|
||||
.player-wrap { width: 100%; height: 100%; }
|
||||
|
||||
/* список записей архива */
|
||||
.m-seglist { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; -webkit-overflow-scrolling: touch; }
|
||||
.m-seg {
|
||||
display: flex; align-items: center; justify-content: space-between; width: 100%; text-align: left;
|
||||
background: #161a22; color: #e6e9ef; border: 1px solid #272d3a; border-radius: 8px;
|
||||
padding: 13px 14px; margin-bottom: 6px; font-size: 15px; font-variant-numeric: tabular-nums; cursor: pointer;
|
||||
}
|
||||
.m-seg:active { background: #1d2230; }
|
||||
.m-seg.active { background: #c0392b; border-color: #c0392b; color: #fff; }
|
||||
.m-dur { color: #8b93a7; font-size: 13px; }
|
||||
.m-seg.active .m-dur { color: #fff; }
|
||||
.m-empty { color: #8b93a7; text-align: center; padding: 28px 16px; font-size: 14px; }
|
||||
|
||||
/* фирменный плеер (.pjs) — общий static/player.css; ниже только тач-подгонка размеров */
|
||||
.pjs-btn { font-size: 20px; padding: 6px 8px; }
|
||||
.pjs-seek { height: 6px; }
|
||||
.pjs-seek:hover { height: 6px; }
|
||||
.pjs-knob { width: 16px; height: 16px; margin-left: -8px; }
|
||||
.pjs-bar { gap: 12px; padding-bottom: calc(11px + env(safe-area-inset-bottom, 0)); }
|
||||
.pjs.live .pjs-bar { justify-content: center; gap: 30px; }
|
||||
@@ -0,0 +1,120 @@
|
||||
// CoRE.Vizion+ — мобильный dashboard. Оба режима через фирменный плеер (.pjs):
|
||||
// Live — только пауза/плей + во весь экран; Архив — перемотка + скорость.
|
||||
// Видео отдаётся сервером уже перекодированным в H.264 (играет на любом телефоне).
|
||||
"use strict";
|
||||
|
||||
// фиксированный профиль H.264 для телефона (макс. совместимость)
|
||||
const TR = "codec=h264&profile=baseline&bitrate=2000k&preset=ultrafast&keyint=2&rc=vbr&qp=23";
|
||||
|
||||
function pad(n) { return String(n).padStart(2, "0"); }
|
||||
|
||||
async function api(url) {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ArchivePlayer — общий плеер из static/player.js (подключается ПЕРЕД mobile.js).
|
||||
|
||||
const M = {
|
||||
cams: [], segs: [], liveId: null, archId: null, date: null, view: "live", player: null,
|
||||
|
||||
async init() {
|
||||
let data;
|
||||
try { data = await api("/api/cameras"); } catch (e) { return; }
|
||||
this.cams = data.cameras || [];
|
||||
const enabled = this.cams.filter((c) => c.enabled);
|
||||
|
||||
const lc = document.getElementById("m-live-cam");
|
||||
const ac = document.getElementById("m-arch-cam");
|
||||
lc.innerHTML = enabled.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
|
||||
ac.innerHTML = this.cams.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
|
||||
this.liveId = enabled[0] ? enabled[0].id : null;
|
||||
this.archId = this.cams[0] ? this.cams[0].id : null;
|
||||
lc.onchange = () => { this.liveId = lc.value; this.startLive(); };
|
||||
ac.onchange = () => { this.archId = ac.value; this.loadArch(); };
|
||||
|
||||
const dt = document.getElementById("m-arch-date");
|
||||
const t = new Date();
|
||||
this.date = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
|
||||
dt.value = this.date;
|
||||
dt.onchange = () => { this.date = dt.value; this.loadArch(); };
|
||||
|
||||
// бутерброд-меню сверху: выбор режима (Live / Архив)
|
||||
const drawer = document.getElementById("m-drawer");
|
||||
const scrim = document.getElementById("m-scrim");
|
||||
const setMenu = (open) => { drawer.classList.toggle("open", open); scrim.hidden = !open; };
|
||||
document.getElementById("m-burger").onclick = () => setMenu(!drawer.classList.contains("open"));
|
||||
scrim.onclick = () => setMenu(false);
|
||||
document.querySelectorAll(".m-nav").forEach((b) => {
|
||||
b.onclick = () => { this.showView(b.dataset.view); setMenu(false); };
|
||||
});
|
||||
|
||||
this.showView("live");
|
||||
},
|
||||
|
||||
_kill() { if (this.player) { this.player.destroy(); this.player = null; } },
|
||||
|
||||
showView(v) {
|
||||
this.view = v;
|
||||
document.getElementById("m-title").textContent = v === "live" ? "Live" : "Архив";
|
||||
document.querySelectorAll(".m-nav").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
|
||||
document.getElementById("view-live").hidden = v !== "live";
|
||||
document.getElementById("view-arch").hidden = v !== "arch";
|
||||
this._kill();
|
||||
if (v === "live") this.startLive();
|
||||
else this.loadArch();
|
||||
},
|
||||
|
||||
// ── Live ──
|
||||
startLive() {
|
||||
this._kill();
|
||||
const box = document.getElementById("m-live-box");
|
||||
if (!this.liveId) { box.innerHTML = '<div class="m-empty">Нет доступных камер</div>'; return; }
|
||||
this.player = new ArchivePlayer(box, { live: true, autohide: false, src: `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}` });
|
||||
},
|
||||
|
||||
// ── Архив ──
|
||||
async loadArch() {
|
||||
this._kill();
|
||||
const box = document.getElementById("m-arch-box");
|
||||
const list = document.getElementById("m-seglist");
|
||||
box.innerHTML = '<div class="m-empty">Выберите запись ниже</div>';
|
||||
if (!this.archId || !this.date) { list.innerHTML = ""; return; }
|
||||
const [y, m, d] = this.date.split("-").map(Number);
|
||||
const from = Math.floor(new Date(y, m - 1, d, 0, 0, 0).getTime() / 1000);
|
||||
const to = from + 86400;
|
||||
list.innerHTML = '<div class="m-empty">Загрузка…</div>';
|
||||
let recs = [];
|
||||
try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; }
|
||||
catch (e) { list.innerHTML = '<div class="m-empty">Ошибка загрузки</div>'; return; }
|
||||
if (!recs.length) { list.innerHTML = '<div class="m-empty">За выбранный день записей нет</div>'; return; }
|
||||
this.segs = recs;
|
||||
list.innerHTML = recs.map((r) => {
|
||||
const tt = new Date(r.started_at * 1000);
|
||||
const mins = Math.round((r.duration_s || 0) / 60);
|
||||
return `<button class="m-seg" data-id="${r.id}">` +
|
||||
`${pad(tt.getHours())}:${pad(tt.getMinutes())}:${pad(tt.getSeconds())}` +
|
||||
`<span class="m-dur">${mins} мин</span></button>`;
|
||||
}).join("");
|
||||
list.onclick = (e) => {
|
||||
const b = e.target.closest(".m-seg");
|
||||
if (!b) return;
|
||||
list.querySelectorAll(".m-seg").forEach((x) => x.classList.toggle("active", x === b));
|
||||
this.playSeg(+b.dataset.id);
|
||||
};
|
||||
},
|
||||
playSeg(id) {
|
||||
this._kill();
|
||||
const seg = this.segs.find((r) => r.id === id);
|
||||
const box = document.getElementById("m-arch-box");
|
||||
this.player = new ArchivePlayer(box, {
|
||||
mode: "transcode", autohide: false,
|
||||
duration: seg ? (seg.duration_s || 0) : 0,
|
||||
srcFor: (t) => `/api/recordings/${id}/play.mp4?${TR}&start=${(t || 0).toFixed(3)}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => M.init());
|
||||
@@ -0,0 +1,48 @@
|
||||
/* CoRE.Vizion+ — фирменный плеер (.pjs). Общие стили: десктоп (архив) и мобила. */
|
||||
.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; }
|
||||
|
||||
/* live-режим: только пауза/плей + во весь экран (без шкалы/времени/скорости) */
|
||||
.pjs.live .pjs-time, .pjs.live .pjs-dur, .pjs.live .pjs-seek, .pjs.live .pjs-speed { display: none; }
|
||||
.pjs.live .pjs-bar { justify-content: center; gap: 30px; }
|
||||
@@ -0,0 +1,185 @@
|
||||
// CoRE.Vizion+ — фирменный плеер (.pjs). Общий модуль: десктоп (архив) и мобила.
|
||||
// Класс называется ArchivePlayer (имя сохранено: app.js делает new ArchivePlayer(...)).
|
||||
// Режимы:
|
||||
// transcode — серверный H.264-поток без Range: виртуальный таймлайн на duration,
|
||||
// перемотка = перезапуск транскода с -ss (srcFor(startSec)).
|
||||
// file — нативно перематываемый файл (HW-декод H.265): v.currentTime/duration.
|
||||
// live — без таймлайна: только пауза/плей + fullscreen (src без длительности).
|
||||
// opts: { mode, live, duration, srcFor(startSec)->url, src, offset, onEnded, onTime, spIdx, autohide }
|
||||
// autohide — авто-скрытие панели (десктоп: true по умолчанию; тач/мобила: передаём false).
|
||||
"use strict";
|
||||
|
||||
class ArchivePlayer {
|
||||
constructor(wrap, opts = {}) {
|
||||
this.live = opts.live === true || opts.mode === "live";
|
||||
this.mode = this.live ? "live" : (opts.mode === "transcode" ? "transcode" : "file");
|
||||
this.total = opts.duration || 0; // полная длина сегмента, сек
|
||||
this.srcFor = opts.srcFor || null; // (startSec) => url (для transcode)
|
||||
this.startOffset = opts.offset || 0; // смещение начала текущего потока (transcode)
|
||||
this.onEnded = opts.onEnded || null;
|
||||
this.onTime = opts.onTime || null; // (posSec) — следование таймлайна
|
||||
this.speeds = [0.5, 1, 2, 5];
|
||||
this.spIdx = opts.spIdx != null ? opts.spIdx : 1; // скорость сохраняется между фрагментами
|
||||
this.autohide = opts.autohide !== false; // false → панель всегда видна (тач)
|
||||
this.hideTimer = 0; this.dragging = false; this.dragP = 0;
|
||||
wrap.innerHTML = "";
|
||||
const root = document.createElement("div");
|
||||
root.className = "pjs show" + (this.live ? " live" : "");
|
||||
root.tabIndex = 0;
|
||||
root.innerHTML =
|
||||
'<video class="pjs-video" ' + (this.live ? "muted " : "") + 'autoplay playsinline></video>' +
|
||||
'<div class="pjs-center"><div class="pjs-spinner"></div>' +
|
||||
'<button class="pjs-bigplay" aria-label="Play">▶</button></div>' +
|
||||
'<div class="pjs-bar">' +
|
||||
'<button class="pjs-btn pjs-play" title="Воспроизведение/пауза (Пробел)">⏸</button>' +
|
||||
'<span class="pjs-time">0:00</span>' +
|
||||
'<div class="pjs-seek"><div class="pjs-buf"></div><div class="pjs-prog"></div><div class="pjs-knob"></div></div>' +
|
||||
'<span class="pjs-dur">0:00</span>' +
|
||||
'<button class="pjs-btn pjs-speed" title="Скорость воспроизведения">1×</button>' +
|
||||
'<button class="pjs-btn pjs-fs" title="Во весь экран (F)">⛶</button>' +
|
||||
'</div>';
|
||||
wrap.appendChild(root);
|
||||
this.root = root;
|
||||
this.v = root.querySelector(".pjs-video");
|
||||
this.seek = root.querySelector(".pjs-seek");
|
||||
this.prog = root.querySelector(".pjs-prog");
|
||||
this.buf = root.querySelector(".pjs-buf");
|
||||
this.knob = root.querySelector(".pjs-knob");
|
||||
this.timeEl = root.querySelector(".pjs-time");
|
||||
this.durEl = root.querySelector(".pjs-dur");
|
||||
this.playBtn = root.querySelector(".pjs-play");
|
||||
this.speedBtn = root.querySelector(".pjs-speed");
|
||||
this.speedBtn.textContent = this.speeds[this.spIdx] + "×";
|
||||
this.v.src = this.live ? opts.src : (this.mode === "transcode" ? this.srcFor(this.startOffset) : opts.src);
|
||||
this._bind();
|
||||
if (this.live) this.v.play().catch(() => {});
|
||||
}
|
||||
_fmt(s) {
|
||||
if (!isFinite(s) || s < 0) s = 0;
|
||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
||||
return m + ":" + String(sec).padStart(2, "0");
|
||||
}
|
||||
_total() {
|
||||
if (this.live) return 0;
|
||||
if (this.mode === "transcode") return this.total;
|
||||
return isFinite(this.v.duration) && this.v.duration ? this.v.duration : (this.total || 0);
|
||||
}
|
||||
_curTime() {
|
||||
if (this.live) return 0;
|
||||
if (this.mode === "transcode") return this.startOffset + (this.v.currentTime || 0);
|
||||
return this.v.currentTime || 0;
|
||||
}
|
||||
_applyRate() { this.v.playbackRate = this.speeds[this.spIdx]; }
|
||||
// перемотка к доле p∈[0..1] всего сегмента
|
||||
_seekToFrac(p) {
|
||||
if (this.live) return;
|
||||
const total = this._total(); if (!total) return;
|
||||
const t = Math.min(total, Math.max(0, p * total));
|
||||
if (this.mode === "transcode") {
|
||||
this.startOffset = t; // перезапуск транскода с нужной секунды
|
||||
this.v.src = this.srcFor(t);
|
||||
this.v.play().catch(() => {});
|
||||
} else {
|
||||
try { this.v.currentTime = t; } catch (e) { /**/ }
|
||||
}
|
||||
this._sync();
|
||||
}
|
||||
_seekRel(d) { const total = this._total(); if (total) this._seekToFrac((this._curTime() + d) / total); }
|
||||
_bind() {
|
||||
const v = this.v, root = this.root;
|
||||
v.addEventListener("loadedmetadata", () => {
|
||||
this.durEl.textContent = this._fmt(this._total());
|
||||
if (this.mode === "file" && this.startOffset > 0) { try { v.currentTime = this.startOffset; } catch (e) { /**/ } }
|
||||
this._applyRate(); // вернуть скорость после загрузки нового потока (src сбрасывает playbackRate)
|
||||
});
|
||||
v.addEventListener("timeupdate", () => this._sync());
|
||||
v.addEventListener("progress", () => this._syncBuf());
|
||||
v.addEventListener("play", () => { this.playBtn.textContent = "⏸"; root.classList.remove("paused"); this._autohide(); });
|
||||
v.addEventListener("pause", () => { this.playBtn.textContent = "▶"; root.classList.add("paused"); this._show(); });
|
||||
v.addEventListener("waiting", () => root.classList.add("buffering"));
|
||||
v.addEventListener("playing", () => root.classList.remove("buffering"));
|
||||
v.addEventListener("ended", () => { root.classList.add("paused"); if (this.onEnded) this.onEnded(); });
|
||||
|
||||
const toggle = () => { if (v.paused) { v.play().catch(() => {}); } else { v.pause(); } };
|
||||
this.playBtn.onclick = toggle;
|
||||
root.querySelector(".pjs-bigplay").onclick = toggle;
|
||||
v.onclick = toggle;
|
||||
v.ondblclick = () => this._fs();
|
||||
root.querySelector(".pjs-fs").onclick = () => this._fs();
|
||||
this.speedBtn.onclick = () => {
|
||||
this.spIdx = (this.spIdx + 1) % this.speeds.length;
|
||||
const r = this.speeds[this.spIdx]; v.playbackRate = r; this.speedBtn.textContent = r + "×";
|
||||
};
|
||||
|
||||
if (!this.live) {
|
||||
// перемотка по шкале: во время drag — только превью, seek/перезапуск один раз на отпускании
|
||||
const fracAt = (clientX) => { const r = this.seek.getBoundingClientRect(); return Math.min(1, Math.max(0, (clientX - r.left) / r.width)); };
|
||||
const preview = (p) => { this.prog.style.width = (p * 100) + "%"; this.knob.style.left = (p * 100) + "%"; this.timeEl.textContent = this._fmt(p * this._total()); };
|
||||
this.seek.addEventListener("pointerdown", (e) => { this.dragging = true; this.dragP = fracAt(e.clientX); try { this.seek.setPointerCapture(e.pointerId); } catch (er) {} preview(this.dragP); });
|
||||
this.seek.addEventListener("pointermove", (e) => { if (this.dragging) { this.dragP = fracAt(e.clientX); preview(this.dragP); } });
|
||||
this.seek.addEventListener("pointerup", () => { if (this.dragging) { this.dragging = false; this._seekToFrac(this.dragP); } });
|
||||
}
|
||||
|
||||
// показ/автоскрытие панели
|
||||
root.addEventListener("mousemove", () => this._autohide());
|
||||
root.addEventListener("mouseleave", () => { if (!v.paused && this.autohide) this._hide(); });
|
||||
|
||||
// горячие клавиши (когда плеер в фокусе)
|
||||
root.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space") { e.preventDefault(); toggle(); }
|
||||
else if (e.code === "ArrowRight") { e.preventDefault(); this._seekRel(5); }
|
||||
else if (e.code === "ArrowLeft") { e.preventDefault(); this._seekRel(-5); }
|
||||
else if (e.key === "f" || e.key === "F" || e.key === "а" || e.key === "А") { e.preventDefault(); this._fs(); }
|
||||
});
|
||||
this._fsSync = () => root.classList.toggle("fs", (document.fullscreenElement || document.webkitFullscreenElement) === root);
|
||||
document.addEventListener("fullscreenchange", this._fsSync);
|
||||
document.addEventListener("webkitfullscreenchange", this._fsSync);
|
||||
setTimeout(() => { try { root.focus(); } catch (e) {} }, 0);
|
||||
}
|
||||
_sync() {
|
||||
if (this.dragging || this.live) return; // во время перетаскивания шкалой управляет preview
|
||||
const cur = this._curTime(), total = this._total();
|
||||
const p = total ? Math.min(1, cur / total) : 0;
|
||||
this.prog.style.width = (p * 100) + "%";
|
||||
this.knob.style.left = (p * 100) + "%";
|
||||
this.timeEl.textContent = this._fmt(cur);
|
||||
this.durEl.textContent = this._fmt(total);
|
||||
if (this.onTime) this.onTime(cur);
|
||||
}
|
||||
_syncBuf() {
|
||||
const v = this.v; if (this.live || !v.buffered.length) return;
|
||||
const total = this._total(); if (!total) return;
|
||||
const base = this.mode === "transcode" ? this.startOffset : 0;
|
||||
const end = base + v.buffered.end(v.buffered.length - 1);
|
||||
this.buf.style.width = Math.min(100, end / total * 100) + "%";
|
||||
}
|
||||
// self-contained fullscreen (без глобального toggleFullscreen): работает и на мобиле
|
||||
_fs() {
|
||||
const el = this.root;
|
||||
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
|
||||
if (fsEl) {
|
||||
(document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document);
|
||||
} else if (el.requestFullscreen || el.webkitRequestFullscreen) {
|
||||
(el.requestFullscreen || el.webkitRequestFullscreen).call(el);
|
||||
} else if (this.v.webkitEnterFullscreen) {
|
||||
this.v.webkitEnterFullscreen(); // iOS: на весь экран умеет только видеоэлемент
|
||||
}
|
||||
}
|
||||
_show() { this.root.classList.add("show"); }
|
||||
_hide() { this.root.classList.remove("show"); }
|
||||
_autohide() {
|
||||
this._show();
|
||||
if (!this.autohide) return; // тач-режим: панель всегда видна
|
||||
clearTimeout(this.hideTimer);
|
||||
this.hideTimer = setTimeout(() => { if (!this.v.paused && !this.dragging) this._hide(); }, 2500);
|
||||
}
|
||||
destroy() {
|
||||
this.onTime = null; this.onEnded = null; // хвостовые timeupdate/ended не должны дёргать ленту
|
||||
clearTimeout(this.hideTimer);
|
||||
if (this._fsSync) {
|
||||
document.removeEventListener("fullscreenchange", this._fsSync);
|
||||
document.removeEventListener("webkitfullscreenchange", this._fsSync);
|
||||
}
|
||||
try { this.v.pause(); this.v.removeAttribute("src"); this.v.load(); } catch (e) { /**/ }
|
||||
}
|
||||
}
|
||||
+167
-77
@@ -13,8 +13,9 @@
|
||||
--ok: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--err: #ef4444;
|
||||
--header-h: 48px;
|
||||
--sidebar-w: 240px;
|
||||
--header-h: 50px;
|
||||
--footer-h: 22px;
|
||||
--sidebar-w: 72px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -26,12 +27,12 @@ html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Шапка ── */
|
||||
header {
|
||||
height: var(--header-h); display: flex; align-items: center;
|
||||
/* ── Шапка (50px, бренд по центру) ── */
|
||||
header.app-header {
|
||||
height: var(--header-h); flex: none; display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
gap: 18px;
|
||||
}
|
||||
header.app-header .brand { font-size: 18px; }
|
||||
.brand { font-weight: 700; letter-spacing: .5px; color: #fff; }
|
||||
.brand span { color: #c0392b; }
|
||||
nav { display: flex; gap: 4px; }
|
||||
@@ -46,11 +47,22 @@ nav a.active { background: var(--accent); color: #fff; }
|
||||
.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)); }
|
||||
/* ── Раскладка (между шапкой и футером) ── */
|
||||
.layout { display: flex; height: calc(100% - var(--header-h) - var(--footer-h)); }
|
||||
|
||||
/* ── Футер (бренд + CPU/RAM/диск + часы + версия) ── */
|
||||
footer.app-footer {
|
||||
height: var(--footer-h); display: flex; align-items: center; gap: 14px;
|
||||
padding: 0 14px; background: var(--panel); border-top: 1px solid var(--border);
|
||||
font-size: 11px; color: var(--text-dim);
|
||||
}
|
||||
footer.app-footer .brand { font-size: 12px; }
|
||||
footer.app-footer .stats { display: flex; gap: 14px; font-size: 11px; }
|
||||
footer.app-footer .clock { font-size: 11px; color: var(--text); }
|
||||
footer.app-footer #footer-version { font-variant-numeric: tabular-nums; }
|
||||
aside {
|
||||
width: var(--sidebar-w); background: var(--panel); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px 0; overflow: hidden;
|
||||
}
|
||||
.aside-title {
|
||||
padding: 12px 14px 8px; font-size: 11px; text-transform: uppercase;
|
||||
@@ -58,14 +70,14 @@ aside {
|
||||
}
|
||||
/* ── Навигация в сайдбаре ── */
|
||||
.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;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
||||
width: 64px; height: 64px; padding: 0; border-radius: 12px;
|
||||
color: var(--text-dim); text-decoration: none; font-weight: 500; font-size: 9px; line-height: 1.15;
|
||||
border: 0; background: none; cursor: pointer; text-align: center; 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; }
|
||||
.nav-item.active { background: var(--panel-2); color: #fff; box-shadow: inset 0 0 0 1px #c0392b; }
|
||||
.nav-item .ico { font-size: 26px; line-height: 1; width: auto; text-align: center; }
|
||||
.aside-spacer { flex: 1; }
|
||||
|
||||
/* Иконка настройки раскладки рядом с Live view */
|
||||
@@ -84,7 +96,7 @@ aside {
|
||||
.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); }
|
||||
.app-version { padding: 6px 2px; font-size: 10px; color: var(--text-dim); text-align: center; width: 100%; }
|
||||
.nav-item.logout { color: var(--text-dim); border-top: 1px solid var(--border); }
|
||||
.nav-item.logout:hover { color: #e57373; }
|
||||
|
||||
@@ -147,7 +159,7 @@ input[type="checkbox"]:checked {
|
||||
}
|
||||
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 {
|
||||
input[type="date"], input[type="number"], select {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 6px 8px; border-radius: 6px;
|
||||
}
|
||||
@@ -158,6 +170,42 @@ input[type="date"], select {
|
||||
background: #000; overflow: hidden;
|
||||
grid-auto-rows: 1fr; grid-auto-columns: 1fr; /* все дорожки строго равны */
|
||||
}
|
||||
/* «Соло»: одна камера на весь экран (двойной клик по ячейке в полноэкранном режиме) */
|
||||
.grid.solo { grid-template-columns: 1fr !important; grid-template-rows: 1fr !important; }
|
||||
.grid.solo > .tile:not(.solo) { display: none; }
|
||||
|
||||
/* ── Панель управления под сеткой (футер фрейма live): листание + play/stop + фулскрин ── */
|
||||
footer.grid-bar {
|
||||
height: 32px; flex: none; display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 8px; background: var(--panel); border-top: 1px solid var(--border);
|
||||
}
|
||||
.grid-bar .gb-left { flex: 1; display: flex; justify-content: flex-start; }
|
||||
.grid-bar .gb-center { flex: 0 0 auto; display: flex; justify-content: center; gap: 8px; }
|
||||
.grid-bar .gb-right { flex: 1; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.grid-bar .gb-btn {
|
||||
height: 24px; min-width: 30px; padding: 0 8px; border: 1px solid var(--border);
|
||||
background: var(--panel-2); color: var(--text); border-radius: 6px; cursor: pointer;
|
||||
font-size: 14px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.grid-bar .gb-btn:hover:not(:disabled) { border-color: #c0392b; color: #fff; }
|
||||
.grid-bar .gb-btn:disabled { opacity: .3; cursor: default; }
|
||||
.grid-bar .gb-play { min-width: 42px; font-size: 15px; }
|
||||
/* кнопка «Раскладка» (window split) + всплывающее меню (раскрывается вверх) */
|
||||
.grid-bar .gb-split { position: relative; display: inline-flex; }
|
||||
.grid-bar .gb-split-menu {
|
||||
display: none; position: absolute; bottom: calc(100% + 6px); right: 0; z-index: 50;
|
||||
flex-direction: column; gap: 2px; padding: 4px; min-width: 60px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.grid-bar .gb-split-menu.open { display: flex; }
|
||||
.grid-bar .gb-split-menu button {
|
||||
height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: 5px;
|
||||
background: none; color: var(--text); cursor: pointer; font-size: 13px;
|
||||
font-variant-numeric: tabular-nums; text-align: center;
|
||||
}
|
||||
.grid-bar .gb-split-menu button:hover { background: var(--panel-2); }
|
||||
.grid-bar .gb-split-menu button.active { background: var(--accent); border-color: #c0392b; color: #fff; }
|
||||
.tile {
|
||||
position: relative; background: #05070a; border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; overflow: hidden;
|
||||
@@ -192,6 +240,9 @@ input[type="date"], select {
|
||||
.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; }
|
||||
/* в полноэкранном режиме панель-футер скрыта (вход/выход — клавиша F, выход также Esc) */
|
||||
#live-main:fullscreen .grid-bar,
|
||||
#arch-content:fullscreen .grid-bar { display: none; }
|
||||
.hevc-hint {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
|
||||
background: rgba(63, 3, 3, .88); color: #fff; font-size: 11px;
|
||||
@@ -199,55 +250,57 @@ input[type="date"], select {
|
||||
}
|
||||
|
||||
/* ── Архив ── */
|
||||
.archive-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
/* в ряд: сайдбар 220px слева + контент справа (явно, т.к. базовое main = column) */
|
||||
.archive-main { flex: 1; display: flex; flex-direction: row; overflow: hidden; }
|
||||
/* левый сайдбар архива: 220px, поделён по высоте пополам */
|
||||
.arch-side {
|
||||
width: 220px; flex: none; display: flex; flex-direction: column;
|
||||
background: var(--panel); border-right: 1px solid var(--border); overflow: hidden;
|
||||
}
|
||||
/* верхний фрейм — список камер: заполняет всё над карточкой даты, без полосы прокрутки */
|
||||
.arch-cams { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none; }
|
||||
.arch-cams::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||
.arch-cam {
|
||||
padding: 6px 12px; font-size: 13px; color: var(--text); cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.arch-cam:hover { background: var(--panel-2); }
|
||||
.arch-cam.active { background: #c0392b; color: #fff; font-weight: 600; }
|
||||
/* нижний фрейм даты — карточка (дата сверху + календарь), прижата вниз */
|
||||
.arch-date {
|
||||
flex: none; margin: 10px; padding: 10px;
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.arch-dateinput {
|
||||
width: 100%; background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 5px 8px; font-size: 13px;
|
||||
}
|
||||
.arch-cal { user-select: none; }
|
||||
.cal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.cal-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.cal-nav {
|
||||
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
|
||||
width: 24px; height: 24px; border-radius: 6px; cursor: pointer; padding: 0; line-height: 1; font-size: 14px;
|
||||
}
|
||||
.cal-nav:hover { border-color: #c0392b; color: #fff; }
|
||||
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
||||
.cal-wd { font-size: 10px; color: var(--text-dim); text-align: center; padding: 2px 0; }
|
||||
.cal-day {
|
||||
text-align: center; font-size: 12px; padding: 4px 0; border-radius: 5px; cursor: pointer;
|
||||
color: var(--text); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cal-day.empty { visibility: hidden; cursor: default; }
|
||||
.cal-day:hover:not(.empty) { background: rgba(255, 255, 255, .07); }
|
||||
.cal-day.today { box-shadow: inset 0 0 0 1px var(--text-dim); }
|
||||
.cal-day.sel { background: #c0392b; color: #fff; font-weight: 600; }
|
||||
/* контент архива (плеер + таймлайн) */
|
||||
.arch-content { flex: 1; display: flex; flex-direction: column; min-width: 0; 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; }
|
||||
/* ── плеер архива (.pjs) вынесен в общий static/player.css (подключается в <head>) ── */
|
||||
|
||||
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
|
||||
.timeline {
|
||||
@@ -328,7 +381,7 @@ input[type="date"], select {
|
||||
.settings-main .wrap { max-width: 920px; margin: 0 auto; }
|
||||
|
||||
/* Вкладки (как в браузере) */
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
|
||||
.tabs { display: flex; flex-wrap: wrap; 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;
|
||||
@@ -336,20 +389,18 @@ input[type="date"], select {
|
||||
.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 { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; padding-left: 28px; align-items: flex-start; }
|
||||
.tr-row[hidden] { display: none; }
|
||||
/* 3 группы параметров перекодирования: Кодек / Битрейт / Готовый профиль */
|
||||
.tr-group { border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px 12px; background: var(--panel-2); }
|
||||
.tr-gtitle { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 8px; }
|
||||
.tr-gbody { display: flex; flex-wrap: wrap; gap: 12px 14px; align-items: flex-end; }
|
||||
.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-gbody select, .tr-gbody input[type="number"] { display: block; }
|
||||
.tr-gbody input[type="number"] { width: 80px; }
|
||||
.tr-group-preset select { min-width: 120px; }
|
||||
.tr-row select:disabled, .tr-row input:disabled { opacity: .4; cursor: not-allowed; }
|
||||
/* блок профиля: режим (свой плеер / перекодировать) + ряд параметров */
|
||||
.tr-block { padding: 12px 0; border-top: 1px solid var(--border); }
|
||||
.tr-block:first-of-type { border-top: none; padding-top: 0; }
|
||||
@@ -415,8 +466,26 @@ table.cams th, table.cams td {
|
||||
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; }
|
||||
/* камеры: жёстко фиксированная высота строки 22px независимо от контролов.
|
||||
line-height:1 + контролы по центру и ниже 22px → строки не распирает. */
|
||||
table.cams.cams-fixed td, table.cams.cams-fixed th {
|
||||
height: 22px; padding-top: 0; padding-bottom: 0; line-height: 1;
|
||||
}
|
||||
table.cams.cams-fixed td > * { vertical-align: middle; }
|
||||
/* тумблеры «Вкл»/«Запись» в строке — компактные (30×16) */
|
||||
table.cams.cams-fixed td input[type="checkbox"] {
|
||||
width: 30px; height: 16px; border-radius: 8px;
|
||||
}
|
||||
table.cams.cams-fixed td input[type="checkbox"]::before { width: 12px; height: 12px; top: 1px; left: 1px; }
|
||||
table.cams.cams-fixed td input[type="checkbox"]:checked::before { transform: translateX(14px); }
|
||||
/* селекты и инпуты в строке — на 2px ниже строки, по центру */
|
||||
table.cams.cams-fixed td select.stor-sel,
|
||||
table.cams.cams-fixed td select.cell-sel,
|
||||
table.cams.cams-fixed td input.cell-in { height: 20px; }
|
||||
/* кнопки действий ⟳/✕ (режим редактирования) — компактные */
|
||||
table.cams.cams-fixed td.actions button {
|
||||
height: 20px; padding: 0 6px; font-size: 13px; line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Инлайн-редактор строки (вместо всплывающего окна) ── */
|
||||
.cam-edit { background: var(--panel-2); padding: 12px 12px 14px; }
|
||||
@@ -439,10 +508,31 @@ button.primary { background: var(--accent); border-color: #c0392b; color: #fff;
|
||||
button.danger { border-color: #c0392b; color: #e74c3c; }
|
||||
button.danger:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
|
||||
table.cams td.actions button { padding: 2px 8px; font-size: 12px; }
|
||||
/* инлайн-селекты в строке (хранилище, канал) — компактные, не раздувают высоту строки */
|
||||
table.cams td select.stor-sel, table.cams td select.cell-sel {
|
||||
height: 22px; padding: 1px 4px; font-size: 12px; line-height: 1;
|
||||
border-radius: 5px; width: 100%; max-width: 150px; box-sizing: border-box;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* массовое редактирование: инпуты прямо в ячейках, компактные (высота строки не растёт) */
|
||||
table.cams td input.cell-in {
|
||||
height: 22px; padding: 1px 6px; font-size: 12px; line-height: 1;
|
||||
border-radius: 5px; width: 100%; box-sizing: border-box; vertical-align: middle;
|
||||
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
|
||||
}
|
||||
table.cams td input.cell-in:focus { outline: none; border-color: #c0392b; }
|
||||
table.cams td.cell-dual { white-space: nowrap; }
|
||||
table.cams td.cell-dual input.cell-in { display: inline-block; width: calc(50% - 3px); }
|
||||
table.cams td.cell-dual input.cell-in + input.cell-in { margin-left: 4px; }
|
||||
table.cams tr.bulk-row td { background: rgba(192, 57, 43, .05); }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
|
||||
border-radius: 11px; font-size: 12px; background: var(--panel-2);
|
||||
}
|
||||
.badge-active { background: rgba(46, 204, 113, .18); color: var(--ok); font-weight: 600; }
|
||||
.q-cell { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.q-cell .q-input { width: 78px; }
|
||||
.q-cell button { padding: 2px 8px; font-size: 12px; }
|
||||
.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;
|
||||
|
||||
+12
-7
@@ -286,7 +286,11 @@ var FrameRenderer = class _FrameRenderer {
|
||||
format: "I420",
|
||||
codedWidth: w,
|
||||
codedHeight: h,
|
||||
timestamp
|
||||
timestamp,
|
||||
// Hikvision H.265 — BT.709, TV/limited range. Без явного colorSpace браузер для
|
||||
// I420 по умолчанию берёт BT.601 (зелёно-красный сдвиг) и неверный диапазон
|
||||
// (белёсость). Задаём явно, чтобы YUV→RGB шёл по правильной матрице и диапазону.
|
||||
colorSpace: { primaries: "bt709", transfer: "bt709", matrix: "bt709", fullRange: false }
|
||||
});
|
||||
await this._writer.write(videoFrame);
|
||||
videoFrame.close();
|
||||
@@ -394,12 +398,13 @@ var FRAGMENT_SRC = `
|
||||
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;
|
||||
// BT.709 limited range (TV): Y∈[16,235]/255 → (Y-16)*255/219; Cb/Cr∈[16,240]/255 центр 128/255.
|
||||
float y = (texture2D(u_texY, v_tex).r - 0.0627451) * 1.1643836;
|
||||
float cb = texture2D(u_texCb, v_tex).r - 0.5019608;
|
||||
float cr = texture2D(u_texCr, v_tex).r - 0.5019608;
|
||||
float r = y + 1.7927411 * cr;
|
||||
float g = y - 0.2132486 * cb - 0.5329093 * cr;
|
||||
float b = y + 2.1124018 * cb;
|
||||
gl_FragColor = vec4(clamp(r, 0.0, 1.0), clamp(g, 0.0, 1.0), clamp(b, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
sudo apt install docker-compose-v2 git -y
|
||||
git clone https://git.core.uz/git_admin/CoRE.Vision
|
||||
sudo docker compose up -d --build
|
||||
Reference in New Issue
Block a user