Compare commits
41 Commits
346d3b5b96
..
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 |
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -12,7 +13,7 @@ from ..config import Camera, save_config
|
|||||||
from ..runtime import runtime
|
from ..runtime import runtime
|
||||||
from ..services import go2rtc
|
from ..services import go2rtc
|
||||||
from ..transcode import (encode_args, norm_profile, norm_bitrate, norm_preset,
|
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"])
|
router = APIRouter(prefix="/api/cameras", tags=["cameras"])
|
||||||
|
|
||||||
@@ -32,11 +33,13 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera:
|
|||||||
id=cam_id,
|
id=cam_id,
|
||||||
name=(body.get("name") or cam_id).strip(),
|
name=(body.get("name") or cam_id).strip(),
|
||||||
enabled=bool(body.get("enabled", True)),
|
enabled=bool(body.get("enabled", True)),
|
||||||
|
record=bool(body.get("record", True)),
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user=user,
|
user=user,
|
||||||
password=password,
|
password=password,
|
||||||
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
|
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
|
||||||
sub_path=sub,
|
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,
|
"id": cam.id,
|
||||||
"name": cam.name,
|
"name": cam.name,
|
||||||
"enabled": cam.enabled,
|
"enabled": cam.enabled,
|
||||||
|
"record": cam.record,
|
||||||
"ip": cam.ip,
|
"ip": cam.ip,
|
||||||
"user": cam.user,
|
"user": cam.user,
|
||||||
"password": cam.password,
|
"password": cam.password,
|
||||||
"main_path": cam.main_path,
|
"main_path": cam.main_path,
|
||||||
"sub_path": cam.sub_path,
|
"sub_path": cam.sub_path,
|
||||||
|
"storage_id": cam.storage_id,
|
||||||
"has_sub": has_sub,
|
"has_sub": has_sub,
|
||||||
"status": status.to_dict() if status else None,
|
"status": status.to_dict() if status else None,
|
||||||
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
|
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
|
||||||
@@ -86,6 +91,8 @@ async def camera_live_transcoded(
|
|||||||
preset: str = Query(None),
|
preset: str = Query(None),
|
||||||
keyint: str = Query(None),
|
keyint: str = Query(None),
|
||||||
codec: str = Query(None),
|
codec: str = Query(None),
|
||||||
|
rc: str = Query(None),
|
||||||
|
qp: str = Query(None),
|
||||||
stream: str = Query("sub"),
|
stream: str = Query("sub"),
|
||||||
):
|
):
|
||||||
"""Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента.
|
"""Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента.
|
||||||
@@ -103,7 +110,8 @@ async def camera_live_transcoded(
|
|||||||
"-fflags", "nobuffer", "-flags", "low_delay",
|
"-fflags", "nobuffer", "-flags", "low_delay",
|
||||||
"-rtsp_transport", "tcp", "-i", src,
|
"-rtsp_transport", "tcp", "-i", src,
|
||||||
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate),
|
*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",
|
"-an",
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||||
"-f", "mp4", "pipe:1",
|
"-f", "mp4", "pipe:1",
|
||||||
@@ -251,7 +259,8 @@ async def create_camera(request: Request) -> dict:
|
|||||||
runtime.config.cameras.append(cam)
|
runtime.config.cameras.append(cam)
|
||||||
save_config(runtime.config)
|
save_config(runtime.config)
|
||||||
runtime.supervisor.add_camera(cam)
|
runtime.supervisor.add_camera(cam)
|
||||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
if cam.enabled:
|
||||||
|
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||||
return _camera_payload(cam_id)
|
return _camera_payload(cam_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -266,19 +275,29 @@ async def update_camera(cam_id: str, request: Request) -> dict:
|
|||||||
runtime.config.cameras[idx] = cam
|
runtime.config.cameras[idx] = cam
|
||||||
save_config(runtime.config)
|
save_config(runtime.config)
|
||||||
await runtime.supervisor.replace_camera(cam)
|
await runtime.supervisor.replace_camera(cam)
|
||||||
await go2rtc.add_camera_streams(runtime.config, 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)
|
return _camera_payload(cam_id)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{cam_id}")
|
@router.delete("/{cam_id}")
|
||||||
async def delete_camera(cam_id: str, request: Request) -> dict:
|
async def delete_camera(cam_id: str, request: Request) -> dict:
|
||||||
require_role(request, "admin")
|
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")
|
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)
|
save_config(runtime.config)
|
||||||
await runtime.supervisor.remove_camera(cam_id)
|
await runtime.supervisor.remove_camera(cam_id)
|
||||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||||
|
if shifts:
|
||||||
|
await runtime.supervisor.restart_all()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -295,3 +314,38 @@ async def clear_cameras(request: Request) -> dict:
|
|||||||
await runtime.supervisor.remove_camera(cam_id)
|
await runtime.supervisor.remove_camera(cam_id)
|
||||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||||
return {"ok": True, "removed": len(ids)}
|
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 ..runtime import runtime
|
||||||
from ..transcode import (H264_PROFILES, BITRATES, encode_args, norm_profile,
|
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"])
|
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ async def recording_transcoded(
|
|||||||
preset: str = Query(None),
|
preset: str = Query(None),
|
||||||
keyint: str = Query(None),
|
keyint: str = Query(None),
|
||||||
codec: str = Query(None),
|
codec: str = Query(None),
|
||||||
|
rc: str = Query(None),
|
||||||
|
qp: str = Query(None),
|
||||||
start: float = Query(0.0),
|
start: float = Query(0.0),
|
||||||
):
|
):
|
||||||
"""Перекодирование H.265→H.264 на лету (fragmented mp4) — для браузеров без HEVC.
|
"""Перекодирование 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 []),
|
*(["-ss", f"{start:.3f}"] if start and start > 0 else []),
|
||||||
"-i", rec.path,
|
"-i", rec.path,
|
||||||
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate),
|
*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",
|
"-an",
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||||
"-f", "mp4", "pipe:1",
|
"-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
|
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 ..runtime import runtime
|
||||||
from ..services.system import system_stats
|
from ..services.system import system_stats
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["system"])
|
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")
|
VERSION_FILE = os.environ.get("NVR_VERSION_FILE", "/app/VERSION")
|
||||||
|
|
||||||
|
|
||||||
@@ -36,25 +33,3 @@ async def get_system() -> dict:
|
|||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
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}
|
|
||||||
|
|||||||
+161
-18
@@ -18,6 +18,8 @@ class Camera:
|
|||||||
password: str
|
password: str
|
||||||
main_path: str
|
main_path: str
|
||||||
sub_path: str | None = None
|
sub_path: str | None = None
|
||||||
|
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
|
||||||
|
storage_id: str = "" # в какое хранилище писать ("" = активное/по умолчанию)
|
||||||
|
|
||||||
def rtsp_url(self, path: str) -> str:
|
def rtsp_url(self, path: str) -> str:
|
||||||
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
|
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
|
||||||
@@ -28,12 +30,45 @@ class Camera:
|
|||||||
return self.rtsp_url(self.main_path)
|
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)
|
@dataclass(frozen=True)
|
||||||
class Storage:
|
class Storage:
|
||||||
path: str = "/records"
|
"""Глобальная политика записи + список хранилищ и id активного.
|
||||||
quota_gb: int = 50
|
|
||||||
|
Квота — на каждом StorageItem (не здесь). retention_days/segment_seconds —
|
||||||
|
глобальные: срок хранения по возрасту и длина сегмента для всех хранилищ.
|
||||||
|
"""
|
||||||
|
active: str = "local"
|
||||||
retention_days: int = 7
|
retention_days: int = 7
|
||||||
segment_seconds: int = 600
|
segment_seconds: int = 600
|
||||||
|
items: tuple[StorageItem, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
# Дефолтное хранилище (когда конфиг пуст / битый) — совпадает с историческим /records.
|
||||||
|
_DEFAULT_STORAGE = StorageItem(
|
||||||
|
id="local", name="Локальное хранилище", type="local", path="/records", quota_gb=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -54,6 +89,44 @@ class Config:
|
|||||||
def enabled_cameras(self) -> list[Camera]:
|
def enabled_cameras(self) -> list[Camera]:
|
||||||
return [c for c in self.cameras if c.enabled]
|
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:
|
def load_config(path: str | None = None) -> Config:
|
||||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||||
@@ -68,46 +141,116 @@ def load_config(path: str | None = None) -> Config:
|
|||||||
except (OSError, yaml.YAMLError):
|
except (OSError, yaml.YAMLError):
|
||||||
raw = {}
|
raw = {}
|
||||||
|
|
||||||
storage = Storage(**(raw.get("storage") or {}))
|
storage = _storage_from_raw(raw.get("storage") or {})
|
||||||
live = Live(**(raw.get("live") or {}))
|
live = Live(**(raw.get("live") or {}))
|
||||||
cameras = [
|
cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])]
|
||||||
Camera(
|
|
||||||
id=c["id"],
|
|
||||||
name=c.get("name", c["id"]),
|
|
||||||
enabled=c.get("enabled", True),
|
|
||||||
ip=c["ip"],
|
|
||||||
user=c["user"],
|
|
||||||
password=c["password"],
|
|
||||||
main_path=c["main_path"],
|
|
||||||
sub_path=c.get("sub_path"),
|
|
||||||
)
|
|
||||||
for c in (raw.get("cameras") or [])
|
|
||||||
]
|
|
||||||
return Config(storage=storage, live=live, cameras=cameras)
|
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=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,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def camera_to_yaml(c: Camera) -> dict:
|
def camera_to_yaml(c: Camera) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
"name": c.name,
|
"name": c.name,
|
||||||
"enabled": c.enabled,
|
"enabled": c.enabled,
|
||||||
|
"record": c.record,
|
||||||
"ip": c.ip,
|
"ip": c.ip,
|
||||||
"user": c.user,
|
"user": c.user,
|
||||||
"password": c.password,
|
"password": c.password,
|
||||||
"main_path": c.main_path,
|
"main_path": c.main_path,
|
||||||
"sub_path": c.sub_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:
|
def save_config(config: Config, path: str | None = None) -> None:
|
||||||
"""Сохраняет конфиг обратно в cameras.yaml (комментарии теряются)."""
|
"""Сохраняет конфиг обратно в cameras.yaml (комментарии теряются)."""
|
||||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||||
data = {
|
data = {
|
||||||
"storage": {
|
"storage": {
|
||||||
"path": config.storage.path,
|
"active": config.storage.active,
|
||||||
"quota_gb": config.storage.quota_gb,
|
|
||||||
"retention_days": config.storage.retention_days,
|
"retention_days": config.storage.retention_days,
|
||||||
"segment_seconds": config.storage.segment_seconds,
|
"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},
|
"live": {"go2rtc_url": config.live.go2rtc_url},
|
||||||
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import aiosqlite
|
|||||||
|
|
||||||
from .models import Recording
|
from .models import Recording
|
||||||
|
|
||||||
|
|
||||||
|
def _like_prefix(prefix: str) -> str:
|
||||||
|
"""LIKE-шаблон для путей внутри хранилища: '<path>/%' с экранированием %, _, \\."""
|
||||||
|
p = prefix.rstrip("/") + "/"
|
||||||
|
p = p.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
return p + "%"
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS recordings (
|
CREATE TABLE IF NOT EXISTS recordings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -105,6 +113,24 @@ class Database:
|
|||||||
rows = await cur.fetchall()
|
rows = await cur.fetchall()
|
||||||
return [Recording(**dict(r)) for r in rows]
|
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]:
|
async def recordings_before(self, ts: int) -> list[Recording]:
|
||||||
cur = await self.conn.execute(
|
cur = await self.conn.execute(
|
||||||
"SELECT * FROM recordings WHERE started_at < ? ORDER BY started_at ASC", (ts,)
|
"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 .services.retention import Retention
|
||||||
from .runtime import runtime
|
from .runtime import runtime
|
||||||
from . import auth, prefs
|
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 .api.ws import ConnectionManager
|
||||||
from .services import go2rtc
|
from .services import go2rtc
|
||||||
|
|
||||||
@@ -28,6 +28,13 @@ logging.basicConfig(
|
|||||||
log = logging.getLogger("nvr")
|
log = logging.getLogger("nvr")
|
||||||
|
|
||||||
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
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
|
@asynccontextmanager
|
||||||
@@ -46,25 +53,31 @@ async def lifespan(app: FastAPI):
|
|||||||
runtime.indexer = Indexer(runtime.config, runtime.db)
|
runtime.indexer = Indexer(runtime.config, runtime.db)
|
||||||
runtime.retention = Retention(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 для рестрима
|
await go2rtc.sync_all(runtime.config) # потоки (вкл. <cam>_main) в go2rtc для рестрима
|
||||||
|
runtime.reconciler = go2rtc.Reconciler(runtime.config) # самовосстановление потоков go2rtc
|
||||||
runtime.supervisor.start_all()
|
runtime.supervisor.start_all()
|
||||||
runtime.indexer.start()
|
runtime.indexer.start()
|
||||||
runtime.retention.start()
|
runtime.retention.start()
|
||||||
log.info("NVR запущен: камер=%d, хранилище=%s",
|
runtime.reconciler.start()
|
||||||
len(runtime.config.cameras), runtime.config.storage.path)
|
log.info("NVR запущен: камер=%d, активное хранилище=%s",
|
||||||
|
len(runtime.config.cameras), runtime.config.active_storage().path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
log.info("остановка NVR…")
|
log.info("остановка NVR…")
|
||||||
|
await runtime.reconciler.stop()
|
||||||
await runtime.indexer.stop()
|
await runtime.indexer.stop()
|
||||||
await runtime.retention.stop()
|
await runtime.retention.stop()
|
||||||
await runtime.supervisor.stop_all()
|
await runtime.supervisor.stop_all()
|
||||||
await runtime.db.close()
|
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"}
|
PUBLIC_PATHS = {"/login", "/api/login", "/api/health"}
|
||||||
@@ -96,14 +109,17 @@ app.include_router(auth.router)
|
|||||||
app.include_router(prefs.router)
|
app.include_router(prefs.router)
|
||||||
app.include_router(cameras.router)
|
app.include_router(cameras.router)
|
||||||
app.include_router(recordings.router)
|
app.include_router(recordings.router)
|
||||||
|
app.include_router(storages.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
app.include_router(ws.router)
|
app.include_router(ws.router)
|
||||||
|
|
||||||
|
|
||||||
# ── фронтенд ────────────────────────────────────────────────────
|
# ── фронтенд ────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index() -> FileResponse:
|
async def index(request: Request) -> FileResponse:
|
||||||
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
# на мобильном порту — мобильный dashboard (Live одной камеры + Архив)
|
||||||
|
page = "mobile.html" if _is_mobile(request) else "index.html"
|
||||||
|
return FileResponse(os.path.join(FRONTEND_DIR, page))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/archive")
|
@app.get("/archive")
|
||||||
|
|||||||
@@ -5,19 +5,22 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
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 копированием.
|
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
||||||
|
|
||||||
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
||||||
|
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
||||||
|
sub_dir — имя подпапки камеры (номер слота, напр. «05»); по умолчанию — id камеры.
|
||||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
||||||
"""
|
"""
|
||||||
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
|
# Подпапка даты: <root>/<sub_dir>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
|
||||||
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
|
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
|
||||||
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
|
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
|
||||||
out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
|
out_template = os.path.join(out_root, sub_dir or cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
|
||||||
return [
|
return [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
@@ -29,7 +32,7 @@ def record_args(cam: Camera, storage: Storage, input_url: str | None = None) ->
|
|||||||
"-an", # звук не пишем (камеры обычно без него)
|
"-an", # звук не пишем (камеры обычно без него)
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"-f", "segment",
|
"-f", "segment",
|
||||||
"-segment_time", str(storage.segment_seconds),
|
"-segment_time", str(segment_seconds),
|
||||||
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
|
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
|
||||||
"-segment_clocktime_offset", "0",
|
"-segment_clocktime_offset", "0",
|
||||||
"-segment_format", "mp4",
|
"-segment_format", "mp4",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..db import Database
|
from ..db import Database
|
||||||
@@ -32,22 +32,34 @@ def _parse_started_at(path: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _scan_segments(root: str) -> dict[str, list[str]]:
|
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]] = {}
|
result: dict[str, list[str]] = {}
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
return result
|
return result
|
||||||
for cam_id in os.listdir(root):
|
for folder in os.listdir(root):
|
||||||
cam_dir = os.path.join(root, cam_id)
|
cam_dir = os.path.join(root, folder)
|
||||||
if not os.path.isdir(cam_dir):
|
if not os.path.isdir(cam_dir):
|
||||||
continue
|
continue
|
||||||
files = [
|
files: list[str] = []
|
||||||
os.path.join(cam_dir, fn)
|
for entry in os.listdir(cam_dir):
|
||||||
for fn in os.listdir(cam_dir)
|
p = os.path.join(cam_dir, entry)
|
||||||
if fn.endswith(".mp4")
|
if entry.endswith(".mp4") and os.path.isfile(p):
|
||||||
]
|
files.append(p) # старый плоский формат
|
||||||
files.sort()
|
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:
|
if files:
|
||||||
result[cam_id] = files
|
result[folder] = files
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -74,30 +86,64 @@ class Indexer:
|
|||||||
async def _loop(self) -> None:
|
async def _loop(self) -> None:
|
||||||
while not self._stopping:
|
while not self._stopping:
|
||||||
try:
|
try:
|
||||||
|
self._ensure_date_dirs()
|
||||||
await self.scan_once()
|
await self.scan_once()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
log.exception("indexer scan failed")
|
log.exception("indexer scan failed")
|
||||||
await asyncio.sleep(SCAN_INTERVAL)
|
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:
|
async def scan_once(self) -> int:
|
||||||
root = self.config.storage.path
|
active_path = self.config.active_storage().path
|
||||||
by_cam = _scan_segments(root)
|
|
||||||
known = await self.db.known_paths()
|
known = await self.db.known_paths()
|
||||||
added = 0
|
added = 0
|
||||||
|
|
||||||
for cam_id, files in by_cam.items():
|
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
|
||||||
# последний файл может писаться прямо сейчас — не индексируем
|
for root in self.config.storage_paths():
|
||||||
complete = files[:-1] if len(files) > 1 else []
|
by_dir = _scan_segments(root)
|
||||||
for path in complete:
|
is_active = (root == active_path)
|
||||||
if path in known:
|
for folder, files in by_dir.items():
|
||||||
continue
|
# Сопоставление папки с камерой по приоритету:
|
||||||
duration, size = await probe_duration(path)
|
# 1) точное совпадение с id камеры — легаси-папка по id (в т.ч. числовому),
|
||||||
if size is None or size == 0:
|
# иначе папка камеры с id «5» спуталась бы со слотом 5;
|
||||||
continue
|
# 2) папка-слот «NN» → камера в этом слоте (новая схема);
|
||||||
await self.db.add_recording(
|
# 3) иначе оставляем имя папки (осиротевший архив индексируется как раньше).
|
||||||
cam_id, path, _parse_started_at(path), duration, size
|
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
|
||||||
)
|
)
|
||||||
added += 1
|
complete = files[:-1] if being_written else files
|
||||||
|
for path in complete:
|
||||||
|
if path in known:
|
||||||
|
continue
|
||||||
|
duration, size = await probe_duration(path)
|
||||||
|
if size is None or size == 0:
|
||||||
|
continue
|
||||||
|
await self.db.add_recording(
|
||||||
|
cam_id, path, _parse_started_at(path), duration, size
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
|
||||||
# уборка пропавших файлов
|
# уборка пропавших файлов
|
||||||
for path in known:
|
for path in known:
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
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 ..models import CameraState, CameraStatus
|
||||||
from .ffmpeg import record_args
|
from .ffmpeg import record_args
|
||||||
|
|
||||||
@@ -21,9 +22,12 @@ MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => сч
|
|||||||
class RecorderTask:
|
class RecorderTask:
|
||||||
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
"""Держит один процесс 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.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.input_url = input_url # источник записи (рестрим go2rtc)
|
||||||
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
||||||
self.status = CameraStatus(
|
self.status = CameraStatus(
|
||||||
@@ -69,11 +73,33 @@ class RecorderTask:
|
|||||||
# ── основной цикл ───────────────────────────────────────────
|
# ── основной цикл ───────────────────────────────────────────
|
||||||
async def _run(self) -> None:
|
async def _run(self) -> None:
|
||||||
backoff = BACKOFF_START
|
backoff = BACKOFF_START
|
||||||
os.makedirs(os.path.join(self.storage.path, self.cam.id), exist_ok=True)
|
|
||||||
|
|
||||||
while not self._stopping:
|
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)
|
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))
|
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
|
||||||
try:
|
try:
|
||||||
self._proc = await asyncio.create_subprocess_exec(
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -21,16 +21,23 @@ class Supervisor:
|
|||||||
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
|
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
|
||||||
return go2rtc.restream_main_url(self.config, cam.id)
|
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:
|
def start_all(self) -> None:
|
||||||
for cam in self.config.cameras:
|
for cam in self.config.cameras:
|
||||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
task = self._new_task(cam)
|
||||||
input_url=self._input_url(cam))
|
|
||||||
self.tasks[cam.id] = task
|
self.tasks[cam.id] = task
|
||||||
if cam.enabled:
|
if cam.enabled and cam.record:
|
||||||
task.start()
|
task.start()
|
||||||
log.info("recorder started: %s", cam.id)
|
log.info("recorder started: %s", cam.id)
|
||||||
else:
|
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:
|
async def stop_all(self) -> None:
|
||||||
for task in self.tasks.values():
|
for task in self.tasks.values():
|
||||||
@@ -57,10 +64,9 @@ class Supervisor:
|
|||||||
|
|
||||||
def add_camera(self, cam: Camera) -> None:
|
def add_camera(self, cam: Camera) -> None:
|
||||||
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
|
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
|
||||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
task = self._new_task(cam)
|
||||||
input_url=self._input_url(cam))
|
|
||||||
self.tasks[cam.id] = task
|
self.tasks[cam.id] = task
|
||||||
if cam.enabled:
|
if cam.enabled and cam.record:
|
||||||
task.start()
|
task.start()
|
||||||
log.info("recorder added: %s", cam.id)
|
log.info("recorder added: %s", cam.id)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
|
|||||||
from .recorder.indexer import Indexer
|
from .recorder.indexer import Indexer
|
||||||
from .recorder.supervisor import Supervisor
|
from .recorder.supervisor import Supervisor
|
||||||
from .services.retention import Retention
|
from .services.retention import Retention
|
||||||
|
from .services.go2rtc import Reconciler
|
||||||
from .api.ws import ConnectionManager
|
from .api.ws import ConnectionManager
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ class Runtime:
|
|||||||
supervisor: "Supervisor"
|
supervisor: "Supervisor"
|
||||||
indexer: "Indexer"
|
indexer: "Indexer"
|
||||||
retention: "Retention"
|
retention: "Retention"
|
||||||
|
reconciler: "Reconciler"
|
||||||
ws: "ConnectionManager"
|
ws: "ConnectionManager"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ go2rtc-потоки на камеру:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -27,6 +28,7 @@ from ..config import Camera, Config
|
|||||||
log = logging.getLogger("nvr.go2rtc")
|
log = logging.getLogger("nvr.go2rtc")
|
||||||
|
|
||||||
GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml")
|
GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml")
|
||||||
|
RECONCILE_INTERVAL = 30 # сек — период сверки рантайма go2rtc с конфигом
|
||||||
|
|
||||||
|
|
||||||
def _rtsp_host(config: Config) -> str:
|
def _rtsp_host(config: Config) -> str:
|
||||||
@@ -58,6 +60,8 @@ def _streams_for(cam: Camera) -> dict[str, str]:
|
|||||||
def build_streams(config: Config) -> dict:
|
def build_streams(config: Config) -> dict:
|
||||||
streams: dict = {}
|
streams: dict = {}
|
||||||
for c in config.cameras:
|
for c in config.cameras:
|
||||||
|
if not c.enabled: # выключенная камера — без потоков (ни live, ни записи)
|
||||||
|
continue
|
||||||
for name, src in _streams_for(c).items():
|
for name, src in _streams_for(c).items():
|
||||||
streams[name] = [src]
|
streams[name] = [src]
|
||||||
return streams
|
return streams
|
||||||
@@ -118,5 +122,80 @@ async def sync_all(config: Config) -> None:
|
|||||||
write_go2rtc_yaml(config)
|
write_go2rtc_yaml(config)
|
||||||
base = config.live.go2rtc_url.rstrip("/")
|
base = config.live.go2rtc_url.rstrip("/")
|
||||||
for cam in config.cameras:
|
for cam in config.cameras:
|
||||||
|
if not cam.enabled:
|
||||||
|
continue
|
||||||
for name, src in _streams_for(cam).items():
|
for name, src in _streams_for(cam).items():
|
||||||
await _call("PUT", _put(base, name, src))
|
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,20 +61,22 @@ class Retention:
|
|||||||
return len(old)
|
return len(old)
|
||||||
|
|
||||||
async def _enforce_quota(self) -> int:
|
async def _enforce_quota(self) -> int:
|
||||||
quota = self.config.storage.quota_gb * 1024 ** 3
|
# квота — на КАЖДОЕ хранилище отдельно (0 = без лимита). Для каждого удаляем
|
||||||
if not quota:
|
# самые старые записи ВНУТРИ него, пока его размер не уложится в его квоту.
|
||||||
return 0
|
|
||||||
removed = 0
|
removed = 0
|
||||||
# удаляем самые старые, пока суммарный размер не уложится в квоту
|
for item in self.config.storage.items:
|
||||||
while await self.db.total_size() > quota:
|
if not item.quota_gb:
|
||||||
batch = await self.db.oldest_recordings(limit=20)
|
continue
|
||||||
if not batch:
|
quota = item.quota_gb * 1024 ** 3
|
||||||
break
|
while await self.db.total_size_under(item.path) > quota:
|
||||||
for rec in batch:
|
batch = await self.db.oldest_recordings_under(item.path, limit=20)
|
||||||
await self._remove(rec.id, rec.path)
|
if not batch:
|
||||||
removed += 1
|
|
||||||
if await self.db.total_size() <= quota:
|
|
||||||
break
|
break
|
||||||
|
for rec in batch:
|
||||||
|
await self._remove(rec.id, rec.path)
|
||||||
|
removed += 1
|
||||||
|
if await self.db.total_size_under(item.path) <= quota:
|
||||||
|
break
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
async def _remove(self, rec_id: int, path: str) -> None:
|
async def _remove(self, rec_id: int, path: str) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
|
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -11,19 +12,31 @@ from ..config import Config
|
|||||||
_START = time.time()
|
_START = time.time()
|
||||||
|
|
||||||
|
|
||||||
def system_stats(config: Config) -> dict:
|
def storage_disk(path: str) -> dict:
|
||||||
path = config.storage.path
|
"""Использование диска, на котором лежит path (для одного хранилища)."""
|
||||||
try:
|
try:
|
||||||
usage = shutil.disk_usage(path)
|
usage = shutil.disk_usage(path)
|
||||||
disk = {
|
return {
|
||||||
"total_gb": round(usage.total / 1024 ** 3, 1),
|
"total_gb": round(usage.total / 1024 ** 3, 1),
|
||||||
"used_gb": round(usage.used / 1024 ** 3, 1),
|
"used_gb": round(usage.used / 1024 ** 3, 1),
|
||||||
"free_gb": round(usage.free / 1024 ** 3, 1),
|
"free_gb": round(usage.free / 1024 ** 3, 1),
|
||||||
"percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
"percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
||||||
}
|
}
|
||||||
except OSError:
|
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()
|
vm = psutil.virtual_memory()
|
||||||
return {
|
return {
|
||||||
"uptime_s": int(time.time() - _START),
|
"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_used_gb": round(vm.used / 1024 ** 3, 1),
|
||||||
"ram_total_gb": round(vm.total / 1024 ** 3, 1),
|
"ram_total_gb": round(vm.total / 1024 ** 3, 1),
|
||||||
"storage_path": path,
|
"storage_path": path,
|
||||||
"disk": disk,
|
"disk": storage_disk(path),
|
||||||
"quota_gb": config.storage.quota_gb,
|
"quota_gb": active.quota_gb,
|
||||||
"retention_days": config.storage.retention_days,
|
"retention_days": config.storage.retention_days,
|
||||||
"segment_seconds": config.storage.segment_seconds,
|
"segment_seconds": config.storage.segment_seconds,
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-22
@@ -1,17 +1,25 @@
|
|||||||
"""Общие параметры перекодирования H.264 (профиль, битрейт, пресет, GOP)."""
|
"""Общие параметры перекодирования H.264/VP9: профиль, битрейт, пресет, GOP, режим RC."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
H264_PROFILES = ("baseline", "main", "high")
|
H264_PROFILES = ("baseline", "main", "high")
|
||||||
BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k")
|
BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k")
|
||||||
PRESETS = ("ultrafast", "superfast")
|
PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium")
|
||||||
KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды
|
KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды
|
||||||
CODECS = ("h264", "vp9")
|
CODECS = ("h264", "vp9")
|
||||||
|
RC_MODES = ("vbr", "cbr", "qp") # контроль битрейта: переменный / постоянный / постоянный квантизатор
|
||||||
|
|
||||||
DEFAULT_PROFILE = "main"
|
DEFAULT_PROFILE = "main"
|
||||||
DEFAULT_BITRATE = "2000k"
|
DEFAULT_BITRATE = "2000k"
|
||||||
DEFAULT_PRESET = "superfast"
|
DEFAULT_PRESET = "superfast"
|
||||||
DEFAULT_KEYINT = 2
|
DEFAULT_KEYINT = 2
|
||||||
DEFAULT_CODEC = "h264"
|
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:
|
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
|
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:
|
def norm_keyint(k) -> int:
|
||||||
try:
|
try:
|
||||||
k = int(k)
|
k = int(k)
|
||||||
@@ -38,51 +50,80 @@ def norm_keyint(k) -> int:
|
|||||||
return k if k in KEYINTS else DEFAULT_KEYINT
|
return k if k in KEYINTS else DEFAULT_KEYINT
|
||||||
|
|
||||||
|
|
||||||
def x264_args(profile: str, bitrate: str, low_latency: bool = False,
|
def norm_qp(q) -> int:
|
||||||
preset: str | None = None, keyint_sec=None) -> list[str]:
|
try:
|
||||||
"""Аргументы libx264: профиль, битрейт, пресет и интервал ключевых кадров.
|
q = int(q)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_QP
|
||||||
|
return max(QP_MIN, min(QP_MAX, q))
|
||||||
|
|
||||||
Ключевые кадры расставляются по времени (через -force_key_frames), поэтому
|
|
||||||
интервал в секундах соблюдается независимо от fps камеры."""
|
def _kbps(bitrate: str) -> int:
|
||||||
bufsize = f"{int(bitrate[:-1]) * 2}k"
|
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)
|
preset = norm_preset(preset)
|
||||||
sec = norm_keyint(keyint_sec)
|
sec = norm_keyint(keyint_sec)
|
||||||
|
rc = norm_rc(rc)
|
||||||
args = [
|
args = [
|
||||||
"-c:v", "libx264",
|
"-c:v", "libx264",
|
||||||
"-preset", preset,
|
"-preset", preset,
|
||||||
"-profile:v", profile,
|
"-profile:v", profile,
|
||||||
"-b:v", bitrate,
|
|
||||||
"-maxrate", bitrate,
|
|
||||||
"-bufsize", bufsize,
|
|
||||||
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
||||||
"-pix_fmt", "yuv420p",
|
"-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:
|
if low_latency:
|
||||||
args += ["-tune", "zerolatency"]
|
args += ["-tune", "zerolatency"]
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
def vp9_args(bitrate: str, low_latency: bool = False,
|
def vp9_args(bitrate: str, low_latency: bool = False,
|
||||||
preset: str | None = None, keyint_sec=None) -> list[str]:
|
preset: str | None = None, keyint_sec=None,
|
||||||
"""Аргументы кодировщика libvpx-VP9 (профиль H.264 неприменим)."""
|
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)
|
sec = norm_keyint(keyint_sec)
|
||||||
# быстрый пресет → выше cpu-used (быстрее, ниже качество); realtime для live
|
rc = norm_rc(rc)
|
||||||
cpu = "8" if norm_preset(preset) == "ultrafast" else "6"
|
args = [
|
||||||
return [
|
|
||||||
"-c:v", "libvpx-vp9",
|
"-c:v", "libvpx-vp9",
|
||||||
"-b:v", bitrate,
|
|
||||||
"-maxrate", bitrate,
|
|
||||||
"-deadline", "realtime",
|
"-deadline", "realtime",
|
||||||
"-cpu-used", cpu,
|
"-cpu-used", _VP9_CPU.get(norm_preset(preset), "6"),
|
||||||
"-row-mt", "1",
|
"-row-mt", "1",
|
||||||
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
||||||
"-pix_fmt", "yuv420p",
|
"-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,
|
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)."""
|
"""Аргументы видеокодировщика под выбранный кодек (h264 | vp9)."""
|
||||||
if norm_codec(codec) == "vp9":
|
if norm_codec(codec) == "vp9":
|
||||||
return vp9_args(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)
|
return x264_args(profile, bitrate, low_latency, preset, keyint_sec, rc, qp)
|
||||||
|
|||||||
+8
-4
@@ -10,7 +10,8 @@ services:
|
|||||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090",
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090",
|
||||||
"--ssl-keyfile", "/app/certs/key.pem", "--ssl-certfile", "/app/certs/cert.pem"]
|
"--ssl-keyfile", "/app/certs/key.pem", "--ssl-certfile", "/app/certs/cert.pem"]
|
||||||
ports:
|
ports:
|
||||||
- "443:8090" # дашборд на https://<сервер>
|
- "443:8090" # десктоп-дашборд на https://<сервер>
|
||||||
|
- "8443:8090" # мобильный dashboard https://<сервер>:8443 (тот же бэкенд, mobile.html по Host)
|
||||||
volumes:
|
volumes:
|
||||||
- ./certs:/app/certs # self-signed TLS-сертификат (rw: entrypoint генерирует при отсутствии)
|
- ./certs:/app/certs # self-signed TLS-сертификат (rw: entrypoint генерирует при отсутствии)
|
||||||
- ./VERSION:/app/VERSION:ro # номер версии (показывается в UI)
|
- ./VERSION:/app/VERSION:ro # номер версии (показывается в UI)
|
||||||
@@ -20,10 +21,13 @@ services:
|
|||||||
- ./backend/app:/app/app # код бэкенда (правка без пересборки образа)
|
- ./backend/app:/app/app # код бэкенда (правка без пересборки образа)
|
||||||
- ./frontend:/app/frontend # фронтенд (правка без пересборки)
|
- ./frontend:/app/frontend # фронтенд (правка без пересборки)
|
||||||
- ./data:/data # SQLite-индекс архива
|
- ./data:/data # SQLite-индекс архива
|
||||||
# Запись. Локальная папка ./records ИЛИ смонтированный на хост NAS (SMB/NFS).
|
# Запись. ./records — дефолтное локальное хранилище (id=local).
|
||||||
# Для NAS: смонтируйте на хосте (например /mnt/nas/cctv) и замените левую часть:
|
|
||||||
# - /mnt/nas/cctv:/records
|
|
||||||
- ./records:/records
|
- ./records:/records
|
||||||
|
# Общий корень доп. хранилищ (вкладка «Хранилища»): локальные подпапки
|
||||||
|
# /storages/<id> и точки монтирования NAS. NAS монтируется на ХОСТЕ в
|
||||||
|
# ./storages/<id>; после `mount` на хосте выполните `docker compose up -d`,
|
||||||
|
# чтобы том стал виден здесь (или добавьте :rshared для live-подхвата).
|
||||||
|
- ./storages:/storages
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
NVR_CONFIG: /app/cameras.yaml
|
NVR_CONFIG: /app/cameras.yaml
|
||||||
|
|||||||
+22
-26
@@ -3,48 +3,44 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/player.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-page="archive">
|
<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">
|
<div class="layout">
|
||||||
<aside>
|
<aside>
|
||||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
<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>
|
<a class="nav-item active" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||||
<div class="aside-spacer"></div>
|
<div class="aside-spacer"></div>
|
||||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="archive-main">
|
<main class="archive-main">
|
||||||
<div class="toolbar">
|
<aside class="arch-side">
|
||||||
<label class="muted">Камера:</label>
|
<div class="arch-cams" id="arch-cams"></div>
|
||||||
<select id="cam-select"></select>
|
<div class="arch-date">
|
||||||
<label class="muted">Дата:</label>
|
<input type="date" id="date" class="arch-dateinput">
|
||||||
<input type="date" id="date">
|
<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>
|
</div>
|
||||||
<div class="player-wrap" id="player">
|
|
||||||
<div class="hint">Кликните по таймлайну ниже, чтобы смотреть запись</div>
|
|
||||||
</div>
|
|
||||||
<div class="timeline" id="timeline"></div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/player.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-8
@@ -3,17 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-page="cams">
|
<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">
|
<div class="layout">
|
||||||
<aside>
|
<aside>
|
||||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||||
|
|||||||
+26
-20
@@ -3,38 +3,44 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-page="live">
|
<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">
|
<div class="layout">
|
||||||
<aside>
|
<aside>
|
||||||
<a class="nav-item active" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
<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>
|
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||||
<div class="aside-spacer"></div>
|
<div class="aside-spacer"></div>
|
||||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main>
|
<main id="live-main">
|
||||||
<div class="grid g4" id="grid"></div>
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+2
-21
@@ -3,32 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-page="layout">
|
<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">
|
<div class="layout">
|
||||||
<aside>
|
<aside>
|
||||||
<div class="navline">
|
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||||
<a class="nav-item" href="/" data-nav="live" style="flex:1"><span class="ico">▦</span>Live view</a>
|
|
||||||
<a class="nav-gear active" href="/layout" title="Настройка раскладки">⚙</a>
|
|
||||||
</div>
|
|
||||||
<div class="subnav">
|
|
||||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
|
||||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
|
||||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
|
||||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
|
||||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
|
||||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
|
||||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
|
||||||
</div>
|
|
||||||
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||||
<div class="aside-spacer"></div>
|
<div class="aside-spacer"></div>
|
||||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||||
|
|||||||
+2
-2
@@ -3,12 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="login-body">
|
<body class="login-body">
|
||||||
<form class="login-card" id="login-form">
|
<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="text" id="username" placeholder="Логин" autocomplete="username" autofocus>
|
||||||
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
|
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
|
||||||
<button type="submit">Войти</button>
|
<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>
|
||||||
+116
-63
@@ -3,29 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-page="settings">
|
<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">
|
<div class="layout">
|
||||||
<aside>
|
<aside>
|
||||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
<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>
|
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||||
<div class="aside-spacer"></div>
|
<div class="aside-spacer"></div>
|
||||||
<a class="nav-item active" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
<a class="nav-item active" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||||
@@ -34,7 +18,9 @@
|
|||||||
<main class="settings-main">
|
<main class="settings-main">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" data-tab="cams" data-min-role="admin">Камеры</button>
|
<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="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="users" data-min-role="superadmin">Пользователи</button>
|
||||||
<button class="tab" data-tab="general" data-min-role="operator">Система</button>
|
<button class="tab" data-tab="general" data-min-role="operator">Система</button>
|
||||||
<button class="tab" data-tab="optim" data-min-role="superadmin">Доп настройки</button>
|
<button class="tab" data-tab="optim" data-min-role="superadmin">Доп настройки</button>
|
||||||
@@ -48,56 +34,137 @@
|
|||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button id="bulk-edit">Изменить</button>
|
<button id="bulk-edit">Изменить</button>
|
||||||
<button id="bulk-cancel" hidden>Отмена</button>
|
<button id="bulk-cancel" hidden>Отмена</button>
|
||||||
<button id="add-cam">+ Добавить камеру</button>
|
|
||||||
<button id="clear-cams" class="danger" title="Удалить все камеры (архив не трогается)">Очистить все</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="cams">
|
<table class="cams cams-fixed">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody id="cam-rows"></tbody>
|
<tbody id="cam-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ВКЛАДКА: Хранилища (управление записью) -->
|
||||||
|
<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>
|
||||||
|
</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: Система -->
|
<!-- ВКЛАДКА 2: Система -->
|
||||||
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="card" data-min-role="admin">
|
<div class="card">
|
||||||
<h2>Запись</h2>
|
<h2>Система</h2>
|
||||||
<label class="fl" style="font-size:13px;color:var(--text)">Размер сегмента</label>
|
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
|
||||||
<select id="segment-size" style="margin-top:6px;display:block">
|
|
||||||
<option value="60">1 минута</option>
|
|
||||||
<option value="120">2 минуты</option>
|
|
||||||
<option value="300">5 минут</option>
|
|
||||||
<option value="600">10 минут</option>
|
|
||||||
<option value="900">15 минут</option>
|
|
||||||
</select>
|
|
||||||
<div style="margin-top:12px"><button id="segment-save">Сохранить</button></div>
|
|
||||||
<div class="muted" id="segment-saved" style="margin-top:8px;font-size:12px"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</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">
|
<div class="card">
|
||||||
<h2>Плеер и перекодирование <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
|
<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">
|
<div class="muted" style="margin-top:-4px;margin-bottom:16px;font-size:12px">
|
||||||
Для каждого потока выберите способ воспроизведения: <b>как есть</b> —
|
Для каждого потока выберите способ воспроизведения: <b>встроенный плеер</b> —
|
||||||
поток отдаётся без обработки (ffmpeg -c copy), браузер декодирует HEVC сам
|
декодирует поток прямо в браузере без нагрузки на сервер (ffmpeg -c copy):
|
||||||
(нужна аппаратная поддержка); <b>встроенный плеер</b> — декодирует H.265
|
H.265/H.265+ — софтом (WASM hevc.js в Web Worker), H.264/H.264+ — средствами
|
||||||
в браузере софтом (WASM hevc.js, в Web Worker), тянет и основной поток
|
браузера; тянет и основной поток (3200×1800, включая тайлы), и субпоток
|
||||||
(3200×1800, включая тайлы), и субпоток, без нагрузки на сервер (декод
|
(декод нагружает CPU клиента); <b>перекодировать</b> — сервер транскодирует
|
||||||
нагружает CPU клиента); <b>WebCodecs (железо)</b> — аппаратный декод H.265 на GPU клиента,
|
в H.264 / VP9 (нагрузка на CPU сервера, для слабых клиентов).
|
||||||
тянет основной поток 3200×1800 при 0% CPU сервера (рекомендуется для
|
|
||||||
основного потока); <b>перекодировать</b> — сервер транскодирует в H.264 /
|
|
||||||
VP9 (нагрузка на CPU сервера, для слабых клиентов).
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 3 независимых профиля: 1 поток / 2 поток / архив -->
|
<!-- 3 независимых профиля: 1 поток / 2 поток / архив -->
|
||||||
<div class="tr-block" data-tr="1">
|
<div class="tr-block" data-tr="1">
|
||||||
<h3>1 поток (основной)</h3>
|
<h3>1 поток (основной)</h3>
|
||||||
<div class="seg-toggle">
|
<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="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>
|
<label><input type="radio" name="mode-1" value="transcode"><span>Перекодировать</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="tr-row"></div>
|
<div class="tr-row"></div>
|
||||||
@@ -106,9 +173,7 @@
|
|||||||
<div class="tr-block" data-tr="2">
|
<div class="tr-block" data-tr="2">
|
||||||
<h3>2 поток (суб)</h3>
|
<h3>2 поток (суб)</h3>
|
||||||
<div class="seg-toggle">
|
<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="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>
|
<label><input type="radio" name="mode-2" value="transcode"><span>Перекодировать</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="tr-row"></div>
|
<div class="tr-row"></div>
|
||||||
@@ -117,7 +182,6 @@
|
|||||||
<div class="tr-block" data-tr="a">
|
<div class="tr-block" data-tr="a">
|
||||||
<h3>Архив</h3>
|
<h3>Архив</h3>
|
||||||
<div class="seg-toggle">
|
<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="player"><span>Встроенный плеер</span></label>
|
||||||
<label><input type="radio" name="mode-a" value="transcode"><span>Перекодировать</span></label>
|
<label><input type="radio" name="mode-a" value="transcode"><span>Перекодировать</span></label>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,22 +191,6 @@
|
|||||||
<div style="margin-top:16px"><button id="prefs-save">Сохранить</button></div>
|
<div style="margin-top:16px"><button id="prefs-save">Сохранить</button></div>
|
||||||
<div class="muted" id="prefs-saved" style="margin-top:8px;font-size:12px">Сохраняется в этом браузере</div>
|
<div class="muted" id="prefs-saved" style="margin-top:8px;font-size:12px">Сохраняется в этом браузере</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Хранилище</h2>
|
|
||||||
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Система</h2>
|
|
||||||
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Учётная запись</h2>
|
|
||||||
<div class="muted" id="me-info" style="margin-bottom:10px;font-size:13px"></div>
|
|
||||||
<button id="logout">Выйти из системы</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,6 +252,11 @@
|
|||||||
|
|
||||||
<!-- ВКЛАДКА 4: Пользователи (только суперадмин) -->
|
<!-- ВКЛАДКА 4: Пользователи (только суперадмин) -->
|
||||||
<div class="tab-panel" data-panel="users" data-min-role="superadmin" hidden>
|
<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">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>Пользователи и роли</h2>
|
<h2>Пользователи и роли</h2>
|
||||||
|
|||||||
+815
-445
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;
|
--ok: #22c55e;
|
||||||
--warn: #f59e0b;
|
--warn: #f59e0b;
|
||||||
--err: #ef4444;
|
--err: #ef4444;
|
||||||
--header-h: 48px;
|
--header-h: 50px;
|
||||||
--sidebar-w: 240px;
|
--footer-h: 22px;
|
||||||
|
--sidebar-w: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -26,12 +27,12 @@ html, body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Шапка ── */
|
/* ── Шапка (50px, бренд по центру) ── */
|
||||||
header {
|
header.app-header {
|
||||||
height: var(--header-h); display: flex; align-items: center;
|
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);
|
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 { font-weight: 700; letter-spacing: .5px; color: #fff; }
|
||||||
.brand span { color: #c0392b; }
|
.brand span { color: #c0392b; }
|
||||||
nav { display: flex; gap: 4px; }
|
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; }
|
.stats b { color: var(--text); font-weight: 600; }
|
||||||
.clock { font-variant-numeric: tabular-nums; color: var(--text); }
|
.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 {
|
aside {
|
||||||
width: var(--sidebar-w); background: var(--panel); border-right: 1px solid var(--border);
|
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 {
|
.aside-title {
|
||||||
padding: 12px 14px 8px; font-size: 11px; text-transform: uppercase;
|
padding: 12px 14px 8px; font-size: 11px; text-transform: uppercase;
|
||||||
@@ -58,14 +70,14 @@ aside {
|
|||||||
}
|
}
|
||||||
/* ── Навигация в сайдбаре ── */
|
/* ── Навигация в сайдбаре ── */
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
||||||
color: var(--text-dim); text-decoration: none; font-weight: 500; font-size: 14px;
|
width: 64px; height: 64px; padding: 0; border-radius: 12px;
|
||||||
border-left: 3px solid transparent; background: none; border-top: 0; border-right: 0;
|
color: var(--text-dim); text-decoration: none; font-weight: 500; font-size: 9px; line-height: 1.15;
|
||||||
border-bottom: 0; cursor: pointer; width: 100%; text-align: left; font-family: inherit;
|
border: 0; background: none; cursor: pointer; text-align: center; font-family: inherit;
|
||||||
}
|
}
|
||||||
.nav-item:hover { background: var(--panel-2); color: var(--text); }
|
.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.active { background: var(--panel-2); color: #fff; box-shadow: inset 0 0 0 1px #c0392b; }
|
||||||
.nav-item .ico { font-size: 17px; line-height: 1; width: 20px; text-align: center; }
|
.nav-item .ico { font-size: 26px; line-height: 1; width: auto; text-align: center; }
|
||||||
.aside-spacer { flex: 1; }
|
.aside-spacer { flex: 1; }
|
||||||
|
|
||||||
/* Иконка настройки раскладки рядом с Live view */
|
/* Иконка настройки раскладки рядом с Live view */
|
||||||
@@ -84,7 +96,7 @@ aside {
|
|||||||
.subitem.active { color: #fff; border-left-color: #c0392b; background: var(--panel-2); }
|
.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.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); }
|
.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 { color: var(--text-dim); border-top: 1px solid var(--border); }
|
||||||
.nav-item.logout:hover { color: #e57373; }
|
.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"]:checked::before { transform: translateX(18px); background: var(--ok); }
|
||||||
input[type="checkbox"]:focus-visible { outline: 2px solid var(--ok); outline-offset: 2px; }
|
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);
|
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||||
padding: 6px 8px; border-radius: 6px;
|
padding: 6px 8px; border-radius: 6px;
|
||||||
}
|
}
|
||||||
@@ -158,6 +170,42 @@ input[type="date"], select {
|
|||||||
background: #000; overflow: hidden;
|
background: #000; overflow: hidden;
|
||||||
grid-auto-rows: 1fr; grid-auto-columns: 1fr; /* все дорожки строго равны */
|
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 {
|
.tile {
|
||||||
position: relative; background: #05070a; border: 1px solid var(--border);
|
position: relative; background: #05070a; border: 1px solid var(--border);
|
||||||
display: flex; align-items: center; justify-content: center; overflow: hidden;
|
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 { border: none; background: #000; }
|
||||||
.tile:fullscreen video, .tile:fullscreen video-stream { object-fit: contain; }
|
.tile:fullscreen video, .tile:fullscreen video-stream { object-fit: contain; }
|
||||||
#grid:fullscreen { width: 100vw; height: 100vh; background: var(--bg); padding: 0; }
|
#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 {
|
.hevc-hint {
|
||||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
|
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
|
||||||
background: rgba(63, 3, 3, .88); color: #fff; font-size: 11px;
|
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 { 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 video { max-width: 100%; max-height: 100%; }
|
||||||
.player-wrap .hint { color: var(--text-dim); }
|
.player-wrap .hint { color: var(--text-dim); }
|
||||||
|
|
||||||
/* ── плеер архива в стиле Playerjs ── */
|
/* ── плеер архива (.pjs) вынесен в общий static/player.css (подключается в <head>) ── */
|
||||||
.player-wrap .pjs { position: relative; width: 100%; height: 100%; background: #000; outline: none; overflow: hidden; }
|
|
||||||
.pjs-video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; cursor: pointer; }
|
|
||||||
/* центр: большая кнопка play (на паузе) + спиннер (при буферизации) */
|
|
||||||
.pjs-center { position: absolute; inset: 0; pointer-events: none; }
|
|
||||||
.pjs-bigplay {
|
|
||||||
position: absolute; top: 50%; left: 50%; width: 74px; height: 74px; border-radius: 50%;
|
|
||||||
border: none; cursor: pointer; pointer-events: auto; background: rgba(10,12,18,.55); color: #fff;
|
|
||||||
font-size: 26px; padding-left: 5px; opacity: 0; transition: opacity .15s, transform .15s;
|
|
||||||
transform: translate(-50%, -50%) scale(.9);
|
|
||||||
}
|
|
||||||
.pjs.paused:not(.buffering) .pjs-bigplay { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
|
||||||
.pjs-bigplay:hover { background: rgba(10,12,18,.82); }
|
|
||||||
.pjs-spinner {
|
|
||||||
position: absolute; top: 50%; left: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px;
|
|
||||||
border: 3px solid rgba(255,255,255,.22); border-top-color: #fff; border-radius: 50%; opacity: 0;
|
|
||||||
}
|
|
||||||
.pjs.buffering .pjs-spinner { opacity: 1; animation: pjs-spin .8s linear infinite; }
|
|
||||||
@keyframes pjs-spin { to { transform: rotate(360deg); } }
|
|
||||||
/* нижняя панель управления */
|
|
||||||
.pjs-bar {
|
|
||||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
|
|
||||||
display: flex; align-items: center; gap: 10px; padding: 26px 14px 11px;
|
|
||||||
background: linear-gradient(transparent, rgba(0,0,0,.8));
|
|
||||||
opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s;
|
|
||||||
}
|
|
||||||
.pjs.show .pjs-bar, .pjs:hover .pjs-bar, .pjs.paused .pjs-bar { opacity: 1; transform: none; }
|
|
||||||
.pjs-btn {
|
|
||||||
background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; line-height: 1;
|
|
||||||
padding: 5px 7px; border-radius: 5px; opacity: .9; transition: background .12s, opacity .12s;
|
|
||||||
}
|
|
||||||
.pjs-btn:hover { opacity: 1; background: rgba(255,255,255,.14); }
|
|
||||||
.pjs-time, .pjs-dur { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 40px; text-align: center; opacity: .92; }
|
|
||||||
/* шкала перемотки */
|
|
||||||
.pjs-seek { position: relative; flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.25); cursor: pointer; touch-action: none; }
|
|
||||||
.pjs-seek:hover { height: 7px; }
|
|
||||||
.pjs-buf { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: rgba(255,255,255,.35); border-radius: 3px; }
|
|
||||||
.pjs-prog { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #e23b3b; border-radius: 3px; }
|
|
||||||
.pjs-knob {
|
|
||||||
position: absolute; top: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%;
|
|
||||||
background: #e23b3b; box-shadow: 0 0 4px rgba(0,0,0,.5); transform: translateY(-50%) scale(0); transition: transform .12s;
|
|
||||||
}
|
|
||||||
.pjs-seek:hover .pjs-knob, .pjs.show .pjs-knob { transform: translateY(-50%) scale(1); }
|
|
||||||
.pjs.fs:not(.show) { cursor: none; }
|
|
||||||
|
|
||||||
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
|
/* DVR-таймлайн архива: лента тащится мышью/колесом, красный указатель зафиксирован по центру */
|
||||||
.timeline {
|
.timeline {
|
||||||
@@ -328,7 +381,7 @@ input[type="date"], select {
|
|||||||
.settings-main .wrap { max-width: 920px; margin: 0 auto; }
|
.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 {
|
.tab {
|
||||||
background: none; border: none; border-bottom: 2px solid transparent; border-radius: 0;
|
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;
|
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:hover { color: var(--text); }
|
||||||
.tab.active { color: #fff; border-bottom-color: #c0392b; }
|
.tab.active { color: #fff; border-bottom-color: #c0392b; }
|
||||||
.tab-panel[hidden] { display: none; }
|
.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; }
|
.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 label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--text-dim); }
|
||||||
.tr-row select { display: block; }
|
.tr-gbody select, .tr-gbody input[type="number"] { display: block; }
|
||||||
/* общие пресеты перекодирования (SOFT / MEDIUM / HARD) */
|
.tr-gbody input[type="number"] { width: 80px; }
|
||||||
.tr-presets { flex-basis: 100%; display: flex; align-items: center; gap: 8px; padding-left: 28px; }
|
.tr-group-preset select { min-width: 120px; }
|
||||||
.tr-preset {
|
.tr-row select:disabled, .tr-row input:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
padding: 4px 12px; font-size: 12px; font-weight: 600; border-radius: 6px;
|
|
||||||
background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); cursor: pointer;
|
|
||||||
}
|
|
||||||
.tr-preset[data-preset="SOFT"] { color: #e03131; }
|
|
||||||
.tr-preset[data-preset="MEDIUM"] { color: #e9c46a; }
|
|
||||||
.tr-preset[data-preset="HARD"] { color: #ffffff; }
|
|
||||||
.tr-preset.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
||||||
/* блок профиля: режим (свой плеер / перекодировать) + ряд параметров */
|
/* блок профиля: режим (свой плеер / перекодировать) + ряд параметров */
|
||||||
.tr-block { padding: 12px 0; border-top: 1px solid var(--border); }
|
.tr-block { padding: 12px 0; border-top: 1px solid var(--border); }
|
||||||
.tr-block:first-of-type { border-top: none; padding-top: 0; }
|
.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 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 td.actions { white-space: nowrap; text-align: right; }
|
||||||
table.cams tr.slot-empty td { opacity: .5; }
|
table.cams tr.slot-empty td { opacity: .5; }
|
||||||
/* строка-контейнер лога не должна занимать высоту, пока лог скрыт */
|
/* камеры: жёстко фиксированная высота строки 22px независимо от контролов.
|
||||||
table.cams tr.logrow > td { padding: 0; border: none; }
|
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; }
|
.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 { border-color: #c0392b; color: #e74c3c; }
|
||||||
button.danger:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
|
button.danger:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
|
||||||
table.cams td.actions button { padding: 2px 8px; font-size: 12px; }
|
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 {
|
.badge {
|
||||||
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
|
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
|
||||||
border-radius: 11px; font-size: 12px; background: var(--panel-2);
|
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 {
|
.logbox {
|
||||||
margin: 4px 10px 12px; padding: 10px; background: #05070a; border: 1px solid var(--border);
|
margin: 4px 10px 12px; padding: 10px; background: #05070a; border: 1px solid var(--border);
|
||||||
border-radius: 6px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
border-radius: 6px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||||
|
|||||||
+12
-7
@@ -286,7 +286,11 @@ var FrameRenderer = class _FrameRenderer {
|
|||||||
format: "I420",
|
format: "I420",
|
||||||
codedWidth: w,
|
codedWidth: w,
|
||||||
codedHeight: h,
|
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);
|
await this._writer.write(videoFrame);
|
||||||
videoFrame.close();
|
videoFrame.close();
|
||||||
@@ -394,12 +398,13 @@ var FRAGMENT_SRC = `
|
|||||||
uniform sampler2D u_texCb;
|
uniform sampler2D u_texCb;
|
||||||
uniform sampler2D u_texCr;
|
uniform sampler2D u_texCr;
|
||||||
void main() {
|
void main() {
|
||||||
float y = texture2D(u_texY, v_tex).r;
|
// BT.709 limited range (TV): Y∈[16,235]/255 → (Y-16)*255/219; Cb/Cr∈[16,240]/255 центр 128/255.
|
||||||
float cb = texture2D(u_texCb, v_tex).r - 0.5;
|
float y = (texture2D(u_texY, v_tex).r - 0.0627451) * 1.1643836;
|
||||||
float cr = texture2D(u_texCr, v_tex).r - 0.5;
|
float cb = texture2D(u_texCb, v_tex).r - 0.5019608;
|
||||||
float r = y + 1.5748 * cr;
|
float cr = texture2D(u_texCr, v_tex).r - 0.5019608;
|
||||||
float g = y - 0.1873 * cb - 0.4681 * cr;
|
float r = y + 1.7927411 * cr;
|
||||||
float b = y + 1.8556 * cb;
|
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);
|
gl_FragColor = vec4(clamp(r, 0.0, 1.0), clamp(g, 0.0, 1.0), clamp(b, 0.0, 1.0), 1.0);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user