Compare commits
32 Commits
346d3b5b96
...
4a8eddf1d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,7 +275,10 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -295,3 +307,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}
|
|
||||||
|
|||||||
+138
-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,21 @@ 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]
|
||||||
|
|
||||||
|
# ── хранилища ───────────────────────────────────────────────
|
||||||
|
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 +118,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,)
|
||||||
|
|||||||
+12
-5
@@ -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
|
||||||
|
|
||||||
@@ -46,25 +46,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,6 +102,7 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,21 @@ 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) -> list[str]:
|
||||||
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
||||||
|
|
||||||
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
||||||
|
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
||||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
||||||
"""
|
"""
|
||||||
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
|
# Подпапка даты: <root>/<cam>/<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, cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
|
||||||
return [
|
return [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
@@ -29,7 +31,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,7 +32,11 @@ 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, отсортированы]}."""
|
"""Возвращает {camera_id: [пути mp4, отсортированы]}.
|
||||||
|
|
||||||
|
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские»
|
||||||
|
<cam>/*.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
|
||||||
@@ -40,12 +44,19 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
|
|||||||
cam_dir = os.path.join(root, cam_id)
|
cam_dir = os.path.join(root, cam_id)
|
||||||
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[cam_id] = files
|
||||||
return result
|
return result
|
||||||
@@ -74,30 +85,52 @@ 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
|
||||||
|
for d in days:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.join(store.path, cam.id, 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_cam = _scan_segments(root)
|
||||||
for path in complete:
|
is_active = (root == active_path)
|
||||||
if path in known:
|
for cam_id, files in by_cam.items():
|
||||||
continue
|
# в активном хранилище последний файл ещё пишется — пропускаем;
|
||||||
duration, size = await probe_duration(path)
|
# в неактивных все сегменты завершены, индексируем целиком.
|
||||||
if size is None or size == 0:
|
complete = files[:-1] if is_active else files
|
||||||
continue
|
for path in complete:
|
||||||
await self.db.add_recording(
|
if path in known:
|
||||||
cam_id, path, _parse_started_at(path), duration, size
|
continue
|
||||||
)
|
duration, size = await probe_duration(path)
|
||||||
added += 1
|
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,11 @@ 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):
|
||||||
self.cam = cam
|
self.cam = cam
|
||||||
self.storage = storage
|
self.store = store # активное хранилище (куда пишем)
|
||||||
|
self.segment_seconds = segment_seconds
|
||||||
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 +72,32 @@ 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:
|
||||||
|
# подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
|
||||||
|
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
|
||||||
|
base = os.path.join(self.store.path, self.cam.id)
|
||||||
|
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)
|
||||||
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,22 @@ 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))
|
||||||
|
|
||||||
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 +63,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)
|
||||||
|
|||||||
+6
-3
@@ -20,10 +20,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
|
||||||
|
|||||||
+20
-26
@@ -3,45 +3,39 @@
|
|||||||
<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="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>
|
||||||
|
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
+25
-20
@@ -3,38 +3,43 @@
|
|||||||
<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="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>
|
||||||
|
|||||||
+110
-49
@@ -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,36 +34,122 @@
|
|||||||
<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">
|
||||||
@@ -127,22 +199,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 +260,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>
|
||||||
|
|||||||
+684
-158
File diff suppressed because it is too large
Load Diff
+164
-32
@@ -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 */
|
||||||
@@ -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,7 +250,51 @@ input[type="date"], select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Архив ── */
|
/* ── Архив ── */
|
||||||
.archive-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
.archive-main { flex: 1; display: flex; 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); }
|
||||||
@@ -328,7 +423,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 +431,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 +508,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 +550,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