|
|
import http.client
|
|
|
import json
|
|
|
import mimetypes
|
|
|
import os
|
|
|
import re
|
|
|
import shutil
|
|
|
import socket
|
|
|
import subprocess
|
|
|
import sys
|
|
|
import threading
|
|
|
import time
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
from pathlib import Path
|
|
|
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
|
|
|
|
|
|
from configurable_udp_capture import (
|
|
|
DEFAULT_PACKET_LAYOUT,
|
|
|
DEFAULT_PACKET_SCHEMA,
|
|
|
normalize_packet_layout,
|
|
|
normalize_packet_schema,
|
|
|
packet_layout_from_schema,
|
|
|
packet_schema_from_layout,
|
|
|
)
|
|
|
from udp_probe import run_udp_probe
|
|
|
|
|
|
APP_DIR = Path(__file__).resolve().parent
|
|
|
DATA_DIR = Path(os.environ.get("FPV_DATA_DIR", "/data"))
|
|
|
HOST = os.environ.get("FPV_UI_HOST", "127.0.0.1")
|
|
|
PORT = int(os.environ.get("FPV_UI_PORT", "8080"))
|
|
|
MAX_JSON_BODY_BYTES = 1 * 1024 * 1024
|
|
|
LOG_PATH = Path(os.environ.get("FPV_UI_LOG_PATH", str(DATA_DIR / "logs" / "main.log")))
|
|
|
FRAME_PATH = Path(os.environ.get("FPV_UI_FRAME_PATH", str(DATA_DIR / "ui" / "latest.jpg")))
|
|
|
GUIDANCE_PATH = Path(os.environ.get("FPV_UI_GUIDANCE_PATH", str(DATA_DIR / "guidance" / "guidance_state.json")))
|
|
|
OUT_DIR = Path(os.environ.get("FPV_UI_OUT_DIR", str(DATA_DIR / "out")))
|
|
|
DOWNLOAD_DIR = Path(os.environ.get("FPV_UI_DOWNLOAD_DIR", str(OUT_DIR / ".downloads")))
|
|
|
ACTIVE_VIDEO_PATH = Path(os.environ.get("FPV_UI_ACTIVE_VIDEO_PATH", str(OUT_DIR / ".active_video")))
|
|
|
INPUT_DIR = Path(os.environ.get("FPV_UI_INPUT_DIR", str(DATA_DIR / "input")))
|
|
|
UDP_PROBE_DIR = Path(os.environ.get("FPV_UI_UDP_PROBE_DIR", str(DATA_DIR / "udp-probes")))
|
|
|
CONTROL_PATH = Path(os.environ.get("FPV_UI_CONTROL_PATH", str(DATA_DIR / "ui" / "control_state.json")))
|
|
|
MAIN_SCRIPT = Path(os.environ.get("FPV_UI_MAIN_SCRIPT", str(APP_DIR / "main.py")))
|
|
|
PYTHON_EXE = os.environ.get("FPV_UI_PYTHON", sys.executable)
|
|
|
MODEL_PATH = os.environ.get("FPV_MODEL_PATH", str(APP_DIR / "best.pt"))
|
|
|
MODEL_DIR = Path(os.environ.get("FPV_UI_MODEL_DIR", str(DATA_DIR / "models")))
|
|
|
MODEL_EXTENSIONS = {".pt"}
|
|
|
MODEL_INFO_LOCK = threading.Lock()
|
|
|
MODEL_INFO_CACHE = {}
|
|
|
NETRON_HOST = "127.0.0.1"
|
|
|
NETRON_PORT = int(os.environ.get("FPV_NETRON_PORT", "8092"))
|
|
|
NETRON_LOCK = threading.Lock()
|
|
|
NETRON_STATE = {"path": ""}
|
|
|
CONTROL_LOCK = threading.Lock()
|
|
|
UDP_PROBE_LOCK = threading.Lock()
|
|
|
CONTROL_PROCESS = None
|
|
|
CONTROL_SOURCE = ""
|
|
|
|
|
|
QUALITIES = [
|
|
|
{"label": "4K 3840x2160", "width": 3840, "height": 2160},
|
|
|
{"label": "QHD 2560x1440", "width": 2560, "height": 1440},
|
|
|
{"label": "Full HD 1920x1080", "width": 1920, "height": 1080},
|
|
|
{"label": "HD 1280x720", "width": 1280, "height": 720},
|
|
|
{"label": "XGA 1024x768", "width": 1024, "height": 768},
|
|
|
{"label": "PAL 720x576", "width": 720, "height": 576},
|
|
|
{"label": "VGA 640x480", "width": 640, "height": 480},
|
|
|
{"label": "UDP-камера 512x640", "width": 512, "height": 640},
|
|
|
]
|
|
|
FPS_OPTIONS = [120, 60, 50, 30, 25, 24, 15]
|
|
|
MIN_FRAME_SIZE = 16
|
|
|
MAX_FRAME_SIZE = 8192
|
|
|
MIN_CAPTURE_FPS = 1
|
|
|
MAX_CAPTURE_FPS = 240
|
|
|
VIDEO_EXTENSIONS = {
|
|
|
".3g2", ".3gp", ".264", ".265", ".asf", ".avi", ".divx", ".dv", ".f4v",
|
|
|
".flv", ".h264", ".h265", ".hevc", ".m2t", ".m2ts", ".m4v", ".mkv", ".mov",
|
|
|
".mp4", ".mpeg", ".mpg", ".mts", ".mxf", ".ogm", ".ogv", ".rm", ".rmvb",
|
|
|
".ts", ".vob", ".webm", ".wmv", ".y4m",
|
|
|
}
|
|
|
UDP_DUMP_EXTENSIONS = {"", ".udp", ".dump"}
|
|
|
INPUT_EXTENSIONS = VIDEO_EXTENSIONS | UDP_DUMP_EXTENSIONS
|
|
|
ERROR_PROTOCOLS = ["guidance_v1", "json", "csv", "bin"]
|
|
|
ERROR_UNITS = ["px", "norm", "deg", "m"]
|
|
|
ARCHIVE_RECORD_MODES = ["fragments", "full"]
|
|
|
FILE_SOURCE_MODES = {"file", "udp_dump", "udp_delimited_file"}
|
|
|
LIVE_SOURCE_MODES = {"udp_mik_live", "udp_delimited_live", "udp_custom_live"}
|
|
|
SOURCE_MODES = {"camera"} | FILE_SOURCE_MODES | LIVE_SOURCE_MODES
|
|
|
FRAME_ENCODINGS = {"auto", "bgr24", "rgb24", "gray8", "gray16", "yuyv422"}
|
|
|
PACKET_PRESETS = {"auto", "mik", "delimited", "custom"}
|
|
|
PACKET_PRESET_BY_SOURCE = {
|
|
|
"udp_mik_live": "mik",
|
|
|
"udp_delimited_live": "delimited",
|
|
|
"udp_custom_live": "custom",
|
|
|
}
|
|
|
H264_CACHE_VERSION = "v2"
|
|
|
DEFAULT_ERROR_HOST = os.environ.get("FPV_ERROR_OUTPUT_HOST", "127.0.0.1" if os.name == "nt" else "host.docker.internal")
|
|
|
DEFAULT_CONTROL = {
|
|
|
"model_path": MODEL_PATH,
|
|
|
"device": 0,
|
|
|
"use_half": True,
|
|
|
"conf": 0.25,
|
|
|
"img_size_roi": 640,
|
|
|
"img_size_full": 1280,
|
|
|
"max_det": 60,
|
|
|
"source_mode": "file",
|
|
|
"camera_index": 0,
|
|
|
"file_path": str(INPUT_DIR / "source.mp4"),
|
|
|
"input_host": "0.0.0.0",
|
|
|
"input_port": 59004,
|
|
|
"separator_byte": 0,
|
|
|
"frame_encoding": "auto",
|
|
|
"packet_preset": "auto",
|
|
|
"packet_layout": [field.copy() for field in DEFAULT_PACKET_LAYOUT],
|
|
|
"packet_schema": DEFAULT_PACKET_SCHEMA.copy(),
|
|
|
"quality": "1280x720",
|
|
|
"fps": 30,
|
|
|
"run_mode": "realtime",
|
|
|
"frame_mode": "hd",
|
|
|
"save": True,
|
|
|
"archive_mode": os.environ.get("FPV_ARCHIVE_RECORD_MODE", "fragments"),
|
|
|
"fragment_gap_sec": float(os.environ.get("FPV_DETECTION_CLIP_MAX_GAP_SEC", "15")),
|
|
|
"error_output": True,
|
|
|
"error_protocol": os.environ.get("FPV_ERROR_OUTPUT_PROTOCOL", "guidance_v1"),
|
|
|
"error_host": DEFAULT_ERROR_HOST,
|
|
|
"error_port": int(os.environ.get("FPV_ERROR_OUTPUT_PORT", "5010")),
|
|
|
"error_object_id": int(os.environ.get("FPV_ERROR_OUTPUT_OBJECT_ID", "1")),
|
|
|
"error_units": os.environ.get("FPV_ERROR_OUTPUT_UNITS", "px"),
|
|
|
"error_hfov": float(os.environ.get("FPV_ERROR_OUTPUT_HFOV_DEG", "90")),
|
|
|
"error_vfov": float(os.environ.get("FPV_ERROR_OUTPUT_VFOV_DEG", "60")),
|
|
|
"error_range_m": float(os.environ.get("FPV_ERROR_OUTPUT_RANGE_M", "0")),
|
|
|
}
|
|
|
|
|
|
|
|
|
def tail_text(path, lines=80):
|
|
|
if not path.exists():
|
|
|
return ""
|
|
|
data = path.read_bytes()[-65536:]
|
|
|
text = data.decode("utf-8", errors="replace")
|
|
|
return "\n".join(text.splitlines()[-int(lines):])
|
|
|
|
|
|
|
|
|
def read_json_file(path):
|
|
|
if not path.exists():
|
|
|
return {}
|
|
|
try:
|
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
except (OSError, json.JSONDecodeError):
|
|
|
return {}
|
|
|
|
|
|
|
|
|
def latest_file(root, pattern):
|
|
|
if not root.exists():
|
|
|
return None
|
|
|
files = [p for p in root.glob(pattern) if p.is_file()]
|
|
|
if not files:
|
|
|
return None
|
|
|
return max(files, key=lambda p: p.stat().st_mtime)
|
|
|
|
|
|
|
|
|
def archive_path(root, name):
|
|
|
decoded = unquote(str(name))
|
|
|
safe_name = Path(decoded).name
|
|
|
if safe_name != decoded or not safe_name.lower().endswith(".mp4"):
|
|
|
return None
|
|
|
path = root / safe_name
|
|
|
try:
|
|
|
path.resolve(strict=False).relative_to(Path(root).resolve())
|
|
|
except (OSError, ValueError):
|
|
|
return None
|
|
|
return path
|
|
|
|
|
|
|
|
|
def udp_probe_path(root, name):
|
|
|
decoded = unquote(str(name))
|
|
|
safe_name = Path(decoded).name
|
|
|
if safe_name != decoded or Path(safe_name).suffix.lower() not in {".udp", ".json"}:
|
|
|
return None
|
|
|
path = root / safe_name
|
|
|
try:
|
|
|
path.resolve(strict=False).relative_to(Path(root).resolve())
|
|
|
except (OSError, ValueError):
|
|
|
return None
|
|
|
return path
|
|
|
|
|
|
|
|
|
def archive_files(root, active_name=None):
|
|
|
if not root.exists():
|
|
|
return []
|
|
|
rows = []
|
|
|
for path in sorted(root.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
|
stat = path.stat()
|
|
|
rows.append({
|
|
|
"name": path.name,
|
|
|
"size": stat.st_size,
|
|
|
"mtime": int(stat.st_mtime),
|
|
|
"active": path.name == active_name,
|
|
|
})
|
|
|
return rows
|
|
|
|
|
|
|
|
|
def cleanup_empty_recordings(root, max_bytes=128):
|
|
|
removed = 0
|
|
|
if not root.exists():
|
|
|
return removed
|
|
|
for path in root.glob("*.mp4"):
|
|
|
try:
|
|
|
if path.stat().st_size <= int(max_bytes):
|
|
|
path.unlink()
|
|
|
removed += 1
|
|
|
except OSError:
|
|
|
pass
|
|
|
return removed
|
|
|
|
|
|
|
|
|
def active_video_name(root, marker_path=None):
|
|
|
marker_path = ACTIVE_VIDEO_PATH if marker_path is None else marker_path
|
|
|
try:
|
|
|
name = marker_path.read_text(encoding="utf-8").strip()
|
|
|
except OSError:
|
|
|
return None
|
|
|
path = archive_path(root, name)
|
|
|
if path and path.exists():
|
|
|
return path.name
|
|
|
return None
|
|
|
|
|
|
|
|
|
def safe_download_filename(name):
|
|
|
filename = Path(str(name)).name
|
|
|
ascii_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", filename).strip("._")
|
|
|
return ascii_name or "video.mp4"
|
|
|
|
|
|
|
|
|
def content_disposition(filename):
|
|
|
ascii_name = safe_download_filename(filename)
|
|
|
encoded = quote(Path(str(filename)).name)
|
|
|
return f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{encoded}'
|
|
|
|
|
|
|
|
|
def h264_download_name(path):
|
|
|
return f"{path.stem}_h264.mp4"
|
|
|
|
|
|
|
|
|
def h264_cache_path(path, cache_dir=DOWNLOAD_DIR):
|
|
|
stat = path.stat()
|
|
|
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", path.stem).strip("._") or "video"
|
|
|
return cache_dir / f"{stem}.{H264_CACHE_VERSION}.{stat.st_mtime_ns}.{stat.st_size}.h264.mp4"
|
|
|
|
|
|
|
|
|
def delete_h264_cache(path, cache_dir=DOWNLOAD_DIR):
|
|
|
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", path.stem).strip("._") or "video"
|
|
|
if not cache_dir.exists():
|
|
|
return
|
|
|
for cached in cache_dir.glob(f"{stem}.*.h264.mp4"):
|
|
|
try:
|
|
|
cached.unlink()
|
|
|
except OSError:
|
|
|
pass
|
|
|
|
|
|
|
|
|
def h264_download_file(path):
|
|
|
ffmpeg = ffmpeg_executable()
|
|
|
if not ffmpeg:
|
|
|
raise RuntimeError("ffmpeg unavailable")
|
|
|
cache = h264_cache_path(path)
|
|
|
if cache.exists() and cache.stat().st_size > 0:
|
|
|
return cache
|
|
|
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
|
temp = cache.with_name(f"{cache.name}.{threading.get_ident()}.tmp")
|
|
|
command = [
|
|
|
ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(path),
|
|
|
"-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
|
|
"-pix_fmt", "yuv420p", "-movflags", "+faststart", "-f", "mp4", str(temp),
|
|
|
]
|
|
|
try:
|
|
|
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
|
|
if result.returncode != 0:
|
|
|
raise RuntimeError((result.stderr or "ffmpeg failed").strip())
|
|
|
temp.replace(cache)
|
|
|
return cache
|
|
|
finally:
|
|
|
try:
|
|
|
temp.unlink()
|
|
|
except OSError:
|
|
|
pass
|
|
|
|
|
|
|
|
|
def write_json_file(path, data):
|
|
|
try:
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
except OSError:
|
|
|
pass
|
|
|
|
|
|
|
|
|
def read_control_state():
|
|
|
state = DEFAULT_CONTROL.copy()
|
|
|
data = read_json_file(CONTROL_PATH)
|
|
|
if isinstance(data, dict):
|
|
|
state.update({k: v for k, v in data.items() if k in state})
|
|
|
if "packet_preset" not in data:
|
|
|
state["packet_preset"] = PACKET_PRESET_BY_SOURCE.get(state.get("source_mode"), "auto")
|
|
|
if "packet_layout" not in data and "packet_schema" in data:
|
|
|
state["packet_layout"] = packet_layout_from_schema(data["packet_schema"])
|
|
|
if state.get("source_mode") in FILE_SOURCE_MODES:
|
|
|
path = resolve_video_path(state.get("file_path"))
|
|
|
default_path = INPUT_DIR / "source.mp4"
|
|
|
if not path.is_file() and default_path.is_file():
|
|
|
state["file_path"] = str(default_path)
|
|
|
return state
|
|
|
|
|
|
|
|
|
def looks_like_mik_dump(path):
|
|
|
try:
|
|
|
size = path.stat().st_size
|
|
|
with path.open("rb") as stream:
|
|
|
header = stream.read(12)
|
|
|
except OSError:
|
|
|
return False
|
|
|
if size < 12 or len(header) < 12:
|
|
|
return False
|
|
|
port = int.from_bytes(header[:2], "little")
|
|
|
payload_size = int.from_bytes(header[2:4], "little")
|
|
|
flags = header[5]
|
|
|
packet_number = header[7]
|
|
|
array_size = int.from_bytes(header[8:12], "little")
|
|
|
return (
|
|
|
0 < port <= 65535
|
|
|
and 8 <= payload_size <= 65507
|
|
|
and size >= 4 + payload_size
|
|
|
and flags & 0x02
|
|
|
and not flags & ~0x03
|
|
|
and packet_number == 0
|
|
|
and 0 < array_size <= 256 * 1024 * 1024
|
|
|
)
|
|
|
|
|
|
|
|
|
def input_file_kind(path):
|
|
|
if path.suffix.lower() in {".udp", ".dump"} or looks_like_mik_dump(path):
|
|
|
return "udp_dump"
|
|
|
return "video"
|
|
|
|
|
|
|
|
|
def input_video_files():
|
|
|
if not INPUT_DIR.exists():
|
|
|
return []
|
|
|
rows = []
|
|
|
for path in sorted(INPUT_DIR.iterdir(), key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True):
|
|
|
if not path.is_file() or path.name.startswith("."):
|
|
|
continue
|
|
|
stat = path.stat()
|
|
|
rows.append({
|
|
|
"name": path.name,
|
|
|
"path": str(path),
|
|
|
"size": stat.st_size,
|
|
|
"mtime": int(stat.st_mtime),
|
|
|
"kind": input_file_kind(path),
|
|
|
})
|
|
|
return rows
|
|
|
|
|
|
|
|
|
def parse_quality(value):
|
|
|
match = re.fullmatch(r"(\d{1,5})\s*[xх×]\s*(\d{1,5})", str(value or "").strip().lower())
|
|
|
if not match:
|
|
|
return 1280, 720
|
|
|
width = max(MIN_FRAME_SIZE, min(MAX_FRAME_SIZE, int(match.group(1))))
|
|
|
height = max(MIN_FRAME_SIZE, min(MAX_FRAME_SIZE, int(match.group(2))))
|
|
|
return width, height
|
|
|
|
|
|
|
|
|
def camera_bridge_source(base_url, camera_index, width, height, fps):
|
|
|
separator = "&" if "?" in base_url else "?"
|
|
|
query = urlencode({
|
|
|
"index": int(camera_index),
|
|
|
"width": int(width),
|
|
|
"height": int(height),
|
|
|
"fps": int(fps),
|
|
|
"quality": int(os.environ.get("FPV_CAMERA_BRIDGE_JPEG_QUALITY", "85")),
|
|
|
})
|
|
|
return f"{base_url}{separator}{query}"
|
|
|
|
|
|
|
|
|
def resolve_video_path(value):
|
|
|
path = Path(str(value or "").strip().strip('"'))
|
|
|
if not str(path):
|
|
|
path = INPUT_DIR / "source.mp4"
|
|
|
if not path.is_absolute():
|
|
|
path = APP_DIR / path
|
|
|
return path
|
|
|
|
|
|
|
|
|
def normalize_control(data):
|
|
|
state = read_control_state()
|
|
|
if isinstance(data, dict):
|
|
|
state.update({k: v for k, v in data.items() if k in state})
|
|
|
model_path = resolve_model_path(state.get("model_path"))
|
|
|
state["model_path"] = str(model_path or resolve_model_path(MODEL_PATH) or MODEL_PATH)
|
|
|
try:
|
|
|
state["device"] = max(-1, min(16, int(state.get("device", 0))))
|
|
|
except (TypeError, ValueError):
|
|
|
state["device"] = 0
|
|
|
raw_half = state.get("use_half", True)
|
|
|
state["use_half"] = raw_half if isinstance(raw_half, bool) else str(raw_half).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
try:
|
|
|
state["conf"] = max(0.01, min(1.0, float(state.get("conf", 0.25))))
|
|
|
except (TypeError, ValueError):
|
|
|
state["conf"] = 0.25
|
|
|
for key, default in (("img_size_roi", 640), ("img_size_full", 1280)):
|
|
|
try:
|
|
|
state[key] = max(128, min(4096, int(state.get(key, default))))
|
|
|
except (TypeError, ValueError):
|
|
|
state[key] = default
|
|
|
try:
|
|
|
state["max_det"] = max(1, min(300, int(state.get("max_det", 60))))
|
|
|
except (TypeError, ValueError):
|
|
|
state["max_det"] = 60
|
|
|
source_mode = str(state.get("source_mode") or "file").lower()
|
|
|
state["source_mode"] = source_mode if source_mode in SOURCE_MODES else "file"
|
|
|
state["camera_index"] = max(0, int(state.get("camera_index") or 0))
|
|
|
state["file_path"] = str(resolve_video_path(state.get("file_path")))
|
|
|
state["input_host"] = str(state.get("input_host") or "0.0.0.0").strip() or "0.0.0.0"
|
|
|
state["input_port"] = int(max(1, min(65535, int(state.get("input_port") or 59004))))
|
|
|
state["separator_byte"] = int(max(0, min(255, int(state.get("separator_byte") or 0))))
|
|
|
state["frame_encoding"] = str(state.get("frame_encoding") or "auto").lower()
|
|
|
if state["frame_encoding"] not in FRAME_ENCODINGS:
|
|
|
state["frame_encoding"] = "auto"
|
|
|
state["packet_preset"] = str(state.get("packet_preset") or "auto").lower()
|
|
|
if isinstance(data, dict) and "source_mode" in data and "packet_preset" not in data:
|
|
|
state["packet_preset"] = PACKET_PRESET_BY_SOURCE.get(state["source_mode"], "auto")
|
|
|
if state["packet_preset"] not in PACKET_PRESETS:
|
|
|
state["packet_preset"] = "auto"
|
|
|
if isinstance(data, dict) and "packet_schema" in data and "packet_layout" not in data:
|
|
|
state["packet_layout"] = packet_layout_from_schema(state["packet_schema"])
|
|
|
state["packet_layout"] = normalize_packet_layout(state.get("packet_layout"))
|
|
|
state["packet_schema"] = packet_schema_from_layout(
|
|
|
state["packet_layout"],
|
|
|
normalize_packet_schema(state.get("packet_schema")),
|
|
|
)
|
|
|
width, height = parse_quality(state.get("quality"))
|
|
|
state["quality"] = f"{width}x{height}"
|
|
|
fps = int(float(state.get("fps") or 30))
|
|
|
state["fps"] = max(MIN_CAPTURE_FPS, min(MAX_CAPTURE_FPS, fps))
|
|
|
state["run_mode"] = "fast" if state.get("run_mode") == "fast" else "realtime"
|
|
|
if state["source_mode"] in LIVE_SOURCE_MODES:
|
|
|
state["run_mode"] = "realtime"
|
|
|
state["frame_mode"] = "pal" if state.get("frame_mode") == "pal" else "hd"
|
|
|
state["save"] = bool(state.get("save", True))
|
|
|
state["archive_mode"] = str(state.get("archive_mode") or "fragments").lower()
|
|
|
if state["archive_mode"] not in ARCHIVE_RECORD_MODES:
|
|
|
state["archive_mode"] = "fragments"
|
|
|
state["fragment_gap_sec"] = float(max(0.0, min(3600.0, float(state.get("fragment_gap_sec") or 15.0))))
|
|
|
state["error_output"] = bool(state.get("error_output", False))
|
|
|
state["error_protocol"] = str(state.get("error_protocol") or "guidance_v1").lower()
|
|
|
if state["error_protocol"] not in ERROR_PROTOCOLS:
|
|
|
state["error_protocol"] = "guidance_v1"
|
|
|
state["error_units"] = str(state.get("error_units") or "px").lower()
|
|
|
if state["error_units"] not in ERROR_UNITS:
|
|
|
state["error_units"] = "px"
|
|
|
state["error_host"] = str(state.get("error_host") or DEFAULT_ERROR_HOST).strip() or DEFAULT_ERROR_HOST
|
|
|
state["error_port"] = int(max(1, min(65535, int(state.get("error_port") or 5010))))
|
|
|
state["error_object_id"] = int(max(1, min(255, int(state.get("error_object_id") or 1))))
|
|
|
state["error_hfov"] = float(max(1.0, min(179.0, float(state.get("error_hfov") or 90.0))))
|
|
|
state["error_vfov"] = float(max(1.0, min(179.0, float(state.get("error_vfov") or 60.0))))
|
|
|
state["error_range_m"] = float(max(0.0, float(state.get("error_range_m") or 0.0)))
|
|
|
return state
|
|
|
|
|
|
|
|
|
def process_running():
|
|
|
global CONTROL_PROCESS
|
|
|
if CONTROL_PROCESS is None:
|
|
|
return False
|
|
|
if CONTROL_PROCESS.poll() is None:
|
|
|
return True
|
|
|
CONTROL_PROCESS = None
|
|
|
return False
|
|
|
|
|
|
|
|
|
def control_payload():
|
|
|
state = read_control_state()
|
|
|
running = process_running()
|
|
|
active_name = active_video_name(OUT_DIR) if running else None
|
|
|
if not running:
|
|
|
try:
|
|
|
ACTIVE_VIDEO_PATH.unlink(missing_ok=True)
|
|
|
except OSError:
|
|
|
pass
|
|
|
return {
|
|
|
**state,
|
|
|
"running": running,
|
|
|
"pid": CONTROL_PROCESS.pid if CONTROL_PROCESS and CONTROL_PROCESS.poll() is None else None,
|
|
|
"active_video": active_name or "",
|
|
|
"qualities": QUALITIES,
|
|
|
"fps_options": FPS_OPTIONS,
|
|
|
"error_protocols": ERROR_PROTOCOLS,
|
|
|
"error_units_options": ERROR_UNITS,
|
|
|
"archive_record_modes": ARCHIVE_RECORD_MODES,
|
|
|
"default_input": str(INPUT_DIR / "source.mp4"),
|
|
|
"default_model": str(resolve_model_path(MODEL_PATH) or MODEL_PATH),
|
|
|
}
|
|
|
|
|
|
|
|
|
def stop_control_process():
|
|
|
global CONTROL_PROCESS, CONTROL_SOURCE
|
|
|
if CONTROL_PROCESS is None or CONTROL_PROCESS.poll() is not None:
|
|
|
CONTROL_PROCESS = None
|
|
|
CONTROL_SOURCE = ""
|
|
|
try:
|
|
|
ACTIVE_VIDEO_PATH.unlink(missing_ok=True)
|
|
|
except OSError:
|
|
|
pass
|
|
|
return False
|
|
|
pid = CONTROL_PROCESS.pid
|
|
|
if os.name == "nt":
|
|
|
subprocess.run(
|
|
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
check=False,
|
|
|
)
|
|
|
else:
|
|
|
CONTROL_PROCESS.terminate()
|
|
|
try:
|
|
|
CONTROL_PROCESS.wait(timeout=5)
|
|
|
except subprocess.TimeoutExpired:
|
|
|
CONTROL_PROCESS.kill()
|
|
|
CONTROL_PROCESS.wait(timeout=5)
|
|
|
CONTROL_PROCESS = None
|
|
|
CONTROL_SOURCE = ""
|
|
|
try:
|
|
|
ACTIVE_VIDEO_PATH.unlink(missing_ok=True)
|
|
|
except OSError:
|
|
|
pass
|
|
|
return True
|
|
|
|
|
|
|
|
|
def start_control_process(data):
|
|
|
global CONTROL_PROCESS, CONTROL_SOURCE
|
|
|
state = normalize_control(data)
|
|
|
if state["source_mode"] in FILE_SOURCE_MODES and not Path(state["file_path"]).is_file():
|
|
|
raise FileNotFoundError(state["file_path"])
|
|
|
|
|
|
with CONTROL_LOCK:
|
|
|
if UDP_PROBE_LOCK.locked():
|
|
|
raise RuntimeError("UDP probe is active")
|
|
|
stop_control_process()
|
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
INPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
FRAME_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
GUIDANCE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
for path in (FRAME_PATH, GUIDANCE_PATH, ACTIVE_VIDEO_PATH):
|
|
|
try:
|
|
|
path.unlink(missing_ok=True)
|
|
|
except OSError:
|
|
|
pass
|
|
|
|
|
|
width, height = parse_quality(state["quality"])
|
|
|
env = os.environ.copy()
|
|
|
process_source_mode = state["source_mode"]
|
|
|
if state["source_mode"] == "camera":
|
|
|
bridge_url = os.environ.get("FPV_CAMERA_BRIDGE_URL", "").strip()
|
|
|
if bridge_url:
|
|
|
source = camera_bridge_source(
|
|
|
bridge_url,
|
|
|
state["camera_index"],
|
|
|
width,
|
|
|
height,
|
|
|
state["fps"],
|
|
|
)
|
|
|
process_source_mode = "camera_bridge"
|
|
|
else:
|
|
|
source = str(state["camera_index"])
|
|
|
elif state["source_mode"] in LIVE_SOURCE_MODES:
|
|
|
source = f"udp://{state['input_host']}:{state['input_port']}"
|
|
|
else:
|
|
|
source = str(state["file_path"])
|
|
|
|
|
|
env.update({
|
|
|
"PYTHONUNBUFFERED": "1",
|
|
|
"FPV_MODEL_PATH": state["model_path"],
|
|
|
"FPV_DEVICE": str(state["device"]),
|
|
|
"FPV_USE_HALF": "1" if state["use_half"] else "0",
|
|
|
"FPV_CONF": str(state["conf"]),
|
|
|
"FPV_IMG_SIZE_ROI": str(state["img_size_roi"]),
|
|
|
"FPV_IMG_SIZE_FULL": str(state["img_size_full"]),
|
|
|
"FPV_MAX_DET": str(state["max_det"]),
|
|
|
"FPV_SOURCE": source,
|
|
|
"FPV_SOURCE_MODE": process_source_mode,
|
|
|
"FPV_UDP_INPUT_HOST": state["input_host"],
|
|
|
"FPV_UDP_INPUT_PORT": str(state["input_port"]),
|
|
|
"FPV_FRAME_SEPARATOR_BYTE": str(state["separator_byte"]),
|
|
|
"FPV_FRAME_ENCODING": state["frame_encoding"],
|
|
|
"FPV_UDP_PACKET_SCHEMA": json.dumps(state["packet_schema"], separators=(",", ":")),
|
|
|
"FPV_CAP_BACKEND": os.environ.get("FPV_CAP_BACKEND", "dshow" if os.name == "nt" else "v4l2"),
|
|
|
"FPV_CAMERA_WIDTH": str(width),
|
|
|
"FPV_CAMERA_HEIGHT": str(height),
|
|
|
"FPV_CAMERA_FPS": str(state["fps"]),
|
|
|
"FPV_CAMERA_FOURCC": "MJPG",
|
|
|
"FPV_TARGET_OUT_FPS": str(state["fps"]) if state["source_mode"] == "camera" else "0",
|
|
|
"FPV_VIDEO_REALTIME": "0" if state["run_mode"] == "fast" else "1",
|
|
|
"FPV_SHOW_OUTPUT": "0",
|
|
|
"FPV_SAVE_INFER_VIDEO": "1" if state["save"] else "0",
|
|
|
"FPV_OUT_VIDEO_PATH": str(OUT_DIR / "out_infer.mp4"),
|
|
|
"FPV_ARCHIVE_RECORD_MODE": state["archive_mode"],
|
|
|
"FPV_DETECTION_CLIP_MAX_GAP_SEC": str(state["fragment_gap_sec"]),
|
|
|
"FPV_FORCE_EFFECTIVE_PAL": "1" if state["frame_mode"] == "pal" else "0",
|
|
|
"FPV_EFFECTIVE_W": str(width),
|
|
|
"FPV_EFFECTIVE_H": str(height),
|
|
|
"FPV_UI_FRAME_EXPORT_ENABLE": "1",
|
|
|
"FPV_UI_FRAME_EXPORT_PATH": str(FRAME_PATH),
|
|
|
"FPV_UI_FRAME_EXPORT_EVERY": "1",
|
|
|
"FPV_UI_FRAME_EXPORT_JPEG_QUALITY": os.environ.get("FPV_UI_FRAME_EXPORT_JPEG_QUALITY", "82"),
|
|
|
"FPV_UI_FRAME_EXPORT_MAX_FPS": os.environ.get("FPV_UI_FRAME_EXPORT_MAX_FPS", "50"),
|
|
|
"FPV_REALTIME_SKIP_STALE_FRAMES": os.environ.get("FPV_REALTIME_SKIP_STALE_FRAMES", "1"),
|
|
|
"FPV_REALTIME_MAX_SKIP_FRAMES": os.environ.get("FPV_REALTIME_MAX_SKIP_FRAMES", "8"),
|
|
|
"FPV_REALTIME_PREVIEW_SKIPPED_FRAMES": os.environ.get("FPV_REALTIME_PREVIEW_SKIPPED_FRAMES", "1"),
|
|
|
"FPV_REALTIME_ANALYSIS_EVERY": os.environ.get("FPV_REALTIME_ANALYSIS_EVERY", "4"),
|
|
|
"FPV_GUIDANCE_EXPORT_ENABLE": "1",
|
|
|
"FPV_GUIDANCE_EXPORT_PATH": str(GUIDANCE_PATH),
|
|
|
"FPV_ERROR_OUTPUT_ENABLE": "1" if state["error_output"] else "0",
|
|
|
"FPV_ERROR_OUTPUT_PROTOCOL": state["error_protocol"],
|
|
|
"FPV_ERROR_OUTPUT_HOST": state["error_host"],
|
|
|
"FPV_ERROR_OUTPUT_PORT": str(state["error_port"]),
|
|
|
"FPV_ERROR_OUTPUT_OBJECT_ID": str(state["error_object_id"]),
|
|
|
"FPV_ERROR_OUTPUT_UNITS": state["error_units"],
|
|
|
"FPV_ERROR_OUTPUT_HFOV_DEG": str(state["error_hfov"]),
|
|
|
"FPV_ERROR_OUTPUT_VFOV_DEG": str(state["error_vfov"]),
|
|
|
"FPV_ERROR_OUTPUT_RANGE_M": str(state["error_range_m"]),
|
|
|
"FPV_AUTOPILOT_BACKEND": "json",
|
|
|
"FPV_AUTOPILOT_JSON_PATH": str(DATA_DIR / "autopilot" / "autopilot_cmd.json"),
|
|
|
})
|
|
|
stdout = LOG_PATH.open("w", encoding="utf-8", buffering=1)
|
|
|
stderr = (LOG_PATH.parent / "main.err.log").open("w", encoding="utf-8", buffering=1)
|
|
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
|
try:
|
|
|
CONTROL_PROCESS = subprocess.Popen(
|
|
|
[PYTHON_EXE, "-u", str(MAIN_SCRIPT)],
|
|
|
cwd=str(APP_DIR),
|
|
|
env=env,
|
|
|
stdout=stdout,
|
|
|
stderr=stderr,
|
|
|
creationflags=creationflags,
|
|
|
)
|
|
|
if process_source_mode == "camera_bridge" or state["source_mode"] in LIVE_SOURCE_MODES:
|
|
|
source_kind = "stream"
|
|
|
else:
|
|
|
source_kind = "camera" if state["source_mode"] == "camera" else "file"
|
|
|
CONTROL_SOURCE = f"{source} ({source_kind})"
|
|
|
finally:
|
|
|
stdout.close()
|
|
|
stderr.close()
|
|
|
write_json_file(CONTROL_PATH, state)
|
|
|
return control_payload()
|
|
|
|
|
|
|
|
|
def parse_range_header(range_header, size):
|
|
|
match = re.fullmatch(r"bytes=(\d*)-(\d*)", (range_header or "").strip())
|
|
|
if not match or size <= 0:
|
|
|
return None
|
|
|
start_text, end_text = match.groups()
|
|
|
if not start_text and not end_text:
|
|
|
return None
|
|
|
if not start_text:
|
|
|
length = int(end_text)
|
|
|
if length <= 0:
|
|
|
return None
|
|
|
return max(0, size - length), size - 1
|
|
|
start = int(start_text)
|
|
|
end = int(end_text) if end_text else size - 1
|
|
|
if start >= size or start > end:
|
|
|
return None
|
|
|
return start, min(end, size - 1)
|
|
|
|
|
|
|
|
|
def parse_perf(log_text):
|
|
|
match = None
|
|
|
for line in log_text.splitlines():
|
|
|
if "[perf]" in line:
|
|
|
match = line
|
|
|
if not match:
|
|
|
return {}
|
|
|
perf = re.search(
|
|
|
r"fps~([0-9.]+).*?iter p50=([0-9.]+) p95=([0-9.]+).*?"
|
|
|
r"yolo p50=([0-9.]+) p95=([0-9.]+)(?: skip=([0-9]+))?"
|
|
|
r"(?: pass=([0-9]+))?(?: analysisEvery=([0-9]+))?",
|
|
|
match,
|
|
|
)
|
|
|
if not perf:
|
|
|
return {}
|
|
|
return {
|
|
|
"fps": perf.group(1),
|
|
|
"p50": perf.group(2),
|
|
|
"p95": perf.group(3),
|
|
|
"yolo_p50": perf.group(4),
|
|
|
"yolo_p95": perf.group(5),
|
|
|
"skip": perf.group(6) or "0",
|
|
|
"pass": perf.group(7) or "0",
|
|
|
"analysis_every": perf.group(8) or "1",
|
|
|
}
|
|
|
|
|
|
|
|
|
def parse_source(log_text):
|
|
|
for line in reversed(log_text.splitlines()):
|
|
|
match = re.search(r"Opened source: (.+?) \(([^)]+)\)", line)
|
|
|
if match:
|
|
|
return f"{match.group(1)} ({match.group(2)})"
|
|
|
for line in reversed(log_text.splitlines()):
|
|
|
match = re.search(r"\[entrypoint\] source=([^ ]+)", line)
|
|
|
if match:
|
|
|
return match.group(1)
|
|
|
return os.environ.get("FPV_SOURCE", "")
|
|
|
|
|
|
|
|
|
def status_payload():
|
|
|
logs = tail_text(LOG_PATH)
|
|
|
parse_logs = tail_text(LOG_PATH, lines=500)
|
|
|
video = latest_file(OUT_DIR, "*.mp4")
|
|
|
control = control_payload()
|
|
|
active_name = control["active_video"] or None
|
|
|
return {
|
|
|
"logs": logs,
|
|
|
"guidance": read_json_file(GUIDANCE_PATH),
|
|
|
"perf": parse_perf(parse_logs),
|
|
|
"source": CONTROL_SOURCE if CONTROL_SOURCE and control["running"] else parse_source(parse_logs),
|
|
|
"frame_exists": FRAME_PATH.exists(),
|
|
|
"video": active_name or (video.name if video else ""),
|
|
|
"control": control,
|
|
|
}
|
|
|
|
|
|
|
|
|
def udp_probe_payload(data):
|
|
|
if not isinstance(data, dict):
|
|
|
raise ValueError("JSON object expected")
|
|
|
state = normalize_control(data)
|
|
|
width, height = parse_quality(state["quality"])
|
|
|
duration = max(0.2, min(15.0, float(data.get("duration", 3.0))))
|
|
|
result = run_udp_probe(
|
|
|
state["input_host"],
|
|
|
state["input_port"],
|
|
|
UDP_PROBE_DIR,
|
|
|
width=width,
|
|
|
height=height,
|
|
|
separator=state["separator_byte"],
|
|
|
frame_encoding=state["frame_encoding"],
|
|
|
duration=duration,
|
|
|
)
|
|
|
capture = result.get("exact_capture")
|
|
|
if capture:
|
|
|
capture["dump_url"] = f"/udp-probe/{quote(capture['dump_name'])}"
|
|
|
capture["report_url"] = f"/udp-probe/{quote(capture['report_name'])}"
|
|
|
result["requested_host"] = state["input_host"]
|
|
|
result["requested_port"] = state["input_port"]
|
|
|
return result
|
|
|
|
|
|
|
|
|
def copy_bytes(src, dst, remaining):
|
|
|
while remaining > 0:
|
|
|
try:
|
|
|
chunk = src.read(min(1024 * 1024, remaining))
|
|
|
except OSError:
|
|
|
return
|
|
|
if not chunk:
|
|
|
break
|
|
|
try:
|
|
|
dst.write(chunk)
|
|
|
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
|
|
|
return
|
|
|
remaining -= len(chunk)
|
|
|
|
|
|
|
|
|
def copy_stream(src, dst):
|
|
|
while True:
|
|
|
try:
|
|
|
chunk = src.read(1024 * 1024)
|
|
|
except OSError:
|
|
|
return
|
|
|
if not chunk:
|
|
|
return
|
|
|
try:
|
|
|
dst.write(chunk)
|
|
|
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
|
|
|
return
|
|
|
|
|
|
|
|
|
def copy_exact(src, dst, size, chunk_size=4 * 1024 * 1024):
|
|
|
remaining = int(size)
|
|
|
while remaining > 0:
|
|
|
chunk = src.read(min(int(chunk_size), remaining))
|
|
|
if not chunk:
|
|
|
raise EOFError("upload interrupted")
|
|
|
dst.write(chunk)
|
|
|
remaining -= len(chunk)
|
|
|
return int(size)
|
|
|
|
|
|
|
|
|
def input_upload_path(filename):
|
|
|
name = str(filename or "").strip()
|
|
|
if (
|
|
|
not name
|
|
|
or name in {".", ".."}
|
|
|
or "/" in name
|
|
|
or "\\" in name
|
|
|
or "\x00" in name
|
|
|
or len(name) > 240
|
|
|
):
|
|
|
return None
|
|
|
return INPUT_DIR / name
|
|
|
|
|
|
|
|
|
def _path_under(path, roots):
|
|
|
try:
|
|
|
resolved = Path(path).resolve(strict=False)
|
|
|
for root in roots:
|
|
|
resolved.relative_to(Path(root).resolve())
|
|
|
return True
|
|
|
except (OSError, ValueError):
|
|
|
return False
|
|
|
return False
|
|
|
|
|
|
|
|
|
def resolve_model_path(value=None):
|
|
|
raw = str(value or MODEL_PATH).strip().strip('"')
|
|
|
path = Path(raw)
|
|
|
if not path.is_absolute():
|
|
|
path = APP_DIR / path
|
|
|
if path.suffix.lower() not in MODEL_EXTENSIONS:
|
|
|
return None
|
|
|
roots = (APP_DIR, MODEL_DIR)
|
|
|
return path if _path_under(path, roots) else None
|
|
|
|
|
|
|
|
|
def model_upload_path(filename):
|
|
|
name = str(filename or "").strip()
|
|
|
if (
|
|
|
not name
|
|
|
or name in {".", ".."}
|
|
|
or "/" in name
|
|
|
or "\\" in name
|
|
|
or "\x00" in name
|
|
|
or len(name) > 240
|
|
|
or Path(name).suffix.lower() not in MODEL_EXTENSIONS
|
|
|
):
|
|
|
return None
|
|
|
path = MODEL_DIR / name
|
|
|
return path if _path_under(path, (MODEL_DIR,)) else None
|
|
|
|
|
|
|
|
|
def model_catalog():
|
|
|
rows = {}
|
|
|
for root in (APP_DIR, MODEL_DIR):
|
|
|
if not root.exists():
|
|
|
continue
|
|
|
for path in root.glob("*.pt"):
|
|
|
if not path.is_file() or not _path_under(path, (root,)):
|
|
|
continue
|
|
|
try:
|
|
|
stat = path.stat()
|
|
|
except OSError:
|
|
|
continue
|
|
|
rows[str(path.resolve())] = {
|
|
|
"name": path.name,
|
|
|
"path": str(path),
|
|
|
"size": stat.st_size,
|
|
|
"mtime": int(stat.st_mtime),
|
|
|
}
|
|
|
return sorted(rows.values(), key=lambda item: item["name"].lower())
|
|
|
|
|
|
|
|
|
def inspect_model(path):
|
|
|
path = resolve_model_path(path)
|
|
|
if path is None or not path.is_file():
|
|
|
raise FileNotFoundError(str(path or "model"))
|
|
|
stat = path.stat()
|
|
|
key = (str(path.resolve()), stat.st_mtime_ns, stat.st_size)
|
|
|
with MODEL_INFO_LOCK:
|
|
|
cached = MODEL_INFO_CACHE.get(key)
|
|
|
if cached is not None:
|
|
|
return cached
|
|
|
import __main__
|
|
|
import cbam_register
|
|
|
for name in ("ChannelAttentionDyn", "SpatialAttention", "CBAM"):
|
|
|
setattr(__main__, name, getattr(cbam_register, name))
|
|
|
from ultralytics import YOLO
|
|
|
|
|
|
loaded = YOLO(str(path))
|
|
|
network = getattr(loaded, "model", loaded)
|
|
|
parameters = sum(parameter.numel() for parameter in network.parameters())
|
|
|
trainable = sum(parameter.numel() for parameter in network.parameters() if parameter.requires_grad)
|
|
|
layers = []
|
|
|
for index, (name, module) in enumerate(network.named_modules()):
|
|
|
if not name:
|
|
|
continue
|
|
|
layers.append({
|
|
|
"index": index,
|
|
|
"name": name,
|
|
|
"type": type(module).__name__,
|
|
|
"params": sum(parameter.numel() for parameter in module.parameters(recurse=False)),
|
|
|
})
|
|
|
if len(layers) >= 512:
|
|
|
break
|
|
|
names = getattr(loaded, "names", {})
|
|
|
if isinstance(names, dict):
|
|
|
names = [names[key] for key in sorted(names)]
|
|
|
else:
|
|
|
names = list(names or [])
|
|
|
info = {
|
|
|
"name": path.name,
|
|
|
"path": str(path),
|
|
|
"size": stat.st_size,
|
|
|
"task": str(getattr(loaded, "task", "detect")),
|
|
|
"classes": names,
|
|
|
"parameters": parameters,
|
|
|
"trainable": trainable,
|
|
|
"layers": layers,
|
|
|
}
|
|
|
MODEL_INFO_CACHE.clear()
|
|
|
MODEL_INFO_CACHE[key] = info
|
|
|
return info
|
|
|
|
|
|
|
|
|
def model_payload():
|
|
|
state = read_control_state()
|
|
|
selected = resolve_model_path(state.get("model_path")) or resolve_model_path(MODEL_PATH)
|
|
|
return {
|
|
|
"models": model_catalog(),
|
|
|
"selected": str(selected) if selected else "",
|
|
|
"default": str(resolve_model_path(MODEL_PATH) or MODEL_PATH),
|
|
|
}
|
|
|
|
|
|
|
|
|
def ensure_netron(path=None):
|
|
|
model = resolve_model_path(path or model_payload()["selected"])
|
|
|
if model is None or not model.is_file():
|
|
|
raise FileNotFoundError(str(model or "model"))
|
|
|
try:
|
|
|
import netron
|
|
|
except ImportError as exc:
|
|
|
raise RuntimeError("Netron не установлен в Docker-образе") from exc
|
|
|
|
|
|
model_key = str(model.resolve())
|
|
|
address = (NETRON_HOST, NETRON_PORT)
|
|
|
with NETRON_LOCK:
|
|
|
try:
|
|
|
running = netron.status(address)
|
|
|
except Exception:
|
|
|
running = False
|
|
|
if NETRON_STATE.get("path") != model_key or not running:
|
|
|
netron.stop(address)
|
|
|
netron.start(str(model), address=address, browse=False)
|
|
|
NETRON_STATE.update({"path": model_key})
|
|
|
return {"url": "/netron/", "path": str(model), "port": NETRON_PORT}
|
|
|
|
|
|
|
|
|
def select_model(data):
|
|
|
if process_running():
|
|
|
raise RuntimeError("остановите инференс перед сменой модели")
|
|
|
if not isinstance(data, dict):
|
|
|
raise ValueError("JSON object expected")
|
|
|
path = resolve_model_path(data.get("path"))
|
|
|
if path is None or not path.is_file():
|
|
|
raise FileNotFoundError(str(data.get("path") or "model"))
|
|
|
state = read_control_state()
|
|
|
state["model_path"] = str(path)
|
|
|
for key in ("device", "use_half", "conf", "img_size_roi", "img_size_full", "max_det"):
|
|
|
if key in data:
|
|
|
state[key] = data[key]
|
|
|
state = normalize_control(state)
|
|
|
write_json_file(CONTROL_PATH, state)
|
|
|
return control_payload()
|
|
|
|
|
|
|
|
|
def ffmpeg_executable():
|
|
|
exe = shutil.which("ffmpeg")
|
|
|
if exe:
|
|
|
return exe
|
|
|
try:
|
|
|
from imageio_ffmpeg import get_ffmpeg_exe
|
|
|
except ImportError:
|
|
|
return None
|
|
|
return get_ffmpeg_exe()
|
|
|
|
|
|
|
|
|
HTML = """<!doctype html>
|
|
|
<html lang="ru">
|
|
|
<head>
|
|
|
<meta charset="utf-8">
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
<title>FPV Панель</title>
|
|
|
<style>
|
|
|
* { box-sizing: border-box; }
|
|
|
body { margin: 0; background: #111315; color: #edf1f5; font: 14px/1.45 Segoe UI, Arial, sans-serif; }
|
|
|
header { height: 44px; display: flex; align-items: center; gap: 10px; padding: 0 10px; border-bottom: 1px solid #2b3037; background: #171a1e; color: #aeb7c2; }
|
|
|
h1 { font-size: 15px; margin: 0; color: #edf1f5; }
|
|
|
.tabs { display: flex; gap: 2px; margin-left: auto; }
|
|
|
.tab { height: 28px; padding: 0 10px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: #aeb7c2; font: inherit; cursor: pointer; }
|
|
|
.tab.active { color: #edf1f5; border-color: #8fb8ff; }
|
|
|
.header-command { width: 30px; height: 28px; border: 0; background: transparent; color: #aeb7c2; font: 16px Segoe UI Symbol, sans-serif; cursor: pointer; }
|
|
|
.header-command:hover, .header-command.active { color: #edf1f5; background: #22272e; }
|
|
|
.status { min-width: 74px; color: #dce3ea; }
|
|
|
.source { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8e99a6; }
|
|
|
main { height: calc(100vh - 44px); min-height: 0; }
|
|
|
.tab-page[hidden] { display: none; }
|
|
|
.stream-tab { height: 100%; min-height: 0; padding: 6px; }
|
|
|
.archive-tab { height: 100%; min-height: 0; display: grid; place-items: start center; padding: 6px; overflow: auto; }
|
|
|
.settings-tab { height: 100%; min-height: 0; overflow: auto; padding: 6px; }
|
|
|
.model-tab { height: 100%; min-height: 0; overflow: auto; padding: 6px; }
|
|
|
.video { width: min(1440px, 100%); height: 100%; min-width: 0; margin: 0 auto; display: grid; grid-template-rows: 36px minmax(320px, 1fr) auto; gap: 6px; }
|
|
|
.quickbar { min-width: 0; display: grid; grid-template-columns: minmax(220px, 360px) auto auto minmax(0, 1fr); gap: 6px; align-items: center; }
|
|
|
.quickbar select { min-width: 0; height: 32px; border: 1px solid #2b3037; background: #101317; color: #edf1f5; padding: 0 8px; font: inherit; }
|
|
|
.command { height: 32px; border: 1px solid #2b3037; background: #1a1e24; color: #dce3ea; padding: 0 12px; font: inherit; cursor: pointer; }
|
|
|
.command.primary { background: #263247; border-color: #3d5f99; color: #edf1f5; }
|
|
|
.command.danger { color: #ff9d9d; border-color: #533238; }
|
|
|
.command:disabled { opacity: .45; cursor: default; }
|
|
|
.quick-summary { min-width: 0; color: #8e99a6; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
|
|
|
.stage { height: 100%; min-height: 320px; position: relative; display: grid; place-items: center; overflow: hidden; border: 1px solid #2b3037; background: #050607; }
|
|
|
.stage #frame, .stage #frameFallback, .empty { grid-area: 1 / 1; }
|
|
|
.stage #frame, .stage #frameFallback { max-width: 100%; max-height: 100%; object-fit: contain; visibility: hidden; }
|
|
|
.stage #frameFallback { z-index: 0; }
|
|
|
.stage #frame { z-index: 1; }
|
|
|
.empty { z-index: 2; color: #8d98a5; }
|
|
|
.metricgrid { display: grid; grid-template-columns: repeat(6, minmax(92px, 1fr)); gap: 1px; border: 1px solid #2b3037; background: #2b3037; }
|
|
|
.metric { min-width: 0; background: #171a1f; padding: 6px 8px; }
|
|
|
.metric label { display: block; color: #8e99a6; font-size: 11px; line-height: 1.15; margin-bottom: 3px; }
|
|
|
.metric b { display: block; color: #edf1f5; font-size: 15px; line-height: 1.15; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.metric.ok b { color: #5fd38d; }
|
|
|
.metric.warn b { color: #ffc85a; }
|
|
|
.settings-panel { width: min(1040px, 100%); min-height: 100%; margin: 0 auto; border: 1px solid #2b3037; background: #14171b; }
|
|
|
.model-panel { width: min(1120px, 100%); min-height: 100%; margin: 0 auto; border: 1px solid #2b3037; background: #14171b; }
|
|
|
.model-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
|
|
|
.model-group { min-width: 0; margin: 0; padding: 10px; border: 0; border-bottom: 1px solid #2b3037; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
|
.model-group legend { padding: 0 0 7px; color: #cbd3dc; font-size: 13px; font-weight: 600; }
|
|
|
.model-group label { min-width: 0; display: grid; gap: 3px; color: #8e99a6; font-size: 11px; }
|
|
|
.model-group select, .model-group input { min-width: 0; height: 28px; border: 1px solid #2b3037; background: #101317; color: #edf1f5; padding: 0 7px; font: 12px Segoe UI, Arial, sans-serif; }
|
|
|
.model-group input[type="checkbox"] { width: 16px; height: 16px; padding: 0; accent-color: #8fb8ff; }
|
|
|
.model-group button { height: 28px; border: 1px solid #2b3037; background: #1a1e24; color: #dce3ea; padding: 0 10px; font: inherit; cursor: pointer; }
|
|
|
.model-group button:disabled { opacity: .45; cursor: default; }
|
|
|
.model-wide { grid-column: 1 / -1; }
|
|
|
.model-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; grid-column: 1 / -1; }
|
|
|
.model-state { min-width: 0; color: #8e99a6; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.netron-section { padding: 10px; border-top: 1px solid #2b3037; }
|
|
|
.netron-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; }
|
|
|
.netron-state { min-width: 0; color: #8e99a6; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.netron-panel { min-height: 420px; margin-top: 8px; overflow: hidden; border: 1px solid #2b3037; background: #fff; }
|
|
|
.netron-frame { display: block; width: 100%; height: min(68vh, 720px); border: 0; background: #fff; }
|
|
|
.network-model-netron { grid-column: 1 / -1; padding-top: 10px; border-top: 1px solid var(--ui-border); }
|
|
|
.theme-select { width: 112px; height: 28px; border: 1px solid #2b3037; background: #101317; color: #dce3ea; padding: 0 5px; font: 11px Segoe UI, Arial, sans-serif; }
|
|
|
.control-form { display: grid; grid-template-columns: 1fr 1fr; align-items: start; }
|
|
|
.settings-group { min-width: 0; margin: 0; padding: 10px; border: 0; border-bottom: 1px solid #2b3037; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
|
.settings-group.source-group, .settings-advanced { grid-column: 1 / -1; }
|
|
|
.settings-group legend { padding: 0 0 7px; color: #cbd3dc; font-size: 13px; font-weight: 600; }
|
|
|
.control-form label { min-width: 0; display: grid; gap: 3px; color: #8e99a6; font-size: 11px; }
|
|
|
.control-form select, .control-form input { min-width: 0; height: 28px; border: 1px solid #2b3037; background: #101317; color: #edf1f5; padding: 0 7px; font: 12px Segoe UI, Arial, sans-serif; }
|
|
|
.control-form input[type="checkbox"] { width: 16px; height: 16px; padding: 0; accent-color: #8fb8ff; }
|
|
|
.control-form button { height: 28px; border: 1px solid #2b3037; background: #1a1e24; color: #dce3ea; padding: 0 10px; font: inherit; cursor: pointer; }
|
|
|
.control-form button:disabled { opacity: .45; cursor: default; }
|
|
|
.control-form [hidden] { display: none !important; }
|
|
|
.wide { grid-column: 1 / -1; }
|
|
|
.compact { align-self: end; }
|
|
|
.control-fields { grid-column: 1 / -1; display: contents; }
|
|
|
.control-fields[hidden] { display: none; }
|
|
|
.settings-advanced { border-bottom: 1px solid #2b3037; }
|
|
|
.settings-advanced summary { padding: 9px 10px; color: #cbd3dc; font-size: 13px; cursor: pointer; user-select: none; }
|
|
|
.advanced-grid { padding: 0 10px 10px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
|
|
.drop-zone { grid-column: 1 / -1; min-height: 34px; display: grid; place-items: center; border: 1px dashed #39414c; color: #8e99a6; background: #101317; font-size: 12px; }
|
|
|
.drop-zone.drag { color: #edf1f5; border-color: #8fb8ff; background: #151b25; }
|
|
|
.file-picked { color: #8e99a6; font-size: 12px; align-self: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.udp-probe-result { grid-column: 1 / -1; min-width: 0; display: grid; gap: 5px; padding-top: 6px; border-top: 1px solid #2b3037; }
|
|
|
.udp-probe-result strong { font-size: 12px; color: #dce3ea; }
|
|
|
.udp-probe-result pre { max-height: 190px; padding: 6px; border: 1px solid #2b3037; background: #101317; font-size: 11px; }
|
|
|
.udp-probe-result a { width: fit-content; color: #8fb8ff; font-size: 12px; text-decoration: none; }
|
|
|
.packet-builder { grid-column: 1 / -1; border-top: 1px solid #2b3037; }
|
|
|
.packet-builder summary { padding: 8px 0 5px; color: #cbd3dc; font-size: 12px; cursor: pointer; }
|
|
|
.packet-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
|
|
.packet-builder-toolbar { min-width: 0; display: flex; align-items: center; gap: 6px; padding: 3px 0 7px; }
|
|
|
.packet-builder-toolbar strong { color: #dce3ea; font-size: 12px; white-space: nowrap; }
|
|
|
.packet-builder-toolbar .packet-layout-status { min-width: 0; flex: 1; color: #8e99a6; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.packet-builder-toolbar .packet-layout-status.warn { color: #ffc85a; }
|
|
|
.packet-byte-map { min-height: 48px; display: grid; grid-template-columns: repeat(auto-fill, 48px); gap: 3px; align-content: start; padding: 6px; border: 1px solid #2b3037; background: #101317; }
|
|
|
.packet-byte { width: 48px; height: 42px; box-sizing: border-box; display: grid; grid-template-rows: 14px 1fr; place-items: center; border: 1px solid #353b44; border-top-width: 3px; background: #181c21; overflow: hidden; }
|
|
|
.packet-byte small { color: #7f8995; font: 9px Consolas, monospace; }
|
|
|
.packet-byte b { width: 100%; padding: 0 2px; color: #dce3ea; font-size: 9px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.packet-byte-skip { border-top-color: #68717d; }
|
|
|
.packet-byte-flags { border-top-color: #e0a84f; }
|
|
|
.packet-byte-sequence { border-top-color: #64a5eb; }
|
|
|
.packet-byte-packet_number { border-top-color: #45b8a1; }
|
|
|
.packet-byte-value { border-top-color: #df7474; }
|
|
|
.packet-byte-payload { width: 72px; border-top-color: #69c983; }
|
|
|
.packet-field-list { display: grid; gap: 3px; padding: 6px 0 8px; }
|
|
|
.packet-field-row { min-width: 0; display: grid; grid-template-columns: 56px minmax(110px, 1fr) minmax(160px, 1.2fr) 72px 74px 28px; gap: 6px; align-items: end; padding-top: 5px; border-top: 1px solid #242930; }
|
|
|
.packet-field-order { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; }
|
|
|
.packet-field-row .packet-icon { width: 27px; padding: 0; font-size: 14px; }
|
|
|
.packet-field-row .packet-remove { color: #ff9d9d; }
|
|
|
.packet-field-range { height: 28px; display: grid; place-items: center; color: #8e99a6; font: 10px Consolas, monospace; border: 1px solid #2b3037; background: #101317; }
|
|
|
.upload-progress { grid-column: 1 / -1; width: 100%; height: 5px; border: 0; accent-color: #8fb8ff; }
|
|
|
.archive-panel { width: min(1040px, 100%); min-width: 0; height: 100%; min-height: 0; display: grid; grid-template-rows: 32px auto 1fr; border: 1px solid #2b3037; background: #14171b; }
|
|
|
h2 { margin: 0; padding: 6px 8px; font-size: 13px; font-weight: 600; color: #cbd3dc; border-bottom: 1px solid #2b3037; }
|
|
|
pre { margin: 0; padding: 8px; overflow: auto; scrollbar-gutter: stable; white-space: pre-wrap; font: 12px/1.4 Consolas, monospace; color: #dbe2ea; }
|
|
|
.archive-tools { display: grid; grid-template-columns: minmax(160px, 1fr) 140px 140px auto auto auto auto; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid #2b3037; }
|
|
|
.archive-tools > * { min-width: 0; max-width: 100%; box-sizing: border-box; }
|
|
|
.archive-tools input { min-width: 0; height: 28px; border: 1px solid #2b3037; background: #101317; color: #edf1f5; padding: 0 8px; font: inherit; }
|
|
|
.archive-tools button { height: 28px; border: 1px solid #2b3037; background: #171a1f; color: #dce3ea; padding: 0 8px; font: inherit; cursor: pointer; white-space: nowrap; }
|
|
|
.archive-tools button.danger { color: #ff9d9d; border-color: #533238; }
|
|
|
.archive-tools button:disabled { opacity: .45; cursor: default; }
|
|
|
.archive-count { color: #8e99a6; font-size: 12px; white-space: nowrap; }
|
|
|
.archive { min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 4px 6px; }
|
|
|
.rec { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 2px 8px; padding: 5px 2px; border-bottom: 1px solid #242930; }
|
|
|
.rec-check { width: 14px; height: 14px; accent-color: #8fb8ff; }
|
|
|
.rec-info { min-width: 0; }
|
|
|
.rec-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #edf1f5; font-size: 12px; }
|
|
|
.rec-meta { color: #8e99a6; font-size: 11px; }
|
|
|
.rec-actions { display: flex; gap: 6px; align-items: center; }
|
|
|
.rec-actions a, .rec-actions button { color: #8fb8ff; background: transparent; border: 0; padding: 0; font: inherit; font-size: 12px; cursor: pointer; text-decoration: none; }
|
|
|
.rec-actions button.delete { color: #ff8c8c; }
|
|
|
.rec-actions .active-note { color: #8e99a6; font-size: 12px; white-space: nowrap; }
|
|
|
.player-overlay[hidden] { display: none; }
|
|
|
.player-overlay { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 10px; background: rgba(0, 0, 0, .72); }
|
|
|
.player-box { position: relative; width: min(1120px, 100%); height: min(760px, 90vh); border: 1px solid #3a414b; background: #101317; box-shadow: 0 18px 60px rgba(0, 0, 0, .55); }
|
|
|
.player-head { height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 6px 0 10px; border-bottom: 1px solid #2b3037; }
|
|
|
.player-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #dce3ea; font-size: 13px; }
|
|
|
.player-close { width: 28px; height: 28px; border: 0; background: transparent; color: #edf1f5; font-size: 22px; line-height: 1; cursor: pointer; }
|
|
|
.player-box video { width: 100%; height: calc(100% - 36px); display: block; background: #050607; object-fit: contain; }
|
|
|
.player-message { position: absolute; inset: 36px 0 0; display: grid; place-items: center; color: #8d98a5; background: #050607; }
|
|
|
.player-message[hidden] { display: none; }
|
|
|
.player-box video[hidden] { display: block; visibility: hidden; }
|
|
|
.log-drawer[hidden] { display: none; }
|
|
|
.log-drawer { position: fixed; z-index: 900; left: 0; right: 0; bottom: 0; height: min(38vh, 360px); display: grid; grid-template-rows: 36px minmax(0, 1fr); border-top: 1px solid #3a414b; background: #101317; box-shadow: 0 -12px 36px rgba(0, 0, 0, .38); }
|
|
|
.log-head { display: flex; align-items: center; gap: 8px; padding: 0 6px 0 10px; border-bottom: 1px solid #2b3037; color: #cbd3dc; }
|
|
|
.log-head span { flex: 1; }
|
|
|
.log-close { width: 28px; height: 28px; border: 0; background: transparent; color: #edf1f5; font-size: 22px; line-height: 1; cursor: pointer; }
|
|
|
@media (max-width: 1180px) { .metricgrid { grid-template-columns: repeat(4, minmax(92px, 1fr)); } }
|
|
|
@media (max-width: 760px) {
|
|
|
header { gap: 6px; }
|
|
|
h1, .source, .status { display: none; }
|
|
|
.tabs { min-width: 0; flex: 0 1 auto; margin-left: 0; justify-content: flex-start; }
|
|
|
.tab { min-width: 0; padding: 0 6px; font-size: 12px; }
|
|
|
main { height: auto; min-height: calc(100vh - 44px); }
|
|
|
.stream-tab { height: auto; }
|
|
|
.video { min-height: 0; grid-template-rows: auto auto auto; }
|
|
|
.stage { height: auto; min-height: 0; aspect-ratio: 16 / 9; }
|
|
|
.quickbar { grid-template-columns: minmax(0, 1fr) auto; }
|
|
|
.quickbar select, .quick-summary { grid-column: 1 / -1; }
|
|
|
.quick-summary { text-align: left; }
|
|
|
.metricgrid { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
|
|
.control-form { grid-template-columns: 1fr; }
|
|
|
.settings-group { grid-column: 1 / -1; grid-template-columns: 1fr; }
|
|
|
.model-grid { grid-template-columns: 1fr; }
|
|
|
.model-group { grid-template-columns: 1fr; }
|
|
|
.advanced-grid { grid-template-columns: 1fr; }
|
|
|
.packet-grid { grid-template-columns: 1fr; }
|
|
|
.packet-builder-toolbar { flex-wrap: wrap; }
|
|
|
.packet-builder-toolbar .packet-layout-status { flex-basis: 100%; order: 3; }
|
|
|
.packet-field-row { grid-template-columns: 56px minmax(0, 1fr) 72px 28px; }
|
|
|
.packet-field-row .packet-role { grid-column: 2 / -1; }
|
|
|
.packet-field-range { grid-column: 1 / 2; }
|
|
|
.archive-tools { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
|
|
.archive-tools input, .archive-tools button { width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
|
|
.archive-count { grid-column: 1 / -1; }
|
|
|
.rec { grid-template-columns: 22px minmax(0, 1fr); }
|
|
|
.rec-actions { grid-column: 2; min-width: 0; flex-wrap: wrap; }
|
|
|
}
|
|
|
@media (max-width: 600px) {
|
|
|
.archive-tab { display: block; overflow-x: hidden; }
|
|
|
.archive-tools { grid-template-columns: minmax(0, 1fr); }
|
|
|
}
|
|
|
body[data-theme="light"] { background: #eef2f6; color: #1d2a36; }
|
|
|
body[data-theme="light"] header, body[data-theme="light"] .settings-panel, body[data-theme="light"] .model-panel, body[data-theme="light"] .archive-panel { background: #ffffff; border-color: #c8d1db; color: #1d2a36; }
|
|
|
body[data-theme="light"] h1, body[data-theme="light"] .tab.active, body[data-theme="light"] .metric b { color: #1d2a36; }
|
|
|
body[data-theme="light"] h2, body[data-theme="light"] .settings-group legend, body[data-theme="light"] .model-group legend { color: #1d2a36; }
|
|
|
body[data-theme="light"] .tab, body[data-theme="light"] .header-command, body[data-theme="light"] .source, body[data-theme="light"] .status, body[data-theme="light"] .control-form label, body[data-theme="light"] .model-group label, body[data-theme="light"] .model-state, body[data-theme="light"] .metric label { color: #5d6b79; }
|
|
|
body[data-theme="light"] .control-form select, body[data-theme="light"] .control-form input, body[data-theme="light"] .model-group select, body[data-theme="light"] .model-group input, body[data-theme="light"] .theme-select, body[data-theme="light"] pre, body[data-theme="light"] .packet-byte-map, body[data-theme="light"] .stage { background: #f7f9fb; color: #1d2a36; border-color: #c8d1db; }
|
|
|
body[data-theme="light"] .metric { background: #ffffff; }
|
|
|
body[data-theme="light"] .settings-group, body[data-theme="light"] .model-group, body[data-theme="light"] .netron-section { border-color: #d9e0e7; }
|
|
|
body[data-theme="light"] .command, body[data-theme="light"] .control-form button, body[data-theme="light"] .model-group button { background: #edf2f7; color: #1d2a36; border-color: #c8d1db; }
|
|
|
body[data-theme="light"] .stage { background: #dfe6ed; }
|
|
|
body[data-theme="amber"] { background: #17130d; color: #fff0cf; }
|
|
|
body[data-theme="amber"] header, body[data-theme="amber"] .settings-panel, body[data-theme="amber"] .model-panel, body[data-theme="amber"] .archive-panel { background: #211a10; border-color: #584326; }
|
|
|
body[data-theme="amber"] .stage { background: #090705; border-color: #584326; }
|
|
|
body[data-theme="amber"] .control-form select, body[data-theme="amber"] .control-form input, body[data-theme="amber"] .model-group select, body[data-theme="amber"] .model-group input, body[data-theme="amber"] .theme-select, body[data-theme="amber"] pre { background: #120e08; color: #fff0cf; border-color: #584326; }
|
|
|
body[data-theme="amber"] .command, body[data-theme="amber"] .control-form button, body[data-theme="amber"] .model-group button { background: #2d2110; color: #ffe0a0; border-color: #765725; }
|
|
|
body[data-theme="amber"] .tab.active, body[data-theme="amber"] .header-command.active { color: #ffd166; border-color: #ffd166; }
|
|
|
body[data-theme="amber"] h2, body[data-theme="amber"] .settings-group legend, body[data-theme="amber"] .model-group legend { color: #ffe0a0; }
|
|
|
body[data-theme="amber"] .settings-group, body[data-theme="amber"] .model-group, body[data-theme="amber"] .netron-section { border-color: #3e2d18; }
|
|
|
:root { --ui-bg: #111315; --ui-surface: #171a1e; --ui-panel: #14171b; --ui-input: #101317; --ui-stage: #050607; --ui-text: #edf1f5; --ui-muted: #8e99a6; --ui-border: #2b3037; --ui-accent: #8fb8ff; --ui-accent-border: #3d5f99; --ui-accent-bg: #263247; }
|
|
|
body { background: var(--ui-bg) !important; color: var(--ui-text) !important; }
|
|
|
header, .settings-panel, .model-panel, .archive-panel, .log-drawer, .netron-panel { background: var(--ui-surface) !important; border-color: var(--ui-border) !important; color: var(--ui-text) !important; }
|
|
|
.settings-group, .model-group, .settings-advanced, .netron-section, .archive-tools, .metricgrid { border-color: var(--ui-border) !important; }
|
|
|
.settings-group, .model-group, .settings-advanced, .netron-section { background: var(--ui-panel); }
|
|
|
.stage { background: var(--ui-stage) !important; border-color: var(--ui-border) !important; }
|
|
|
.control-form select, .control-form input, .model-group select, .model-group input, .theme-select, .accent-select, .accent-custom, pre, .packet-byte-map, .archive-tools input { background: var(--ui-input) !important; color: var(--ui-text) !important; border-color: var(--ui-border) !important; }
|
|
|
.metric { background: var(--ui-panel) !important; }
|
|
|
h1, h2, .settings-group legend, .model-group legend, .metric b, .tab.active { color: var(--ui-text); }
|
|
|
.tab, .header-command, .source, .status, .control-form label, .model-group label, .model-state, .metric label { color: var(--ui-muted); }
|
|
|
.tab.active, .header-command:hover, .header-command.active, .rec-actions a, .rec-actions button, .archive-tools button, .tab:hover { color: var(--ui-accent) !important; }
|
|
|
.tab.active { border-color: var(--ui-accent) !important; }
|
|
|
.command.primary { background: var(--ui-accent-bg) !important; border-color: var(--ui-accent-border) !important; }
|
|
|
.control-form input[type="checkbox"], .model-group input[type="checkbox"], .upload-progress { accent-color: var(--ui-accent); }
|
|
|
.control-form button, .model-group button, .command { border-color: var(--ui-border); }
|
|
|
.theme-select, .accent-select { width: 112px; height: 28px; }
|
|
|
.accent-select { width: 108px; }
|
|
|
.accent-custom { width: 30px; height: 28px; padding: 2px; cursor: pointer; }
|
|
|
.accent-intensity { width: 78px; height: 22px; margin: 0 2px; accent-color: var(--ui-accent); cursor: pointer; }
|
|
|
@media (max-width: 760px) { .theme-select, .accent-select { width: 86px; font-size: 10px; } .accent-custom { width: 26px; } }
|
|
|
/* Minimal motion layer: CSS only, respects reduced-motion preferences. */
|
|
|
:root { --ui-radius: 12px; --ui-shadow: 0 8px 24px rgba(0, 0, 0, .12); --ui-glow: color-mix(in srgb, var(--ui-accent) 10%, transparent); }
|
|
|
body { min-height: 100vh; background-image: radial-gradient(circle at 12% 0%, var(--ui-glow), transparent 30%), linear-gradient(145deg, var(--ui-bg), var(--ui-stage)) !important; font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: .01em; }
|
|
|
body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; opacity: .07; background-image: linear-gradient(color-mix(in srgb, var(--ui-text) 4%, transparent) 1px, transparent 1px), linear-gradient(90deg, color-mix(in srgb, var(--ui-text) 4%, transparent) 1px, transparent 1px); background-size: 32px 32px; mask-image: linear-gradient(to bottom, black, transparent 78%); }
|
|
|
header { position: relative; height: 52px; padding: 0 14px; gap: 9px; background: var(--ui-surface) !important; border-bottom: 1px solid var(--ui-border) !important; box-shadow: 0 4px 14px rgba(0, 0, 0, .08); }
|
|
|
h1 { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; letter-spacing: .14em; text-transform: uppercase; }
|
|
|
h1::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--ui-accent); box-shadow: 0 0 0 5px color-mix(in srgb, var(--ui-accent) 14%, transparent), 0 0 18px var(--ui-accent); animation: beacon 2.8s ease-in-out infinite; }
|
|
|
.status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; }
|
|
|
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .8; }
|
|
|
.tabs { gap: 4px; padding: 3px; border: 1px solid var(--ui-border); border-radius: 10px; background: var(--ui-input); }
|
|
|
.tab { height: 30px; padding: 0 11px; border: 0 !important; border-radius: 8px; transition: color .2s ease, background .2s ease, transform .2s ease; }
|
|
|
.tab:hover { background: color-mix(in srgb, var(--ui-accent) 5%, var(--ui-input)); transform: translateY(-1px); }
|
|
|
.tab.active { background: color-mix(in srgb, var(--ui-accent) 8%, var(--ui-surface)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-accent) 20%, transparent); }
|
|
|
.theme-select, .accent-select, .accent-custom { border-radius: 8px; transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; }
|
|
|
.theme-select:hover, .accent-select:hover, .accent-custom:hover { border-color: var(--ui-accent) !important; transform: translateY(-1px); }
|
|
|
main { height: calc(100vh - 52px); }
|
|
|
.stream-tab, .archive-tab, .settings-tab, .model-tab { padding: 10px; }
|
|
|
.stage, .settings-panel, .model-panel, .archive-panel, .log-drawer, .player-box, .netron-panel { border-radius: var(--ui-radius); box-shadow: var(--ui-shadow); }
|
|
|
.stage { isolation: isolate; }
|
|
|
.stage::after { content: ""; position: absolute; inset: 0; pointer-events: none; border-radius: inherit; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-accent) 8%, transparent); }
|
|
|
.quickbar { height: 38px; grid-template-columns: minmax(220px, 360px) auto auto minmax(0, 1fr); }
|
|
|
.quickbar select, .command, .control-form select, .control-form input, .control-form button, .model-group select, .model-group input, .model-group button, .archive-tools input, .archive-tools button { border-radius: 8px; transition: border-color .2s ease, box-shadow .2s ease, background .2s ease, transform .2s ease; }
|
|
|
.quickbar select:focus, .command:focus-visible, .control-form select:focus, .control-form input:focus, .control-form button:focus-visible, .model-group select:focus, .model-group input:focus, .model-group button:focus-visible, .archive-tools input:focus, .archive-tools button:focus-visible { outline: 0; border-color: var(--ui-accent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-accent) 10%, transparent); }
|
|
|
.command:hover:not(:disabled), .control-form button:hover:not(:disabled), .model-group button:hover:not(:disabled), .archive-tools button:hover:not(:disabled) { transform: translateY(-1px); border-color: var(--ui-accent); }
|
|
|
.command.primary { box-shadow: 0 8px 20px color-mix(in srgb, var(--ui-accent) 16%, transparent); }
|
|
|
.metricgrid { overflow: hidden; border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, .16); }
|
|
|
.metric { transition: background .25s ease, transform .25s ease; }
|
|
|
.metric:hover { transform: translateY(-1px); background: color-mix(in srgb, var(--ui-accent) 3%, var(--ui-panel)) !important; }
|
|
|
.settings-panel, .model-panel, .archive-panel { animation: page-enter .42s ease both; }
|
|
|
.settings-group, .model-group { padding: 13px; }
|
|
|
.drop-zone { border-radius: 10px; transition: color .2s ease, border-color .2s ease, background .2s ease; }
|
|
|
.drop-zone.drag { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ui-accent) 13%, transparent); animation: pulse-border 1.2s ease-in-out infinite; }
|
|
|
@keyframes page-enter { from { opacity: 0; transform: translateY(7px) scale(.995); } to { opacity: 1; transform: none; } }
|
|
|
@keyframes beacon { 0%, 100% { opacity: .7; transform: scale(.9); } 50% { opacity: 1; transform: scale(1.1); } }
|
|
|
@keyframes pulse-border { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ui-accent) 20%, transparent); } 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--ui-accent) 8%, transparent); } }
|
|
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } }
|
|
|
@media (max-width: 760px) { header { height: 48px; } main { height: calc(100vh - 48px); } .tabs { order: 1; } .theme-select, .accent-select { width: 82px; } }
|
|
|
.command, .control-form button, .model-group button, .archive-tools button { font-weight: 700; letter-spacing: .025em; }
|
|
|
.command { font-size: 13px; }
|
|
|
.command.primary, .command.danger { font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
|
|
|
.command.primary { text-shadow: 0 1px 12px color-mix(in srgb, var(--ui-accent) 30%, transparent); }
|
|
|
.command.danger { text-shadow: 0 1px 10px rgba(255, 100, 120, .22); }
|
|
|
.model-group button, .control-form button, .archive-tools button { font-size: 12px; }
|
|
|
.command, .control-form button, .model-group button, .archive-tools button { font-weight: 900; }
|
|
|
.command.primary { color: #ffffff !important; background: linear-gradient(135deg, var(--ui-accent), color-mix(in srgb, var(--ui-accent) 58%, #000000)) !important; border-color: var(--ui-accent) !important; box-shadow: 0 6px 16px color-mix(in srgb, var(--ui-accent) 18%, transparent), inset 0 1px 0 rgba(255, 255, 255, .2); }
|
|
|
.command.danger { color: #ffffff !important; background: linear-gradient(135deg, #eb526b, #a7193d) !important; border-color: #f06d83 !important; box-shadow: 0 6px 16px rgba(255, 55, 90, .16), inset 0 1px 0 rgba(255, 255, 255, .16); }
|
|
|
.control-form button, .model-group button, .archive-tools button { color: var(--ui-accent) !important; background: color-mix(in srgb, var(--ui-accent) 12%, var(--ui-panel)) !important; }
|
|
|
.control-form button:hover:not(:disabled), .model-group button:hover:not(:disabled), .archive-tools button:hover:not(:disabled) { color: #ffffff !important; background: var(--ui-accent-bg) !important; }
|
|
|
/* Compact form layer: clear fields, short motion, no heavy cards. */
|
|
|
.settings-panel, .model-panel { box-shadow: 0 5px 16px rgba(0, 0, 0, .08); }
|
|
|
.settings-group, .model-group { padding: 9px 10px; gap: 6px 8px; background: transparent !important; }
|
|
|
.settings-group legend, .model-group legend { padding: 0 0 4px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; opacity: .82; }
|
|
|
.control-form label, .model-group label { gap: 2px; font-size: 10px; line-height: 1.2; }
|
|
|
.control-form select, .control-form input:not([type="checkbox"]):not([type="file"]), .model-group select, .model-group input:not([type="checkbox"]):not([type="file"]) { height: 27px; padding: 0 7px; border: 1px solid transparent !important; border-bottom-color: var(--ui-border) !important; border-radius: 6px; background: color-mix(in srgb, var(--ui-input) 62%, transparent) !important; }
|
|
|
.control-form select:focus, .control-form input:not([type="checkbox"]):focus, .model-group select:focus, .model-group input:not([type="checkbox"]):focus { background: var(--ui-input) !important; border-color: var(--ui-accent) !important; box-shadow: 0 0 0 2px color-mix(in srgb, var(--ui-accent) 8%, transparent) !important; transform: translateY(-1px); }
|
|
|
.settings-advanced summary { padding: 7px 10px; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
|
|
|
.advanced-grid { padding: 0 8px 8px; gap: 6px; }
|
|
|
.drop-zone { min-height: 28px; }
|
|
|
.model-actions { gap: 4px; }
|
|
|
.netron-toolbar { margin-top: 8px; }
|
|
|
.settings-group > label, .model-group > label { animation: form-field-in .25s ease both; }
|
|
|
.settings-group > label:nth-of-type(2), .model-group > label:nth-of-type(2) { animation-delay: .03s; }
|
|
|
.settings-group > label:nth-of-type(3), .model-group > label:nth-of-type(3) { animation-delay: .06s; }
|
|
|
.settings-group > label:nth-of-type(4), .model-group > label:nth-of-type(4) { animation-delay: .09s; }
|
|
|
.settings-group:focus-within, .model-group:focus-within { border-color: color-mix(in srgb, var(--ui-accent) 35%, var(--ui-border)) !important; }
|
|
|
.settings-group > summary, .model-group > summary { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 0 4px; color: var(--ui-text); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; cursor: pointer; user-select: none; list-style: none; }
|
|
|
.settings-group > summary::-webkit-details-marker, .model-group > summary::-webkit-details-marker { display: none; }
|
|
|
.settings-group > summary::after, .model-group > summary::after { content: '⌄'; color: var(--ui-accent); font-size: 15px; line-height: 1; transform-origin: center; transition: transform .2s ease, color .2s ease; }
|
|
|
.settings-group:not([open]) > summary::after, .model-group:not([open]) > summary::after { transform: rotate(-90deg); }
|
|
|
.settings-group[open] > :not(summary), .model-group[open] > :not(summary) { animation: form-group-in .22s ease both; }
|
|
|
.settings-group[open] .control-fields > *, .model-group[open] .model-actions { animation: form-field-in .22s ease both; }
|
|
|
.settings-group > summary:focus-visible, .model-group > summary:focus-visible { outline: 0; color: var(--ui-accent); text-decoration: underline; text-underline-offset: 4px; }
|
|
|
.control-fields.form-enter > *, .packet-builder.form-enter > * { animation: form-field-in .24s ease both; }
|
|
|
.control-fields.form-enter > *:nth-child(2), .packet-builder.form-enter > *:nth-child(2) { animation-delay: .03s; }
|
|
|
.control-fields.form-enter > *:nth-child(3), .packet-builder.form-enter > *:nth-child(3) { animation-delay: .06s; }
|
|
|
.control-fields.form-enter > *:nth-child(4), .packet-builder.form-enter > *:nth-child(4) { animation-delay: .09s; }
|
|
|
.form-enter { animation: form-group-in .24s ease both; }
|
|
|
.control-fields.form-exit > *, .packet-builder.form-exit > * { animation: form-field-out .18s ease both; }
|
|
|
.form-exit { animation: form-group-out .18s ease both; pointer-events: none; }
|
|
|
.settings-advanced[open] > .advanced-grid, .packet-builder[open] > .packet-builder-toolbar, .packet-builder[open] > .packet-byte-map, .packet-builder[open] > .packet-field-list, .packet-builder[open] > .packet-grid { animation: form-group-in .22s ease both; }
|
|
|
.settings-advanced summary, .packet-builder summary { transition: color .18s ease; }
|
|
|
.settings-advanced[open] summary, .packet-builder[open] summary { color: var(--ui-accent); }
|
|
|
.config-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--ui-border); }
|
|
|
.config-toolbar-title { margin-right: auto; color: var(--ui-muted); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
|
|
.config-toolbar button, .config-restore-panel button { min-height: 26px; padding: 0 8px; border: 1px solid var(--ui-border); border-radius: 7px; background: var(--ui-input); color: var(--ui-muted); font: 700 10px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; transition: border-color .18s ease, color .18s ease, background .18s ease, transform .18s ease; }
|
|
|
.config-toolbar button:hover, .config-toolbar button:focus-visible, .config-restore-panel button:hover, .config-restore-panel button:focus-visible { outline: 0; border-color: var(--ui-accent); color: var(--ui-accent); transform: translateY(-1px); }
|
|
|
.config-scale-control { display: inline-flex !important; align-items: center; gap: 6px !important; min-width: 148px; color: var(--ui-muted) !important; font-size: 10px !important; white-space: nowrap; }
|
|
|
.config-scale-control input { width: 74px; height: 16px; margin: 0; accent-color: var(--ui-accent); cursor: pointer; }
|
|
|
.config-scale-control input:disabled { opacity: .45; cursor: default; }
|
|
|
.config-scale-control output { min-width: 34px; color: var(--ui-text); font-variant-numeric: tabular-nums; text-align: right; }
|
|
|
.config-layout-state { min-width: 0; color: var(--ui-muted); font-size: 10px; }
|
|
|
.config-workspace { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; min-height: 0; }
|
|
|
.config-workspace.has-structure { grid-template-columns: minmax(190px, 260px) minmax(0, 1fr); align-items: start; }
|
|
|
.config-workspace.has-structure > .config-restore-panel { grid-column: 1; border-right: 1px solid var(--ui-border); border-bottom: 0; }
|
|
|
.config-workspace.has-structure > .config-canvas { grid-column: 2; }
|
|
|
.config-restore-panel { display: grid; gap: 8px; min-width: 0; max-height: calc(100vh - 128px); overflow: auto; padding: 8px 10px; border-bottom: 1px solid var(--ui-border); background: color-mix(in srgb, var(--ui-input) 45%, transparent); }
|
|
|
.config-restore-title { color: var(--ui-muted); font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
|
.config-structure-create { display: flex; align-items: end; flex-wrap: wrap; gap: 6px; }
|
|
|
.config-structure-create label { display: grid; gap: 3px; min-width: 150px; color: var(--ui-muted); font-size: 10px; }
|
|
|
.config-structure-create label:first-child { min-width: 190px; }
|
|
|
.config-structure-create input, .config-structure-create select { height: 27px; min-width: 0; padding: 0 7px; border: 1px solid var(--ui-border); border-radius: 7px; background: var(--ui-input); color: var(--ui-text); font: inherit; }
|
|
|
.config-structure-create input:focus, .config-structure-create select:focus { outline: 0; border-color: var(--ui-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--ui-accent) 10%, transparent); }
|
|
|
.config-structure-hint { color: var(--ui-muted); font-size: 10px; }
|
|
|
.config-restore-list { display: grid; grid-template-columns: 1fr; gap: 5px; }
|
|
|
.config-restore-item { display: flex; align-items: center; gap: 6px; min-width: 0; padding: 4px 5px 4px 8px; border: 1px solid var(--ui-border); border-radius: 7px; color: var(--ui-text); font-size: 11px; }
|
|
|
.config-restore-item.is-hidden { opacity: .72; }
|
|
|
.config-restore-item .config-item-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.config-restore-item .config-item-state { color: var(--ui-muted); font-size: 9px; text-transform: uppercase; }
|
|
|
.config-item-actions { display: inline-flex; gap: 3px; }
|
|
|
.config-restore-item { cursor: pointer; }
|
|
|
.config-restore-item:hover, .config-restore-item.is-selected { border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 7%, var(--ui-panel)); }
|
|
|
.config-deleted-toggle { width: 100%; }
|
|
|
.config-deleted-list { display: grid; gap: 5px; padding-top: 4px; border-top: 1px solid var(--ui-border); }
|
|
|
.config-deleted-list[hidden] { display: none !important; }
|
|
|
.config-restore-panel[hidden], .config-trash[hidden] { display: none !important; }
|
|
|
.config-canvas { position: relative; display: block; min-height: 760px; padding: 8px 6px 18px; overflow: auto; overscroll-behavior: auto; background: radial-gradient(circle at 12% 8%, color-mix(in srgb, var(--ui-accent) 7%, transparent), transparent 28%), linear-gradient(135deg, color-mix(in srgb, var(--ui-bg) 74%, var(--ui-panel)), var(--ui-bg)); }
|
|
|
.config-canvas::before { content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: .34; background-image: linear-gradient(color-mix(in srgb, var(--ui-text) 5%, transparent) 1px, transparent 1px), linear-gradient(90deg, color-mix(in srgb, var(--ui-text) 5%, transparent) 1px, transparent 1px); background-size: 28px 28px; mask-image: linear-gradient(to bottom, black, transparent 86%); }
|
|
|
.config-canvas > .config-block { position: absolute; z-index: 1; box-sizing: border-box; container: config-block / inline-size; margin: 0; min-width: 240px; min-height: 110px; border: 1px solid var(--ui-border) !important; border-radius: 14px; background: color-mix(in srgb, var(--ui-panel) 94%, var(--ui-accent)) !important; box-shadow: 0 8px 22px color-mix(in srgb, var(--ui-bg) 22%, transparent); overflow: auto; transition: border-color .2s ease, box-shadow .2s ease, opacity .2s ease, transform .2s ease; }
|
|
|
body[data-card-style="flat"] .config-canvas > .config-block { box-shadow: none; border-radius: 8px; }
|
|
|
body[data-card-style="neon"] .config-canvas > .config-block { border-color: color-mix(in srgb, var(--ui-accent) 48%, var(--ui-border)) !important; box-shadow: 0 0 0 1px color-mix(in srgb, var(--ui-accent) 12%, transparent), 0 10px 24px color-mix(in srgb, var(--ui-accent) 10%, transparent); }
|
|
|
.config-canvas > .config-block { cursor: grab; touch-action: none; }
|
|
|
.config-canvas > .config-block, .network-block { scrollbar-width: none; }
|
|
|
.config-canvas > .config-block::-webkit-scrollbar, .network-block::-webkit-scrollbar { width: 0; height: 0; }
|
|
|
.config-canvas > .config-block::after, .network-block::after { content: ""; position: absolute; right: 4px; bottom: 4px; z-index: 4; width: 13px; height: 13px; border-right: 2px solid color-mix(in srgb, var(--ui-accent) 62%, transparent); border-bottom: 2px solid color-mix(in srgb, var(--ui-accent) 62%, transparent); border-radius: 0 0 3px 0; opacity: .7; pointer-events: none; }
|
|
|
.config-canvas > .config-block:hover { border-color: color-mix(in srgb, var(--ui-accent) 45%, var(--ui-border)) !important; }
|
|
|
.config-canvas > .config-block.is-selected { border-color: color-mix(in srgb, var(--ui-accent) 70%, var(--ui-border)) !important; box-shadow: 0 0 0 2px color-mix(in srgb, var(--ui-accent) 12%, transparent), 0 6px 18px rgba(0, 0, 0, .1); }
|
|
|
.config-canvas > .config-block.is-dragging, .config-canvas > .config-block.is-resizing { z-index: 5; border-color: var(--ui-accent) !important; box-shadow: 0 10px 28px color-mix(in srgb, var(--ui-accent) 16%, transparent); transition: none; }
|
|
|
.config-canvas > .config-custom-block { border-style: dashed !important; }
|
|
|
.config-block > summary { position: sticky; top: 0; z-index: 2; background: color-mix(in srgb, var(--ui-panel) 92%, var(--ui-accent)); border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 80%, transparent); }
|
|
|
.config-block > summary::after { display: none !important; }
|
|
|
.block-title { min-width: 0; display: inline-flex; align-items: center; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.block-title::before { content: "✦"; flex: 0 0 auto; color: var(--ui-accent); font-size: .85em; opacity: .8; }
|
|
|
.config-block > summary { cursor: grab; touch-action: none; }
|
|
|
.config-block > summary:active, .config-block.is-dragging > summary { cursor: grabbing; }
|
|
|
.config-block.is-dragging, .config-block.is-resizing { user-select: none; }
|
|
|
.config-block.is-dragging { cursor: grabbing; }
|
|
|
.config-block input, .config-block select, .config-block textarea, .config-block button, .config-block a { cursor: auto; touch-action: auto; }
|
|
|
.config-canvas > .config-block.settings-group { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); align-content: start; }
|
|
|
.config-block .advanced-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-content: start; }
|
|
|
.config-block .packet-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); align-content: start; }
|
|
|
.config-block label, .config-block input, .config-block select, .config-block button, .config-block pre { min-width: 0; max-width: 100%; box-sizing: border-box; }
|
|
|
.config-block .control-fields, .config-block .advanced-grid, .config-block .packet-grid, .config-block .packet-field-list { min-width: 0; }
|
|
|
@container config-block (max-width: 620px) {
|
|
|
.config-canvas > .config-block.settings-group { grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); }
|
|
|
.config-block.settings-group > summary { grid-column: 1 / -1; }
|
|
|
.config-block .advanced-grid { grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); }
|
|
|
.config-block .packet-grid { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
|
|
|
.config-block .packet-field-row { grid-template-columns: 54px minmax(0, 1fr) 66px 28px; }
|
|
|
.config-block .packet-field-row .packet-role { grid-column: 2 / -1; }
|
|
|
}
|
|
|
@container config-block (max-width: 420px) {
|
|
|
.config-block .advanced-grid, .config-block .packet-grid { grid-template-columns: minmax(0, 1fr); }
|
|
|
.config-block .packet-builder-toolbar { flex-wrap: wrap; }
|
|
|
.config-block .packet-builder-toolbar .packet-layout-status { flex-basis: 100%; order: 3; }
|
|
|
.config-block .packet-field-row { grid-template-columns: 52px minmax(0, 1fr) 28px; }
|
|
|
.config-block .packet-field-row .packet-field-range { grid-column: 1 / 3; }
|
|
|
}
|
|
|
.config-trash { position: fixed !important; left: 50% !important; bottom: 16px !important; z-index: 1200; display: inline-flex; align-items: center; gap: 9px; min-height: 48px; padding: 9px 16px 9px 11px; border: 1px solid #ff6b81; border-radius: 14px; background: linear-gradient(135deg, #4b1020, #9e2948); color: #fff3f5; box-shadow: 0 10px 30px rgba(102, 12, 35, .35), 0 0 0 4px rgba(255, 93, 119, .1); font-size: 11px; font-weight: 800; letter-spacing: .02em; opacity: .98; transform: translate(-50%, 12px) scale(.96); animation: trash-in .18s ease both; transition: color .18s ease, border-color .18s ease, background .18s ease, transform .18s ease, box-shadow .18s ease; pointer-events: auto; }
|
|
|
.config-trash.is-over { color: #fff; border-color: #ffb2c0; background: linear-gradient(135deg, #9e2948, #d94062); box-shadow: 0 12px 34px rgba(181, 45, 76, .42), 0 0 0 5px rgba(255, 107, 129, .2); transform: translate(-50%, 0) scale(1.04); }
|
|
|
.config-trash-icon { display: block; flex: 0 0 auto; width: 27px; height: 27px; color: currentColor; }
|
|
|
.config-trash:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 3px; }
|
|
|
.config-block-hidden, .config-block-deleted, .config-canvas > .config-block[data-block-state="hidden"], .config-canvas > .config-block[data-block-state="deleted"] { display: none !important; }
|
|
|
@media (max-width: 760px) {
|
|
|
.config-toolbar { align-items: stretch; }
|
|
|
.config-toolbar-title, .config-layout-state { flex-basis: 100%; }
|
|
|
.config-workspace.has-structure { grid-template-columns: 1fr; }
|
|
|
.config-workspace.has-structure > .config-restore-panel, .config-workspace.has-structure > .config-canvas { grid-column: 1; }
|
|
|
.config-workspace.has-structure > .config-restore-panel { border-right: 0; border-bottom: 1px solid var(--ui-border); max-height: 42vh; }
|
|
|
.config-canvas { min-height: 900px; padding: 8px 4px 24px; }
|
|
|
.config-canvas > .config-block { min-width: 220px; max-width: calc(100% - 8px); }
|
|
|
.config-structure-create { align-items: stretch; }
|
|
|
.config-structure-create label, .config-structure-create label:first-child { min-width: min(100%, 220px); flex: 1 1 180px; }
|
|
|
.config-scale-control { flex: 1 1 100%; justify-content: space-between; min-width: 0; }
|
|
|
.config-trash { bottom: 10px; }
|
|
|
}
|
|
|
@keyframes form-field-in { from { opacity: .35; transform: translateY(3px); } to { opacity: 1; transform: none; } }
|
|
|
@keyframes form-field-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-3px); } }
|
|
|
@keyframes form-group-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
|
@keyframes form-group-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-4px); } }
|
|
|
@keyframes trash-in { from { opacity: 0; transform: translate(-50%, 16px) scale(.9); } to { opacity: .96; transform: translate(-50%, 0) scale(1); } }
|
|
|
/* Theme controls stay in one small module instead of crowding the header. */
|
|
|
.appearance-module { position: relative; flex: 0 0 auto; z-index: 30; }
|
|
|
.appearance-trigger { display: inline-flex; align-items: center; gap: 8px; min-width: 148px; height: 34px; padding: 0 9px; border: 1px solid var(--ui-border); border-radius: 10px; background: color-mix(in srgb, var(--ui-surface) 76%, var(--ui-input)); color: var(--ui-text); cursor: pointer; transition: border-color .2s ease, background .2s ease, transform .2s ease, box-shadow .2s ease; }
|
|
|
.appearance-trigger:hover, .appearance-trigger[aria-expanded="true"] { border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 7%, var(--ui-surface)); box-shadow: 0 5px 18px color-mix(in srgb, var(--ui-accent) 10%, transparent); transform: translateY(-1px); }
|
|
|
.appearance-trigger:focus-visible, .appearance-close:focus-visible, .appearance-options button:focus-visible, .appearance-foot button:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; }
|
|
|
.appearance-trigger-dot { width: 11px; height: 11px; flex: 0 0 auto; border-radius: 50%; background: var(--ui-accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ui-accent) 13%, transparent); transition: background .2s ease, box-shadow .2s ease; }
|
|
|
.appearance-trigger-copy { min-width: 0; display: grid; gap: 1px; text-align: left; line-height: 1.05; }
|
|
|
.appearance-trigger-copy b { font-size: 10px; letter-spacing: .07em; text-transform: uppercase; }
|
|
|
.appearance-trigger-copy small { min-width: 0; overflow: hidden; color: var(--ui-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.appearance-trigger-chevron { margin-left: auto; color: var(--ui-muted); font-size: 17px; line-height: 1; transform: translateY(-1px); transition: transform .2s ease, color .2s ease; }
|
|
|
.appearance-trigger[aria-expanded="true"] .appearance-trigger-chevron { color: var(--ui-accent); transform: rotate(180deg) translateY(1px); }
|
|
|
.appearance-panel { position: absolute; top: calc(100% + 9px); right: 0; width: min(390px, calc(100vw - 20px)); max-height: min(78vh, 680px); overflow: auto; padding: 13px; border: 1px solid color-mix(in srgb, var(--ui-accent) 18%, var(--ui-border)); border-radius: 16px; background: color-mix(in srgb, var(--ui-surface) 84%, var(--ui-bg)); box-shadow: 0 14px 32px rgba(0, 0, 0, .17); color: var(--ui-text); animation: appearance-in .2s ease both; }
|
|
|
.appearance-panel[hidden] { display: none !important; }
|
|
|
.appearance-head, .appearance-group-head, .appearance-intensity > span, .appearance-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
|
.appearance-head { padding-bottom: 11px; border-bottom: 1px solid var(--ui-border); }
|
|
|
.appearance-head > div { display: grid; gap: 2px; }
|
|
|
.appearance-head strong { font-size: 13px; }
|
|
|
.appearance-head small, .appearance-group-head small, .appearance-foot span { color: var(--ui-muted); font-size: 10px; }
|
|
|
.appearance-close { width: 26px; height: 26px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ui-muted); font-size: 19px; line-height: 1; cursor: pointer; }
|
|
|
.appearance-close:hover { color: var(--ui-text); border-color: var(--ui-border); background: var(--ui-input); }
|
|
|
.appearance-preview { display: grid; gap: 7px; margin: 11px 0 1px; padding: 9px; border: 1px solid color-mix(in srgb, var(--ui-accent) 30%, var(--ui-border)); border-radius: 11px; background: linear-gradient(135deg, color-mix(in srgb, var(--ui-accent) 10%, var(--ui-panel)), var(--ui-input)); overflow: hidden; }
|
|
|
.appearance-preview-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-muted); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; }
|
|
|
.appearance-preview-status { color: var(--ui-accent); font-weight: 800; }
|
|
|
.appearance-preview-card { position: relative; display: flex; align-items: center; gap: 8px; min-height: 48px; padding: 8px; border: 1px solid color-mix(in srgb, var(--ui-accent) 23%, var(--ui-border)); border-radius: 9px; background: color-mix(in srgb, var(--ui-surface) 72%, transparent); overflow: hidden; animation: preview-float 4.5s ease-in-out infinite; }
|
|
|
.appearance-preview-card::after { content: ""; position: absolute; inset: 0 auto 0 -38%; width: 34%; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--ui-accent) 18%, transparent), transparent); transform: skewX(-18deg); animation: preview-sheen 4.8s ease-in-out infinite; pointer-events: none; }
|
|
|
.appearance-preview-dot { width: 25px; height: 25px; flex: 0 0 auto; border: 4px solid color-mix(in srgb, var(--ui-accent) 20%, transparent); border-radius: 50%; background: var(--ui-accent); box-shadow: 0 0 16px color-mix(in srgb, var(--ui-accent) 35%, transparent); }
|
|
|
.appearance-preview-copy { min-width: 0; display: grid; gap: 2px; }
|
|
|
.appearance-preview-copy strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.appearance-preview-copy small { color: var(--ui-muted); font-size: 10px; }
|
|
|
.appearance-preview-bars { display: flex; align-items: end; gap: 3px; height: 24px; margin-left: auto; padding-right: 2px; }
|
|
|
.appearance-preview-bars i { display: block; width: 4px; border-radius: 4px; background: var(--ui-accent); opacity: .72; animation: preview-bars 1.8s ease-in-out infinite; }
|
|
|
.appearance-preview-bars i:nth-child(1) { height: 9px; animation-delay: -.4s; }
|
|
|
.appearance-preview-bars i:nth-child(2) { height: 17px; animation-delay: -.2s; }
|
|
|
.appearance-preview-bars i:nth-child(3) { height: 12px; animation-delay: -.7s; }
|
|
|
.appearance-preview-bars i:nth-child(4) { height: 21px; animation-delay: -.1s; }
|
|
|
.appearance-preview-bars i:nth-child(5) { height: 14px; animation-delay: -.55s; }
|
|
|
.appearance-group { display: grid; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--ui-border); }
|
|
|
.appearance-group-head span { font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
|
|
.appearance-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 6px; }
|
|
|
.appearance-options button { min-width: 0; display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: center; gap: 7px; height: 34px; padding: 0 7px; border: 1px solid color-mix(in srgb, var(--appearance-option-accent, var(--ui-border)) 26%, var(--ui-border)); border-radius: 8px; background: color-mix(in srgb, var(--ui-input) 88%, var(--ui-surface)); color: color-mix(in srgb, var(--ui-text) 76%, var(--ui-muted)); font: 10px/1.05 ui-sans-serif, system-ui, sans-serif; text-align: left; cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease, transform .18s ease; }
|
|
|
.appearance-options button:hover { color: var(--ui-text); border-color: color-mix(in srgb, var(--ui-accent) 62%, var(--ui-border)); transform: translateY(-1px); }
|
|
|
.appearance-options button.is-active { color: var(--ui-text); border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 10%, var(--ui-input)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-accent) 22%, transparent); }
|
|
|
.appearance-options button.is-active .appearance-swatch { box-shadow: 0 0 0 2px var(--ui-accent), 0 0 0 4px color-mix(in srgb, var(--ui-accent) 18%, transparent); }
|
|
|
.appearance-options button > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.appearance-swatch { width: 22px; height: 22px; border: 1px solid color-mix(in srgb, var(--appearance-option-accent, var(--ui-text)) 48%, var(--ui-text)); border-radius: 7px; background: linear-gradient(135deg, var(--appearance-option-accent, var(--appearance-swatch)) 0 52%, var(--appearance-swatch) 52% 100%); box-shadow: 0 0 0 1px color-mix(in srgb, var(--appearance-option-accent, var(--ui-text)) 24%, transparent); }
|
|
|
.accent-options button { grid-template-columns: 18px minmax(0, 1fr); }
|
|
|
.accent-options .appearance-swatch { width: 16px; height: 16px; border: 0; border-radius: 50%; background: var(--appearance-option-accent, var(--appearance-swatch)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--appearance-option-accent, var(--appearance-swatch)) 24%, transparent); }
|
|
|
.appearance-custom { display: flex !important; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-muted) !important; font-size: 10px !important; }
|
|
|
.appearance-custom .accent-custom { width: 42px; height: 25px; padding: 2px; border: 1px solid var(--ui-border) !important; border-radius: 7px; background: var(--ui-input) !important; cursor: pointer; }
|
|
|
.appearance-intensity { display: grid !important; gap: 7px !important; padding: 12px 0 10px; color: var(--ui-muted) !important; font-size: 10px !important; }
|
|
|
.appearance-intensity output { color: var(--ui-text); font-variant-numeric: tabular-nums; }
|
|
|
.appearance-intensity .accent-intensity { width: 100%; height: 18px; margin: 0; accent-color: var(--ui-accent); cursor: pointer; }
|
|
|
.appearance-field { display: grid !important; grid-template-columns: 1fr minmax(0, 1.35fr); align-items: center; gap: 8px !important; color: var(--ui-muted) !important; font-size: 10px !important; }
|
|
|
.appearance-field select { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid var(--ui-border); border-radius: 8px; background: var(--ui-input); color: var(--ui-text); font: 11px ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
|
|
|
.appearance-field select:focus { outline: 0; border-color: var(--ui-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--ui-accent) 12%, transparent); }
|
|
|
.appearance-setting { display: grid; gap: 6px; }
|
|
|
.appearance-setting-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-muted); font-size: 10px; }
|
|
|
.appearance-setting-head small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
.appearance-segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
|
|
|
.appearance-segmented button { min-width: 0; min-height: 27px; padding: 0 5px; border: 1px solid var(--ui-border); border-radius: 7px; background: var(--ui-input); color: var(--ui-muted); font: 10px ui-sans-serif, system-ui, sans-serif; cursor: pointer; transition: border-color .18s ease, color .18s ease, background .18s ease, transform .18s ease; }
|
|
|
.appearance-segmented button:hover { color: var(--ui-text); border-color: color-mix(in srgb, var(--ui-accent) 62%, var(--ui-border)); transform: translateY(-1px); }
|
|
|
.appearance-segmented button.is-active { color: var(--ui-text); border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 12%, var(--ui-input)); }
|
|
|
.appearance-decor { padding: 5px 0 0; border-top: 1px solid var(--ui-border); }
|
|
|
.appearance-foot { padding-top: 2px; }
|
|
|
.appearance-foot button { min-height: 25px; padding: 0 8px; border: 1px solid var(--ui-border); border-radius: 7px; background: transparent; color: var(--ui-muted); font: 700 10px ui-sans-serif, system-ui, sans-serif; cursor: pointer; transition: color .18s ease, border-color .18s ease, background .18s ease; }
|
|
|
.appearance-foot button:hover { color: var(--ui-accent); border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 8%, transparent); }
|
|
|
.appearance-native { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
|
|
|
body { font-family: var(--ui-font, Inter, ui-sans-serif, system-ui, sans-serif) !important; font-size: calc(14px * var(--ui-font-scale, 1)) !important; }
|
|
|
body button, body input, body select, body textarea { font-family: var(--ui-font, Inter, ui-sans-serif, system-ui, sans-serif) !important; }
|
|
|
body::before { opacity: calc(var(--ui-decor, 42) * .001); }
|
|
|
body[data-density="compact"] .stream-tab, body[data-density="compact"] .archive-tab, body[data-density="compact"] .settings-tab, body[data-density="compact"] .model-tab { padding: 6px; }
|
|
|
body[data-density="compact"] .settings-group, body[data-density="compact"] .model-group { padding: 6px 8px; gap: 4px 6px; }
|
|
|
body[data-density="spacious"] .stream-tab, body[data-density="spacious"] .archive-tab, body[data-density="spacious"] .settings-tab, body[data-density="spacious"] .model-tab { padding: 16px; }
|
|
|
body[data-density="spacious"] .settings-group, body[data-density="spacious"] .model-group { padding: 15px 16px; gap: 10px 12px; }
|
|
|
body[data-density="spacious"] .quickbar { gap: 10px; }
|
|
|
body[data-radius="sharp"] .stage, body[data-radius="sharp"] .settings-panel, body[data-radius="sharp"] .model-panel, body[data-radius="sharp"] .archive-panel, body[data-radius="sharp"] .log-drawer, body[data-radius="sharp"] .player-box, body[data-radius="sharp"] .netron-panel { border-radius: 5px; }
|
|
|
body[data-radius="sharp"] button, body[data-radius="sharp"] select, body[data-radius="sharp"] input, body[data-radius="sharp"] textarea, body[data-radius="sharp"] .config-block, body[data-radius="sharp"] .network-block { border-radius: 4px !important; }
|
|
|
body[data-radius="round"] .stage, body[data-radius="round"] .settings-panel, body[data-radius="round"] .model-panel, body[data-radius="round"] .archive-panel, body[data-radius="round"] .log-drawer, body[data-radius="round"] .player-box, body[data-radius="round"] .netron-panel { border-radius: 20px; }
|
|
|
body[data-radius="round"] button, body[data-radius="round"] select, body[data-radius="round"] input, body[data-radius="round"] textarea, body[data-radius="round"] .config-block, body[data-radius="round"] .network-block { border-radius: 14px !important; }
|
|
|
body[data-contrast="soft"] { --ui-muted: var(--ui-muted-contrast); --ui-border: var(--ui-border-contrast); }
|
|
|
body[data-contrast="high"] { --ui-muted: var(--ui-muted-contrast); --ui-border: var(--ui-border-contrast); }
|
|
|
body[data-motion="none"] *, body[data-motion="none"] *::before, body[data-motion="none"] *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
|
|
body[data-motion="expressive"] .settings-panel, body[data-motion="expressive"] .model-panel, body[data-motion="expressive"] .archive-panel { animation-duration: .62s; }
|
|
|
.video, .settings-panel, .model-panel, .archive-panel { width: 100%; max-width: none; }
|
|
|
.config-canvas > .config-block.settings-group { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
|
|
.config-block > .wide, .config-block > .control-fields, .config-block > .packet-builder { grid-column: 1 / -1 !important; width: 100%; min-width: 0; }
|
|
|
.config-block > .wide > input, .config-block > .wide > select, .config-block > .wide > textarea { width: 100%; }
|
|
|
.config-block .control-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 6px 8px; }
|
|
|
.config-block .control-fields[hidden] { display: none !important; }
|
|
|
.config-block .control-fields > .wide, .config-block .control-fields > .drop-zone, .config-block .control-fields > .upload-progress { grid-column: 1 / -1; }
|
|
|
.config-block .control-fields > label, .config-block .control-fields > button, .config-block .control-fields > .file-picked { min-width: 0; width: 100%; }
|
|
|
.config-block .control-fields > label > input, .config-block .control-fields > label > select, .config-block .control-fields > label > textarea { width: 100%; }
|
|
|
/* Details are the movable cards; their own content must always stretch. */
|
|
|
.config-canvas > .config-block.settings-group { display: flex !important; flex-direction: column; align-items: stretch; gap: 8px; }
|
|
|
.config-canvas > .config-block.settings-group > summary, .config-canvas > .config-block.settings-group > label, .config-canvas > .config-block.settings-group > .control-fields, .config-canvas > .config-block.settings-group > .packet-builder, .config-canvas > .config-block.settings-group > .settings-advanced { width: 100%; flex: 0 0 auto; }
|
|
|
.config-canvas > .config-block.settings-group > label { display: grid; }
|
|
|
.config-canvas > .config-block.settings-group > .control-fields { display: grid !important; }
|
|
|
.config-canvas > .config-block.settings-group { --block-scale: 1; padding: calc(9px * var(--block-scale)) calc(10px * var(--block-scale)); gap: calc(8px * var(--block-scale)); }
|
|
|
.config-canvas > .config-block.settings-group > summary { padding-bottom: calc(4px * var(--block-scale)); font-size: clamp(8px, calc(11px * var(--block-scale)), 24px); }
|
|
|
.config-block.settings-group label, .config-block.settings-group .file-picked { gap: calc(2px * var(--block-scale)); font-size: clamp(8px, calc(10px * var(--block-scale)), 22px); }
|
|
|
.config-block.settings-group input:not([type="checkbox"]):not([type="file"]), .config-block.settings-group select, .config-block.settings-group button { min-height: calc(27px * var(--block-scale)); height: calc(27px * var(--block-scale)); padding-left: calc(7px * var(--block-scale)); padding-right: calc(7px * var(--block-scale)); font-size: clamp(8px, calc(12px * var(--block-scale)), 26px); }
|
|
|
.config-block.settings-group .control-fields, .config-block.settings-group .advanced-grid, .config-block.settings-group .packet-grid { gap: calc(6px * var(--block-scale)) calc(8px * var(--block-scale)); }
|
|
|
.network-panel { overflow: hidden; }
|
|
|
.network-workspace { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; }
|
|
|
.network-workspace.has-structure { grid-template-columns: minmax(190px, 260px) minmax(0, 1fr); align-items: start; }
|
|
|
.network-workspace.has-structure > .network-restore-panel { grid-column: 1; border-right: 1px solid var(--ui-border); border-bottom: 0; }
|
|
|
.network-workspace.has-structure > .network-canvas { grid-column: 2; }
|
|
|
.network-restore-panel { min-height: 0; max-height: calc(100vh - 128px); }
|
|
|
.network-canvas { position: relative; display: block; min-height: 720px; padding: 14px; overflow: auto; overscroll-behavior: auto; background: radial-gradient(circle at 10% 0%, color-mix(in srgb, var(--ui-accent) 7%, transparent), transparent 32%), color-mix(in srgb, var(--ui-bg) 74%, var(--ui-panel)); }
|
|
|
.network-block { --block-scale: 1; position: absolute; min-width: 240px; min-height: 110px; margin: 0; padding: 14px; border: 1px solid var(--ui-border) !important; border-radius: 14px !important; background: color-mix(in srgb, var(--ui-panel) 94%, var(--ui-accent)) !important; box-shadow: 0 8px 22px color-mix(in srgb, var(--ui-bg) 20%, transparent); overflow: auto; transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; cursor: grab; touch-action: none; }
|
|
|
.network-block:hover { border-color: color-mix(in srgb, var(--ui-accent) 48%, var(--ui-border)) !important; transform: translateY(-2px); }
|
|
|
.network-block.is-selected { border-color: color-mix(in srgb, var(--ui-accent) 70%, var(--ui-border)) !important; box-shadow: 0 0 0 2px color-mix(in srgb, var(--ui-accent) 12%, transparent), 0 8px 22px color-mix(in srgb, var(--ui-bg) 20%, transparent); }
|
|
|
.network-block.is-dragging, .network-block.is-resizing { z-index: 5; border-color: var(--ui-accent) !important; box-shadow: 0 10px 28px color-mix(in srgb, var(--ui-accent) 16%, transparent); transform: none; transition: none; user-select: none; }
|
|
|
.network-custom-block { border-style: dashed !important; }
|
|
|
.network-block.is-deleted, .network-block.is-hidden, .network-canvas > .network-block[data-block-state="hidden"], .network-canvas > .network-block[data-block-state="deleted"] { display: none !important; }
|
|
|
.network-summary { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; padding-bottom: 8px; border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 80%, transparent); color: var(--ui-text); font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
|
|
.network-summary::before { content: "✦"; color: var(--ui-accent); font-size: 11px; }
|
|
|
.network-block.settings-group { --block-scale: 1; padding: calc(14px * var(--block-scale)); gap: calc(8px * var(--block-scale)); }
|
|
|
.network-block.settings-group label, .network-block.settings-group .model-state { gap: calc(3px * var(--block-scale)); font-size: clamp(8px, calc(11px * var(--block-scale)), 22px); }
|
|
|
.network-block.settings-group input:not([type="checkbox"]):not([type="file"]), .network-block.settings-group select, .network-block.settings-group button { min-height: calc(28px * var(--block-scale)); height: calc(28px * var(--block-scale)); padding-left: calc(7px * var(--block-scale)); padding-right: calc(7px * var(--block-scale)); font-size: clamp(8px, calc(12px * var(--block-scale)), 26px); }
|
|
|
.network-block.settings-group .network-summary { margin-bottom: calc(8px * var(--block-scale)); padding-bottom: calc(8px * var(--block-scale)); font-size: clamp(9px, calc(12px * var(--block-scale)), 24px); }
|
|
|
.network-block .model-actions { padding-top: 3px; }
|
|
|
body[data-card-style="flat"] .network-block { box-shadow: none; border-radius: 8px !important; }
|
|
|
body[data-card-style="neon"] .network-block { border-color: color-mix(in srgb, var(--ui-accent) 48%, var(--ui-border)) !important; box-shadow: 0 0 0 1px color-mix(in srgb, var(--ui-accent) 12%, transparent), 0 10px 24px color-mix(in srgb, var(--ui-accent) 10%, transparent); }
|
|
|
@keyframes appearance-in { from { opacity: 0; transform: translateY(-5px) scale(.98); } to { opacity: 1; transform: none; } }
|
|
|
@keyframes preview-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
|
|
@keyframes preview-sheen { 0%, 55% { transform: translateX(0) skewX(-18deg); opacity: 0; } 70% { opacity: 1; } 100% { transform: translateX(430%) skewX(-18deg); opacity: 0; } }
|
|
|
@keyframes preview-bars { 0%, 100% { transform: scaleY(.72); opacity: .52; } 50% { transform: scaleY(1); opacity: .95; } }
|
|
|
@media (max-width: 760px) {
|
|
|
.appearance-trigger { min-width: 38px; width: 38px; padding: 0; justify-content: center; }
|
|
|
.appearance-trigger-copy { display: none; }
|
|
|
.appearance-trigger-chevron { display: none; }
|
|
|
.appearance-panel { position: fixed; top: 56px; right: 8px; width: min(390px, calc(100vw - 16px)); }
|
|
|
.appearance-options { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
|
.network-workspace.has-structure { grid-template-columns: 1fr; }
|
|
|
.network-workspace.has-structure > .network-restore-panel, .network-workspace.has-structure > .network-canvas { grid-column: 1; }
|
|
|
.network-workspace.has-structure > .network-restore-panel { border-right: 0; border-bottom: 1px solid var(--ui-border); max-height: 42vh; }
|
|
|
.network-canvas { min-height: 900px; padding: 10px; }
|
|
|
.network-block { min-width: 220px; max-width: calc(100% - 8px); }
|
|
|
}
|
|
|
@media (max-width: 420px) { .appearance-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
<header>
|
|
|
<h1>FPV</h1>
|
|
|
<span id="state" class="status">подключение...</span>
|
|
|
<span id="videoName" class="source"></span>
|
|
|
<nav class="tabs" aria-label="Разделы">
|
|
|
<button class="tab active" type="button" data-tab="stream">Поток</button>
|
|
|
<button class="tab" type="button" data-tab="archive">Архив</button>
|
|
|
<button class="tab" type="button" data-tab="settings">Настройки</button>
|
|
|
<button class="tab" type="button" data-tab="model">Сеть</button>
|
|
|
</nav>
|
|
|
<div class="appearance-module">
|
|
|
<button id="appearanceToggle" class="appearance-trigger" type="button" aria-expanded="false" aria-controls="appearancePanel">
|
|
|
<span class="appearance-trigger-dot" aria-hidden="true"></span>
|
|
|
<span class="appearance-trigger-copy"><b>Оформление</b><small id="appearanceValue">Тёмная · Синий</small></span>
|
|
|
<span class="appearance-trigger-chevron" aria-hidden="true">⌄</span>
|
|
|
</button>
|
|
|
<section id="appearancePanel" class="appearance-panel" hidden aria-label="Настройки оформления">
|
|
|
<div class="appearance-head"><div><strong>Оформление</strong><small>Тема, акцент и насыщенность</small></div><button id="appearanceClose" class="appearance-close" type="button" aria-label="Закрыть">×</button></div>
|
|
|
<div class="appearance-preview" aria-live="polite">
|
|
|
<div class="appearance-preview-head"><span>живой preview</span><span id="appearancePreviewStatus" class="appearance-preview-status">активно</span></div>
|
|
|
<div class="appearance-preview-card"><span class="appearance-preview-dot" aria-hidden="true"></span><div class="appearance-preview-copy"><strong id="appearancePreviewTitle">Тёмная · Синий</strong><small id="appearancePreviewMeta">Системный · 100% · Сбалансированная</small></div><span class="appearance-preview-bars" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span></div>
|
|
|
</div>
|
|
|
<div class="appearance-group"><div class="appearance-group-head"><span>Тема</span><small id="themePreview">Тёмная</small></div><div id="themeOptions" class="appearance-options" role="listbox" aria-label="Выбор темы"></div></div>
|
|
|
<div class="appearance-group"><div class="appearance-group-head"><span>Акцент</span><small id="accentPreview">Синий</small></div><div id="accentOptions" class="appearance-options accent-options" role="listbox" aria-label="Выбор акцента"></div><label class="appearance-custom"><span>Свой цвет</span><input id="accentCustom" class="accent-custom" type="color" value="#8fb8ff" aria-label="Произвольный акцент" title="Произвольный акцент"></label></div>
|
|
|
<label class="appearance-intensity"><span><span>Насыщенность</span><output id="accentIntensityValue">100%</output></span><input id="accentIntensity" class="accent-intensity" type="range" min="55" max="100" step="5" value="100" aria-label="Интенсивность акцента" title="Интенсивность акцента"></label>
|
|
|
<div class="appearance-group appearance-interface-group"><div class="appearance-group-head"><span>Интерфейс</span><small id="interfacePreview">Система · 100%</small></div><label class="appearance-field"><span>Шрифт</span><select id="fontSelect" aria-label="Шрифт"><option value="system">Системный</option><option value="inter">Inter</option><option value="manrope">Manrope</option><option value="plex">IBM Plex Sans</option><option value="mono">Моноширинный</option></select></label><label class="appearance-intensity appearance-font-size"><span><span>Размер текста</span><output id="fontSizeValue">100%</output></span><input id="fontSize" type="range" min="90" max="120" step="5" value="100" aria-label="Размер текста"></label><div class="appearance-setting"><div class="appearance-setting-head"><span>Плотность</span><small id="densityPreview">Сбалансированная</small></div><div class="appearance-segmented" id="densityOptions"><button type="button" data-setting="density" data-value="compact">Компактная</button><button type="button" data-setting="density" data-value="balanced">Сбалансированная</button><button type="button" data-setting="density" data-value="spacious">Свободная</button></div></div></div>
|
|
|
<div class="appearance-group appearance-interface-group"><div class="appearance-group-head"><span>Характер</span><small id="stylePreview">Мягкий · Плавный</small></div><div class="appearance-setting"><div class="appearance-setting-head"><span>Скругление</span><small id="radiusPreview">Мягкое</small></div><div class="appearance-segmented" id="radiusOptions"><button type="button" data-setting="radius" data-value="sharp">Чёткое</button><button type="button" data-setting="radius" data-value="soft">Мягкое</button><button type="button" data-setting="radius" data-value="round">Круглое</button></div></div><div class="appearance-setting"><div class="appearance-setting-head"><span>Анимация</span><small id="motionPreview">Плавная</small></div><div class="appearance-segmented" id="motionOptions"><button type="button" data-setting="motion" data-value="none">Без</button><button type="button" data-setting="motion" data-value="subtle">Плавная</button><button type="button" data-setting="motion" data-value="expressive">Выразительная</button></div></div><div class="appearance-setting"><div class="appearance-setting-head"><span>Контраст</span><small id="contrastPreview">Обычный</small></div><div class="appearance-segmented" id="contrastOptions"><button type="button" data-setting="contrast" data-value="soft">Мягкий</button><button type="button" data-setting="contrast" data-value="normal">Обычный</button><button type="button" data-setting="contrast" data-value="high">Высокий</button></div></div><div class="appearance-setting"><div class="appearance-setting-head"><span>Карточки блоков</span><small id="cardStylePreview">Мягкие</small></div><div class="appearance-segmented" id="cardStyleOptions"><button type="button" data-setting="cardStyle" data-value="flat">Плоские</button><button type="button" data-setting="cardStyle" data-value="soft">Мягкие</button><button type="button" data-setting="cardStyle" data-value="neon">Неон</button></div></div><label class="appearance-intensity appearance-decor"><span><span>Декор фона</span><output id="decorValue">42%</output></span><input id="decorIntensity" type="range" min="0" max="100" step="5" value="42" aria-label="Интенсивность декора фона"></label></div>
|
|
|
<div class="appearance-foot"><span>Настройки сохраняются автоматически</span><button id="appearanceReset" type="button">Сбросить</button></div>
|
|
|
<select id="themeSelect" class="appearance-native" aria-label="Тема">
|
|
|
<option value="dark">Тёмная</option><option value="light">Светлая</option><option value="amber">Янтарная</option><option value="midnight">Полночь</option><option value="forest">Лесная</option><option value="rose">Розовая</option><option value="graphite">Графитовая</option><option value="solarized">Solarized</option><option value="ocean">Океан</option><option value="neon">Неон</option><option value="copper">Медь</option><option value="mono">Монохром</option>
|
|
|
</select>
|
|
|
<select id="accentSelect" class="appearance-native" aria-label="Акцент">
|
|
|
<option value="blue">Синий</option><option value="cyan">Бирюзовый</option><option value="green">Зелёный</option><option value="violet">Фиолетовый</option><option value="orange">Оранжевый</option><option value="red">Красный</option><option value="pink">Розовый</option><option value="lime">Лайм</option><option value="indigo">Индиго</option><option value="teal">Бирюза</option><option value="gold">Золото</option><option value="sky">Небесный</option><option value="coral">Коралл</option><option value="custom">Свой цвет</option>
|
|
|
</select>
|
|
|
</section>
|
|
|
</div>
|
|
|
<button id="logsToggle" class="header-command" type="button" title="Логи" aria-label="Открыть логи">≡</button>
|
|
|
</header>
|
|
|
<main>
|
|
|
<div id="streamTab" class="tab-page stream-tab">
|
|
|
<div class="video">
|
|
|
<div class="quickbar">
|
|
|
<select id="scenarioPreset" aria-label="Сценарий">
|
|
|
<option value="">ручная настройка</option>
|
|
|
<option value="hdmi_1080_60">HDMI Full HD 60 FPS</option>
|
|
|
<option value="hdmi_1080_50">HDMI Full HD 50 FPS</option>
|
|
|
<option value="hdmi_1080_30">HDMI Full HD 30 FPS</option>
|
|
|
<option value="hdmi_720_60">HDMI HD 60 FPS</option>
|
|
|
<option value="hdmi_720_50">HDMI HD 50 FPS</option>
|
|
|
<option value="hdmi_720_30">HDMI HD 30 FPS</option>
|
|
|
<option value="hdmi_pal_50">HDMI PAL 50 FPS</option>
|
|
|
<option value="file_realtime_hd">Готовое видео: realtime HD</option>
|
|
|
<option value="file_realtime_pal">Готовое видео: realtime PAL</option>
|
|
|
<option value="file_fast_1080">Готовое видео: быстрый 1080</option>
|
|
|
<option value="file_fast_hd">Готовое видео: быстрый HD</option>
|
|
|
<option value="file_fast_pal">Готовое видео: быстрый PAL</option>
|
|
|
<option value="udp_raw_gray8_40404">UDP Raw Gray8 · 512x640 · 50 FPS · :40404</option>
|
|
|
<option value="udp_raw_gray16_40404">UDP Raw Gray16 · 512x640 · 50 FPS · :40404</option>
|
|
|
<option value="udp_mik_59004">UDP МИК · порт 59004</option>
|
|
|
<option value="udp_mik_40404">UDP МИК · порт 40404</option>
|
|
|
<option value="udp_dump_fast">Файл UDP-лога МИК</option>
|
|
|
<option value="udp_delimited_file">Файл UDP-лога · кадры с разделителем</option>
|
|
|
<option value="low_load">Минимальная нагрузка</option>
|
|
|
<option value="test_no_record">Тест без записи</option>
|
|
|
</select>
|
|
|
<button id="startRun" class="command primary" type="button">Старт</button>
|
|
|
<button id="stopRun" class="command danger" type="button" hidden>Стоп</button>
|
|
|
<span id="quickSummary" class="quick-summary"></span>
|
|
|
</div>
|
|
|
<div class="stage"><img id="frameFallback" alt=""><img id="frame" alt=""><div id="empty" class="empty">ожидание кадра...</div></div>
|
|
|
<div id="metrics" class="metricgrid"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div id="settingsTab" class="tab-page settings-tab" hidden>
|
|
|
<section class="settings-panel">
|
|
|
<h2>Настройки</h2>
|
|
|
<div class="config-toolbar" role="toolbar" aria-label="Управление блоками конфигурации">
|
|
|
<span class="config-toolbar-title">Блоки конфигурации</span>
|
|
|
<button id="configResetLayout" type="button">сбросить раскладку</button>
|
|
|
<button id="configRestoreToggle" type="button" aria-expanded="false">структура</button>
|
|
|
<button id="configDeleteSelected" class="command danger" type="button" disabled>удалить выбранные</button>
|
|
|
<label class="config-scale-control" title="Масштаб элементов и текста выбранного блока: 50–200%"><span id="configScaleLabel">масштаб блока</span><input id="configContentScale" type="range" min="50" max="200" step="5" value="100" aria-label="Масштаб содержимого выбранного блока, от 50 до 200 процентов" disabled><output id="configContentScaleValue">100%</output></label>
|
|
|
<span id="configLayoutState" class="config-layout-state">Ctrl/Cmd + клик — выбрать несколько · перетаскивайте группу</span>
|
|
|
</div>
|
|
|
<div id="configWorkspace" class="config-workspace">
|
|
|
<div id="configRestorePanel" class="config-restore-panel" hidden>
|
|
|
<span class="config-restore-title">Структура canvas</span>
|
|
|
<div class="config-structure-create">
|
|
|
<label>Шаблон
|
|
|
<select id="configTemplateSelect">
|
|
|
<option value="source">Источник</option>
|
|
|
<option value="processing">Обработка</option>
|
|
|
<option value="archive">Архив</option>
|
|
|
<option value="advanced">Выход ошибки и калибровка</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>Название нового блока
|
|
|
<input id="configBlockTitle" type="text" maxlength="48" placeholder="Мой блок">
|
|
|
</label>
|
|
|
<button id="configCreateBlock" type="button">создать блок</button>
|
|
|
</div>
|
|
|
<span class="config-structure-hint">Клик фокусирует блок. Ctrl/Cmd + клик выбирает несколько. Перетаскивайте группу на canvas или в корзину.</span>
|
|
|
<div id="configRestoreList" class="config-restore-list"></div>
|
|
|
<button id="configDeletedToggle" class="config-deleted-toggle" type="button" hidden>корзина</button>
|
|
|
<div id="configDeletedList" class="config-deleted-list" hidden></div>
|
|
|
</div>
|
|
|
<form id="controlForm" class="control-form config-canvas">
|
|
|
<details class="settings-group source-group" data-section="source" open>
|
|
|
<summary>Источник</summary>
|
|
|
<label class="wide">Режим
|
|
|
<select id="sourceMode">
|
|
|
<option value="camera">HDMI по USB</option>
|
|
|
<option value="file">Готовое видео</option>
|
|
|
<option value="udp_delimited_live">Камера UDP — кадры с разделительным байтом</option>
|
|
|
<option value="udp_mik_live">Камера UDP МИК — протокол документа</option>
|
|
|
<option value="udp_custom_live">Камера UDP — пользовательский пакет</option>
|
|
|
<option value="udp_dump">Файл UDP-лога МИК (пакеты 59004)</option>
|
|
|
<option value="udp_delimited_file">Файл UDP-лога — кадры с разделительным байтом</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<div id="cameraFields" class="control-fields">
|
|
|
<label id="cameraIndexLabel">USB индекс Windows<input id="cameraIndex" type="number" min="0" step="1" value="0"></label>
|
|
|
<label>Профиль размера
|
|
|
<select id="quality">
|
|
|
<option value="3840x2160">4K 3840x2160</option>
|
|
|
<option value="2560x1440">QHD 2560x1440</option>
|
|
|
<option value="1920x1080">Full HD 1920x1080</option>
|
|
|
<option value="1280x720" selected>HD 1280x720</option>
|
|
|
<option value="1024x768">XGA 1024x768</option>
|
|
|
<option value="720x576">PAL 720x576</option>
|
|
|
<option value="640x480">VGA 640x480</option>
|
|
|
<option value="512x640">UDP-камера 512x640</option>
|
|
|
<option value="custom">Произвольный</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>Ширина, px<input id="frameWidth" type="number" min="16" max="8192" step="1" value="1280"></label>
|
|
|
<label>Высота, px<input id="frameHeight" type="number" min="16" max="8192" step="1" value="720"></label>
|
|
|
<label>FPS
|
|
|
<input id="fps" type="number" min="1" max="240" step="1" list="fpsSuggestions" value="30">
|
|
|
<datalist id="fpsSuggestions">
|
|
|
<option value="15"></option>
|
|
|
<option value="24"></option>
|
|
|
<option value="25"></option>
|
|
|
<option value="30"></option>
|
|
|
<option value="50"></option>
|
|
|
<option value="60"></option>
|
|
|
<option value="120"></option>
|
|
|
</datalist>
|
|
|
</label>
|
|
|
</div>
|
|
|
<div id="udpNetworkFields" class="control-fields" hidden>
|
|
|
<label>Адрес привязки (обычно 0.0.0.0)<input id="inputHost" type="text" value="0.0.0.0"></label>
|
|
|
<label>UDP-порт<input id="inputPort" type="number" min="1" max="65535" step="1" value="59004"></label>
|
|
|
<label>Способ разбора
|
|
|
<select id="packetPreset">
|
|
|
<option value="auto">Автоопределение</option>
|
|
|
<option value="mik">МИК по документу</option>
|
|
|
<option value="delimited">Кадры с разделителем</option>
|
|
|
<option value="custom">Пользовательский пакет</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<button id="probeUdp" class="compact" type="button">Проверить и применить</button>
|
|
|
<span id="udpProbeState" class="file-picked">ожидание проверки</span>
|
|
|
<div id="udpProbeResult" class="udp-probe-result" hidden>
|
|
|
<strong id="udpProbeTitle"></strong>
|
|
|
<pre id="udpProbeDetails"></pre>
|
|
|
<a id="udpProbeDownload" href="" download hidden>скачать точный дамп</a>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div id="delimiterFields" class="control-fields" hidden>
|
|
|
<label>Байт-разделитель<input id="separatorByte" type="number" min="0" max="255" step="1" value="0"></label>
|
|
|
<label>Формат кадра
|
|
|
<select id="frameEncoding">
|
|
|
<option value="auto">Авто: JPEG / PNG</option>
|
|
|
<option value="bgr24">Raw BGR24</option>
|
|
|
<option value="rgb24">Raw RGB24</option>
|
|
|
<option value="gray8">Raw Gray 8-bit</option>
|
|
|
<option value="gray16">Raw Gray 16-bit</option>
|
|
|
<option value="yuyv422">Raw YUYV 4:2:2</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
</div>
|
|
|
<details id="packetConstructorFields" class="packet-builder" hidden open>
|
|
|
<summary>Конструктор UDP-пакета</summary>
|
|
|
<div class="packet-builder-toolbar">
|
|
|
<strong id="packetHeaderSummary">Заголовок: 8 байт</strong>
|
|
|
<span id="packetLayoutStatus" class="packet-layout-status"></span>
|
|
|
<button id="addPacketField" type="button">+ байт</button>
|
|
|
<button id="clearPacketLayout" type="button">очистить</button>
|
|
|
</div>
|
|
|
<div id="packetByteMap" class="packet-byte-map"></div>
|
|
|
<div id="packetFieldList" class="packet-field-list"></div>
|
|
|
<div class="packet-grid">
|
|
|
<label>Сборка кадра
|
|
|
<select id="packetAssembly">
|
|
|
<option value="fragmented">Из фрагментов UDP</option>
|
|
|
<option value="datagram">Один датаграмм = кадр</option>
|
|
|
<option value="stream">Поток до размера / разделителя</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>Содержимое после сборки
|
|
|
<select id="packetPayloadFormat">
|
|
|
<option value="frame">JPEG / PNG / Raw кадр</option>
|
|
|
<option value="mik">Массив видео МИК</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>Размер заголовка, байт<input id="packetHeaderSize" type="number" value="8" readonly></label>
|
|
|
<label>Порядок байтов
|
|
|
<select id="packetByteOrder">
|
|
|
<option value="little">Little-endian</option>
|
|
|
<option value="big">Big-endian</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>Маска начала<input id="packetStartMask" type="text" value="0x02"></label>
|
|
|
<label>Маска конца<input id="packetEndMask" type="text" value="0x01"></label>
|
|
|
<label>Значение value
|
|
|
<select id="packetValueMode">
|
|
|
<option value="total_then_offset">Размер в start, затем смещение</option>
|
|
|
<option value="total_size">Полный размер в каждом пакете</option>
|
|
|
<option value="offset">Смещение данных</option>
|
|
|
<option value="unused">Не использовать</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
</div>
|
|
|
</details>
|
|
|
<div id="fileFields" class="control-fields">
|
|
|
<label><span id="inputListLabel">Видео из папки</span>
|
|
|
<select id="inputVideoList">
|
|
|
<option value="">поиск...</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<button id="refreshInputs" class="compact" type="button">обновить</button>
|
|
|
<label class="wide"><span id="filePathLabel">Путь к видео</span><input id="filePath" type="text" value=""></label>
|
|
|
<input id="videoUpload" type="file" hidden>
|
|
|
<button id="browseVideo" type="button">обзор...</button>
|
|
|
<span id="selectedUpload" class="file-picked">файл не выбран</span>
|
|
|
<div id="dropZone" class="drop-zone">перетащите видео сюда</div>
|
|
|
<button id="uploadRunVideo" class="wide" type="button">загрузить выбранное видео</button>
|
|
|
<progress id="uploadProgress" class="upload-progress" max="100" value="0" hidden></progress>
|
|
|
<button id="cancelUpload" class="wide" type="button" hidden>отменить загрузку</button>
|
|
|
</div>
|
|
|
</details>
|
|
|
<details class="settings-group" data-section="processing" open>
|
|
|
<summary>Обработка</summary>
|
|
|
<label>Прогон
|
|
|
<select id="runMode">
|
|
|
<option value="realtime">реальное время</option>
|
|
|
<option value="fast">быстро</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>Геометрия обработки
|
|
|
<select id="frameMode">
|
|
|
<option value="hd">как у источника</option>
|
|
|
<option value="pal">принудительно PAL 720x576</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
</details>
|
|
|
<details class="settings-group" data-section="archive" open>
|
|
|
<summary>Архив</summary>
|
|
|
<label>Архив<input id="saveRecord" type="checkbox" checked></label>
|
|
|
<label>Режим архива
|
|
|
<select id="archiveMode">
|
|
|
<option value="fragments">фрагменты детекции</option>
|
|
|
<option value="full">всё видео</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>Окно, сек<input id="fragmentGapSec" type="number" min="0" max="3600" step="1" value="15"></label>
|
|
|
</details>
|
|
|
<details class="settings-advanced" data-section="advanced">
|
|
|
<summary>Выход ошибки и калибровка</summary>
|
|
|
<div class="advanced-grid">
|
|
|
<label>Передача детекции<input id="errorOutput" type="checkbox" checked></label>
|
|
|
<label>Протокол
|
|
|
<select id="errorProtocol">
|
|
|
<option value="guidance_v1">Наведение v1 UDP</option>
|
|
|
<option value="json">JSON UDP</option>
|
|
|
<option value="csv">CSV UDP</option>
|
|
|
<option value="bin">BIN UDP</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>Единицы
|
|
|
<select id="errorUnits">
|
|
|
<option value="px">px</option>
|
|
|
<option value="norm">norm</option>
|
|
|
<option value="deg">deg</option>
|
|
|
<option value="m">m</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>ИП-адрес получателя<input id="errorHost" type="text" inputmode="decimal" placeholder="192.168.1.10" value=""></label>
|
|
|
<label>Порт<input id="errorPort" type="number" min="1" max="65535" step="1" value="5010"></label>
|
|
|
<label>Номер объекта<input id="errorObjectId" type="number" min="1" max="255" step="1" value="1"></label>
|
|
|
<label>HFOV<input id="errorHfov" type="number" min="1" max="179" step="0.1" value="90"></label>
|
|
|
<label>VFOV<input id="errorVfov" type="number" min="1" max="179" step="0.1" value="60"></label>
|
|
|
<label>Дальность, м<input id="errorRangeM" type="number" min="0" step="0.1" value="0"></label>
|
|
|
</div>
|
|
|
</details>
|
|
|
</form>
|
|
|
</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
<div id="modelTab" class="tab-page model-tab" hidden>
|
|
|
<section class="model-panel network-panel">
|
|
|
<div class="config-toolbar network-toolbar">
|
|
|
<span class="config-toolbar-title">Блоки сети</span>
|
|
|
<button id="networkResetLayout" type="button">сбросить раскладку</button>
|
|
|
<button id="networkStructureToggle" type="button" aria-expanded="false">структура</button>
|
|
|
<button id="networkDeleteSelected" class="command danger" type="button" disabled>удалить выбранные</button>
|
|
|
<label class="config-scale-control" title="Масштаб элементов и текста выбранного сетевого блока"><span>масштаб блока</span><input id="networkContentScale" type="range" min="50" max="200" step="5" value="100" aria-label="Масштаб содержимого выбранного сетевого блока" disabled><output id="networkContentScaleValue">100%</output></label>
|
|
|
<span id="networkLayoutState" class="config-layout-state">Ctrl/Cmd + клик — выбрать несколько · перетаскивайте группу</span>
|
|
|
</div>
|
|
|
<div id="networkWorkspace" class="network-workspace">
|
|
|
<aside id="networkRestorePanel" class="config-restore-panel network-restore-panel" hidden>
|
|
|
<span class="config-restore-title">Структура сети</span>
|
|
|
<div class="config-structure-create">
|
|
|
<label>Шаблон<select id="networkTemplateSelect"><option value="model">Модель</option><option value="model-params">Параметры</option></select></label>
|
|
|
<label>Название<input id="networkBlockTitle" type="text" maxlength="48" placeholder="Мой блок"></label>
|
|
|
<button id="networkCreateBlock" type="button">создать блок</button>
|
|
|
</div>
|
|
|
<div id="networkRestoreList" class="config-restore-list"></div>
|
|
|
</aside>
|
|
|
<div id="networkCanvas" class="model-grid network-canvas">
|
|
|
<section class="model-group settings-group network-block" data-section="model">
|
|
|
<div class="network-summary">Модель</div>
|
|
|
<label class="model-wide">Файл весов
|
|
|
<select id="modelSelect"><option value="">загрузка списка...</option></select>
|
|
|
</label>
|
|
|
<div class="model-actions">
|
|
|
<button id="modelApply" class="command primary" type="button">Применить</button>
|
|
|
<button id="modelBrowse" type="button">Добавить .pt</button>
|
|
|
<input id="modelUpload" type="file" accept=".pt" hidden>
|
|
|
<span id="modelState" class="model-state">модель не выбрана</span>
|
|
|
</div>
|
|
|
<label class="model-wide">Путь<input id="modelPath" type="text" readonly></label>
|
|
|
<div class="netron-section network-model-netron">
|
|
|
<div class="network-summary">Визуализация сети</div>
|
|
|
<div class="netron-toolbar">
|
|
|
<button id="modelNetron" class="command primary" type="button">Показать в Netron</button>
|
|
|
<span id="netronState" class="netron-state">граф Netron не загружен</span>
|
|
|
</div>
|
|
|
<div id="netronPanel" class="netron-panel" hidden>
|
|
|
<iframe id="netronFrame" class="netron-frame" title="Визуализация архитектуры сети в Netron" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe>
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
<section class="model-group settings-group network-block" data-section="model-params">
|
|
|
<div class="network-summary">Параметры до инференса</div>
|
|
|
<label>Устройство
|
|
|
<select id="modelDevice">
|
|
|
<option value="-1">CPU</option>
|
|
|
<option value="0">GPU 0</option>
|
|
|
<option value="1">GPU 1</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label>FP16<input id="modelHalf" type="checkbox" checked></label>
|
|
|
<label>Порог confidence<input id="modelConf" type="number" min="0.01" max="1" step="0.01" value="0.25"></label>
|
|
|
<label>Размер ROI<input id="modelRoi" type="number" min="128" max="4096" step="32" value="640"></label>
|
|
|
<label>Размер полного кадра<input id="modelFull" type="number" min="128" max="4096" step="32" value="1280"></label>
|
|
|
<label>Макс. детекций<input id="modelMaxDet" type="number" min="1" max="300" step="1" value="60"></label>
|
|
|
<span class="model-state model-wide">Параметры применяются при следующем старте.</span>
|
|
|
</section>
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
<div id="archiveTab" class="tab-page archive-tab" hidden>
|
|
|
<section class="archive-panel">
|
|
|
<h2>Архив</h2>
|
|
|
<div class="archive-tools">
|
|
|
<input id="archiveNameFilter" type="search" placeholder="часть названия">
|
|
|
<input id="archiveDateFrom" type="date" title="дата с">
|
|
|
<input id="archiveDateTo" type="date" title="дата по">
|
|
|
<button id="archiveReset" type="button">сброс</button>
|
|
|
<span id="archiveCount" class="archive-count">0</span>
|
|
|
<button id="archiveSelectVisible" type="button">выбрать</button>
|
|
|
<button id="archiveDeleteSelected" class="danger" type="button" disabled>удалить выбранные</button>
|
|
|
</div>
|
|
|
<div class="archive"><div id="archiveList"></div></div>
|
|
|
</section>
|
|
|
</div>
|
|
|
</main>
|
|
|
<div id="configTrash" class="config-trash" role="button" tabindex="0" aria-label="Перетащите блок сюда для удаления" hidden>
|
|
|
<svg class="config-trash-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14M9 7V4h6v3m-8 0 1 13h6l1-13M10 10v7m4-7v7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
|
<span>отпустите здесь, чтобы удалить блок</span>
|
|
|
</div>
|
|
|
<div id="networkTrash" class="config-trash network-trash" role="button" tabindex="0" aria-label="Перетащите сетевой блок сюда для удаления" hidden>
|
|
|
<svg class="config-trash-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14M9 7V4h6v3m-8 0 1 13h6l1-13M10 10v7m4-7v7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
|
<span>отпустите здесь, чтобы удалить блок</span>
|
|
|
</div>
|
|
|
<div id="logDrawer" class="log-drawer" hidden>
|
|
|
<div class="log-head">
|
|
|
<span>Логи</span>
|
|
|
<button id="logsClose" class="log-close" type="button" aria-label="Закрыть">×</button>
|
|
|
</div>
|
|
|
<pre id="logs"></pre>
|
|
|
</div>
|
|
|
<div id="playerOverlay" class="player-overlay" hidden>
|
|
|
<div class="player-box" role="dialog" aria-modal="true" aria-labelledby="playerTitle">
|
|
|
<div class="player-head">
|
|
|
<span id="playerTitle" class="player-title"></span>
|
|
|
<button id="playerClose" class="player-close" type="button" aria-label="Закрыть">×</button>
|
|
|
</div>
|
|
|
<video id="archivePlayer" controls preload="metadata"></video>
|
|
|
<div id="playerMessage" class="player-message" hidden></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<script>
|
|
|
const $ = id => document.getElementById(id);
|
|
|
let haveFrame = false;
|
|
|
let lastFrameId = null;
|
|
|
let lastFrameAt = null;
|
|
|
let lastFps = '-';
|
|
|
let playerRequestId = 0;
|
|
|
let frameTimer = null;
|
|
|
let fallbackLoading = false;
|
|
|
let streamAttached = false;
|
|
|
let lastGoodFrameAt = 0;
|
|
|
let frameMissingSince = 0;
|
|
|
let streamRunning = false;
|
|
|
let archiveKey = '';
|
|
|
let archiveRenderKey = '';
|
|
|
let archiveFiles = [];
|
|
|
let inputFilesKey = '';
|
|
|
let selectedUploadFile = null;
|
|
|
let activeUpload = null;
|
|
|
let udpProbeActive = false;
|
|
|
let controlLoaded = false;
|
|
|
let controlDirty = false;
|
|
|
let packetLayout = [];
|
|
|
const selectedArchive = new Set();
|
|
|
const metricNodes = new Map();
|
|
|
const metricValues = new Map();
|
|
|
const fmt = (v, n = 2) => v == null ? '-' : Number(v).toFixed(n);
|
|
|
const mag = (x, y, n = 2) => Math.hypot(Number(x || 0), Number(y || 0)).toFixed(n);
|
|
|
const mb = bytes => `${(Number(bytes || 0) / 1048576).toFixed(1)} MB`;
|
|
|
const enc = name => encodeURIComponent(name);
|
|
|
const esc = value => String(value).replace(/[&<>"']/g, ch => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[ch]));
|
|
|
const SCENARIOS = {
|
|
|
hdmi_1080_60: {sourceMode: 'camera', cameraIndex: '0', quality: '1920x1080', fps: '60', runMode: 'realtime', frameMode: 'hd', save: true},
|
|
|
hdmi_1080_50: {sourceMode: 'camera', cameraIndex: '0', quality: '1920x1080', fps: '50', runMode: 'realtime', frameMode: 'hd', save: true},
|
|
|
hdmi_1080_30: {sourceMode: 'camera', cameraIndex: '0', quality: '1920x1080', fps: '30', runMode: 'realtime', frameMode: 'hd', save: true},
|
|
|
hdmi_720_60: {sourceMode: 'camera', cameraIndex: '0', quality: '1280x720', fps: '60', runMode: 'realtime', frameMode: 'hd', save: true},
|
|
|
hdmi_720_50: {sourceMode: 'camera', cameraIndex: '0', quality: '1280x720', fps: '50', runMode: 'realtime', frameMode: 'hd', save: true},
|
|
|
hdmi_720_30: {sourceMode: 'camera', cameraIndex: '0', quality: '1280x720', fps: '30', runMode: 'realtime', frameMode: 'hd', save: true},
|
|
|
hdmi_pal_50: {sourceMode: 'camera', cameraIndex: '0', quality: '720x576', fps: '50', runMode: 'realtime', frameMode: 'pal', save: true},
|
|
|
file_realtime_hd: {sourceMode: 'file', quality: '1280x720', fps: '30', runMode: 'realtime', frameMode: 'hd', save: true},
|
|
|
file_realtime_pal: {sourceMode: 'file', quality: '720x576', fps: '25', runMode: 'realtime', frameMode: 'pal', save: true},
|
|
|
file_fast_1080: {sourceMode: 'file', quality: '1920x1080', fps: '30', runMode: 'fast', frameMode: 'hd', save: true},
|
|
|
file_fast_hd: {sourceMode: 'file', quality: '1280x720', fps: '30', runMode: 'fast', frameMode: 'hd', save: true},
|
|
|
file_fast_pal: {sourceMode: 'file', quality: '720x576', fps: '25', runMode: 'fast', frameMode: 'pal', save: true},
|
|
|
udp_dump_fast: {sourceMode: 'udp_dump', quality: '1280x720', fps: '30', runMode: 'fast', frameMode: 'hd', save: true, archiveMode: 'full'},
|
|
|
udp_mik_59004: {sourceMode: 'udp_mik_live', quality: '1280x720', fps: '30', runMode: 'realtime', frameMode: 'hd', save: true, inputPort: '59004'},
|
|
|
udp_mik_40404: {sourceMode: 'udp_mik_live', quality: '1280x720', fps: '30', runMode: 'realtime', frameMode: 'hd', save: true, inputPort: '40404'},
|
|
|
udp_raw_gray8_40404: {sourceMode: 'udp_delimited_live', quality: '512x640', fps: '50', runMode: 'realtime', frameMode: 'hd', save: true, inputPort: '40404', separatorByte: '0', frameEncoding: 'gray8'},
|
|
|
udp_raw_gray16_40404: {sourceMode: 'udp_delimited_live', quality: '512x640', fps: '50', runMode: 'realtime', frameMode: 'hd', save: true, inputPort: '40404', separatorByte: '0', frameEncoding: 'gray16'},
|
|
|
udp_delimited_file: {sourceMode: 'udp_delimited_file', quality: '1280x720', fps: '30', runMode: 'realtime', frameMode: 'hd', save: true, separatorByte: '0', frameEncoding: 'auto', archiveMode: 'full'},
|
|
|
low_load: {sourceMode: 'camera', quality: '640x480', fps: '25', runMode: 'realtime', frameMode: 'pal', save: true},
|
|
|
test_no_record: {sourceMode: 'file', quality: '1280x720', fps: '30', runMode: 'fast', frameMode: 'hd', save: false}
|
|
|
};
|
|
|
const DEFAULT_PACKET_SCHEMA = {
|
|
|
assembly: 'fragmented',
|
|
|
payload_format: 'frame',
|
|
|
header_size: 8,
|
|
|
byte_order: 'little',
|
|
|
flags_offset: 1,
|
|
|
flags_size: 1,
|
|
|
start_mask: 2,
|
|
|
end_mask: 1,
|
|
|
sequence_offset: 2,
|
|
|
sequence_size: 1,
|
|
|
packet_number_offset: 3,
|
|
|
packet_number_size: 1,
|
|
|
value_offset: 4,
|
|
|
value_size: 4,
|
|
|
value_mode: 'total_then_offset',
|
|
|
read_fields: []
|
|
|
};
|
|
|
const DEFAULT_PACKET_LAYOUT = [
|
|
|
{role: 'skip', size: 1, label: 'Версия'},
|
|
|
{role: 'flags', size: 1, label: 'Flags'},
|
|
|
{role: 'sequence', size: 1, label: 'Sequence'},
|
|
|
{role: 'packet_number', size: 1, label: 'Пакет'},
|
|
|
{role: 'value', size: 4, label: 'Размер / offset'}
|
|
|
];
|
|
|
const PACKET_ROLES = {
|
|
|
skip: {label: 'Не читать', short: 'SKIP'},
|
|
|
field: {label: 'Читать как число', short: 'FIELD'},
|
|
|
flags: {label: 'Flags: начало / конец', short: 'FLAGS'},
|
|
|
sequence: {label: 'Sequence / номер кадра', short: 'SEQ'},
|
|
|
packet_number: {label: 'Номер фрагмента', short: 'PKT'},
|
|
|
value: {label: 'Размер / смещение', short: 'VALUE'}
|
|
|
};
|
|
|
const archiveDate = seconds => {
|
|
|
const d = new Date(Number(seconds || 0) * 1000);
|
|
|
const y = d.getFullYear();
|
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
|
return `${y}-${m}-${day}`;
|
|
|
};
|
|
|
function setText(id, value) {
|
|
|
const node = $(id);
|
|
|
const text = value == null ? '' : String(value);
|
|
|
if (node.textContent !== text) node.textContent = text;
|
|
|
}
|
|
|
function setValue(id, value) {
|
|
|
const node = $(id);
|
|
|
const text = value == null ? '' : String(value);
|
|
|
if (node.value !== text) node.value = text;
|
|
|
}
|
|
|
function showFormBlock(id, visible) {
|
|
|
const node = $(id);
|
|
|
if (!node) return;
|
|
|
if (node._visibilityTimer) {
|
|
|
clearTimeout(node._visibilityTimer);
|
|
|
node._visibilityTimer = null;
|
|
|
}
|
|
|
const closing = node.classList.contains('form-exit');
|
|
|
if (visible) {
|
|
|
const wasHidden = node.hidden;
|
|
|
node.hidden = false;
|
|
|
node.removeAttribute('aria-hidden');
|
|
|
if (wasHidden || closing) {
|
|
|
node.classList.remove('form-exit', 'form-enter');
|
|
|
void node.offsetWidth;
|
|
|
node.classList.add('form-enter');
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
if (node.hidden || closing) return;
|
|
|
if (node.contains(document.activeElement)) document.activeElement.blur();
|
|
|
node.setAttribute('aria-hidden', 'true');
|
|
|
node.classList.remove('form-enter');
|
|
|
node.classList.add('form-exit');
|
|
|
const finish = () => {
|
|
|
node.hidden = true;
|
|
|
node.classList.remove('form-exit');
|
|
|
node.removeAttribute('aria-hidden');
|
|
|
node._visibilityTimer = null;
|
|
|
if (typeof updateConfigCanvasHeight === 'function') updateConfigCanvasHeight();
|
|
|
};
|
|
|
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) finish();
|
|
|
else node._visibilityTimer = window.setTimeout(finish, 180);
|
|
|
}
|
|
|
function initCollapsibleSections() {
|
|
|
let state = {};
|
|
|
try {
|
|
|
const saved = JSON.parse(localStorage.getItem('fpv-open-sections') || '{}');
|
|
|
if (saved && typeof saved === 'object') state = saved;
|
|
|
} catch (_error) {}
|
|
|
document.querySelectorAll('details[data-section]').forEach(section => {
|
|
|
if (section.closest('#controlForm')) {
|
|
|
section.open = true;
|
|
|
return;
|
|
|
}
|
|
|
const key = section.dataset.section;
|
|
|
const summary = section.querySelector('summary');
|
|
|
if (state[key] === false) section.open = false;
|
|
|
if (summary) summary.setAttribute('aria-expanded', String(section.open));
|
|
|
section.addEventListener('toggle', () => {
|
|
|
if (summary) summary.setAttribute('aria-expanded', String(section.open));
|
|
|
state[key] = section.open;
|
|
|
try { localStorage.setItem('fpv-open-sections', JSON.stringify(state)); } catch (_error) {}
|
|
|
});
|
|
|
});
|
|
|
}
|
|
|
let configCanvas = null;
|
|
|
let configBlocks = [];
|
|
|
let configLayout = {};
|
|
|
let restorePanelOpen = false;
|
|
|
let deletedPanelOpen = false;
|
|
|
let configZIndex = 1;
|
|
|
let configContentScale = 1;
|
|
|
let configBlockObserver = null;
|
|
|
let selectedConfigBlock = null;
|
|
|
let selectedConfigBlocks = new Set();
|
|
|
function readConfigLayout() {
|
|
|
try {
|
|
|
const saved = JSON.parse(localStorage.getItem('fpv-config-layout-v1') || '{}');
|
|
|
return saved && typeof saved === 'object' ? saved : {};
|
|
|
} catch (_error) { return {}; }
|
|
|
}
|
|
|
function saveConfigLayout() {
|
|
|
try {
|
|
|
localStorage.setItem('fpv-config-layout-v1', JSON.stringify(configLayout));
|
|
|
if (configCanvas) localStorage.setItem('fpv-config-canvas-width', String(Math.round(configCanvasWidth())));
|
|
|
} catch (_error) {}
|
|
|
}
|
|
|
function configMinWidth() {
|
|
|
return window.innerWidth <= 760 ? 220 : 240;
|
|
|
}
|
|
|
function configCanvasWidth() {
|
|
|
return Math.max(configMinWidth(), (configCanvas?.clientWidth || 0) - 12);
|
|
|
}
|
|
|
function updateConfigBlockScale(block) {
|
|
|
if (!block) return;
|
|
|
const width = Math.max(configMinWidth(), block.clientWidth || block.offsetWidth || configMinWidth());
|
|
|
const height = Math.max(110, block.clientHeight || block.offsetHeight || 110);
|
|
|
const area = Math.max(.56, Math.min(1.85, (width / 720) * (height / 320)));
|
|
|
const entry = configLayout[block.dataset.section] || {};
|
|
|
const userScale = Number.isFinite(Number(block._configUserScale)) ? block._configUserScale : (Number(entry.scale) || 100) / 100;
|
|
|
block._configUserScale = Math.max(.5, Math.min(2, userScale));
|
|
|
const scale = Math.max(.5, Math.min(2, Math.sqrt(area) * block._configUserScale));
|
|
|
block.style.setProperty('--block-scale', scale.toFixed(3));
|
|
|
}
|
|
|
function applyConfigContentScale(value) {
|
|
|
configContentScale = Math.max(.5, Math.min(2, (Number(value) || 100) / 100));
|
|
|
const block = selectedConfigBlock;
|
|
|
if (!block || configBlockState(block)) return;
|
|
|
block._configUserScale = configContentScale;
|
|
|
const entry = configLayout[block.dataset.section] || {};
|
|
|
entry.scale = Math.round(configContentScale * 100);
|
|
|
configLayout[block.dataset.section] = entry;
|
|
|
setValue('configContentScale', Math.round(configContentScale * 100));
|
|
|
setText('configContentScaleValue', `${Math.round(configContentScale * 100)}%`);
|
|
|
updateConfigBlockScale(block);
|
|
|
saveConfigLayout();
|
|
|
}
|
|
|
function selectConfigBlock(block) {
|
|
|
selectedConfigBlocks = new Set(block && !configBlockState(block) ? [block] : []);
|
|
|
updateConfigSelection(block);
|
|
|
}
|
|
|
function updateConfigSelection(preferred = selectedConfigBlock) {
|
|
|
selectedConfigBlocks = new Set([...selectedConfigBlocks].filter(block => configBlocks.includes(block) && !configBlockState(block)));
|
|
|
selectedConfigBlock = selectedConfigBlocks.has(preferred) ? preferred : [...selectedConfigBlocks].at(-1) || null;
|
|
|
configBlocks.forEach(other => other.classList.toggle('is-selected', selectedConfigBlocks.has(other)));
|
|
|
$('configRestorePanel')?.querySelectorAll('.config-restore-item').forEach(item => item.classList.toggle('is-selected', selectedConfigBlocks.has(item._configBlock)));
|
|
|
const control = $('configContentScale');
|
|
|
const remove = $('configDeleteSelected');
|
|
|
if (!selectedConfigBlock) {
|
|
|
if (control) control.disabled = true;
|
|
|
if (remove) remove.disabled = true;
|
|
|
return;
|
|
|
}
|
|
|
const entry = configLayout[selectedConfigBlock.dataset.section] || {};
|
|
|
const value = Math.round((Number(entry.scale) || 100));
|
|
|
configContentScale = Math.max(.5, Math.min(2, value / 100));
|
|
|
setValue('configContentScale', value);
|
|
|
setText('configContentScaleValue', `${value}%`);
|
|
|
if (control) control.disabled = selectedConfigBlocks.size !== 1;
|
|
|
if (remove) remove.disabled = !selectedConfigBlocks.size;
|
|
|
updateConfigBlockScale(selectedConfigBlock);
|
|
|
if (selectedConfigBlocks.size > 1) setText('configLayoutState', `выбрано ${selectedConfigBlocks.size} блока · перетаскивайте группу`);
|
|
|
}
|
|
|
function toggleConfigBlockSelection(block, additive = false) {
|
|
|
if (!block || configBlockState(block)) return;
|
|
|
if (!additive) return selectConfigBlock(block);
|
|
|
if (selectedConfigBlocks.has(block)) selectedConfigBlocks.delete(block);
|
|
|
else selectedConfigBlocks.add(block);
|
|
|
updateConfigSelection(block);
|
|
|
}
|
|
|
function configTitle(block) {
|
|
|
return block.querySelector(':scope > summary .block-title')?.textContent || block.dataset.section || 'Блок';
|
|
|
}
|
|
|
function configBlockState(block) {
|
|
|
return block.dataset.blockState || '';
|
|
|
}
|
|
|
function configTemplateKey(block) {
|
|
|
return block.dataset.custom === 'true' ? (block.dataset.template || 'source') : block.dataset.section;
|
|
|
}
|
|
|
function updateConfigCanvasHeight() {
|
|
|
if (!configCanvas) return;
|
|
|
const bottom = configBlocks.reduce((value, block) => {
|
|
|
if (configBlockState(block)) return value;
|
|
|
return Math.max(value, (block.offsetTop || 0) + block.offsetHeight);
|
|
|
}, 0);
|
|
|
configCanvas.style.minHeight = `${Math.max(window.innerWidth <= 760 ? 900 : 760, bottom + 28)}px`;
|
|
|
}
|
|
|
function fitConfigBlockContent(block) {
|
|
|
if (!block || configBlockState(block) || block._configManualHeight) return false;
|
|
|
const entry = configLayout[block.dataset.section];
|
|
|
if (block.style.height && block.style.height !== 'auto') {
|
|
|
block.style.height = 'auto';
|
|
|
if (entry) { delete entry.height; delete entry.manualHeight; }
|
|
|
return true;
|
|
|
}
|
|
|
const contentHeight = block.scrollHeight;
|
|
|
if (!contentHeight || block.offsetHeight <= contentHeight + 120) return false;
|
|
|
block.style.height = 'auto';
|
|
|
if (entry && Object.prototype.hasOwnProperty.call(entry, 'height')) {
|
|
|
delete entry.height;
|
|
|
delete entry.manualHeight;
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
function captureConfigLayout(block) {
|
|
|
const key = block.dataset.section;
|
|
|
if (!key) return;
|
|
|
const entry = configLayout[key] || {};
|
|
|
entry.left = Math.round(parseFloat(block.style.left) || block.offsetLeft || 0);
|
|
|
entry.top = Math.round(parseFloat(block.style.top) || block.offsetTop || 0);
|
|
|
entry.width = Math.round(block.offsetWidth);
|
|
|
if (block.style.height && block.style.height !== 'auto') entry.height = Math.round(block.offsetHeight);
|
|
|
else delete entry.height;
|
|
|
if (Number.isFinite(Number(block._configUserScale)) && Math.abs(block._configUserScale - 1) > .001) entry.scale = Math.round(block._configUserScale * 100);
|
|
|
else delete entry.scale;
|
|
|
if (block.style.zIndex) entry.z = Number(block.style.zIndex);
|
|
|
configLayout[key] = entry;
|
|
|
}
|
|
|
function placeConfigBlock(block, left, top, width) {
|
|
|
if (!block) return;
|
|
|
const canvasWidth = configCanvasWidth();
|
|
|
const minWidth = configMinWidth();
|
|
|
const safeLeft = Math.max(0, Math.round(Math.min(left, Math.max(0, canvasWidth - minWidth))));
|
|
|
const safeWidth = Math.max(minWidth, Math.min(canvasWidth - safeLeft, Math.round(width)));
|
|
|
block.style.left = `${safeLeft}px`;
|
|
|
block.style.top = `${Math.max(0, Math.round(top))}px`;
|
|
|
block.style.width = `${safeWidth}px`;
|
|
|
}
|
|
|
function applyStoredConfigLayout() {
|
|
|
if (!configCanvas) return;
|
|
|
const width = configCanvasWidth();
|
|
|
let previousWidth = 0;
|
|
|
try { previousWidth = Number(localStorage.getItem('fpv-config-canvas-width') || 0); } catch (_error) {}
|
|
|
if (!previousWidth) previousWidth = Object.values(configLayout).reduce((value, entry) => {
|
|
|
if (!entry || typeof entry !== 'object') return value;
|
|
|
const left = Number(entry.left); const blockWidth = Number(entry.width);
|
|
|
return Number.isFinite(left) && Number.isFinite(blockWidth) ? Math.max(value, left + blockWidth) : value;
|
|
|
}, 0);
|
|
|
if (previousWidth > 0 && width > configMinWidth() && Math.abs(width - previousWidth) > 80) {
|
|
|
const scale = width / previousWidth;
|
|
|
Object.values(configLayout).forEach(entry => {
|
|
|
if (!entry || typeof entry !== 'object') return;
|
|
|
if (Number.isFinite(Number(entry.left))) entry.left = Math.max(0, Math.round(Number(entry.left) * scale));
|
|
|
if (Number.isFinite(Number(entry.width))) entry.width = Math.max(configMinWidth(), Math.round(Number(entry.width) * scale));
|
|
|
});
|
|
|
saveConfigLayout();
|
|
|
}
|
|
|
const gap = 14;
|
|
|
const visible = block => block && !configBlockState(block);
|
|
|
const source = configBlocks.find(block => block.dataset.section === 'source' && visible(block));
|
|
|
const processing = configBlocks.find(block => block.dataset.section === 'processing' && visible(block));
|
|
|
const archive = configBlocks.find(block => block.dataset.section === 'archive' && visible(block));
|
|
|
const advanced = configBlocks.find(block => block.dataset.section === 'advanced' && visible(block));
|
|
|
const defaults = new Set();
|
|
|
const placeDefault = (block, left, top, blockWidth) => {
|
|
|
if (!block) return;
|
|
|
const entry = configLayout[block.dataset.section] || {};
|
|
|
if (!Number.isFinite(Number(entry.left)) || !Number.isFinite(Number(entry.top))) placeConfigBlock(block, left, top, blockWidth);
|
|
|
defaults.add(block);
|
|
|
};
|
|
|
let y = 8;
|
|
|
placeDefault(source, 0, y, width);
|
|
|
if (source) y += source.offsetHeight + gap;
|
|
|
const pair = [processing, archive].filter(Boolean);
|
|
|
if (pair.length === 2) {
|
|
|
const half = Math.max(configMinWidth(), (width - gap) / 2);
|
|
|
placeDefault(processing, 0, y, half);
|
|
|
placeDefault(archive, half + gap, y, half);
|
|
|
y += Math.max(processing.offsetHeight, archive.offsetHeight) + gap;
|
|
|
} else if (pair.length === 1) {
|
|
|
placeDefault(pair[0], 0, y, width);
|
|
|
y += pair[0].offsetHeight + gap;
|
|
|
}
|
|
|
placeDefault(advanced, 0, y, width);
|
|
|
if (advanced) y += advanced.offsetHeight + gap;
|
|
|
configBlocks.filter(block => visible(block) && !defaults.has(block)).forEach(block => {
|
|
|
const entry = configLayout[block.dataset.section] || {};
|
|
|
if (!Number.isFinite(Number(entry.left)) || !Number.isFinite(Number(entry.top))) {
|
|
|
placeConfigBlock(block, 12, y, Math.min(width, Math.max(configMinWidth(), width * .46)));
|
|
|
y += block.offsetHeight + gap;
|
|
|
}
|
|
|
});
|
|
|
configBlocks.forEach(block => {
|
|
|
const entry = configLayout[block.dataset.section];
|
|
|
if (!entry || configBlockState(block)) return;
|
|
|
const minWidth = configMinWidth();
|
|
|
const storedLeft = Number(entry.left);
|
|
|
const left = Number.isFinite(storedLeft) ? Math.max(0, Math.min(width - minWidth, storedLeft)) : null;
|
|
|
if (left != null) block.style.left = `${left}px`;
|
|
|
if (Number.isFinite(Number(entry.top))) block.style.top = `${Math.max(0, Number(entry.top))}px`;
|
|
|
if (Number.isFinite(Number(entry.width))) block.style.width = `${Math.max(minWidth, Math.min(width - (left || 0), Number(entry.width)))}px`;
|
|
|
if (Number.isFinite(Number(entry.height))) block.style.height = `${Math.max(110, Number(entry.height))}px`;
|
|
|
if (Number.isFinite(Number(entry.z))) { block.style.zIndex = String(Math.max(1, Number(entry.z))); configZIndex = Math.max(configZIndex, Number(entry.z)); }
|
|
|
});
|
|
|
let fitted = false;
|
|
|
configBlocks.forEach(block => { fitted = fitConfigBlockContent(block) || fitted; updateConfigBlockScale(block); });
|
|
|
if (fitted) saveConfigLayout();
|
|
|
updateConfigCanvasHeight();
|
|
|
}
|
|
|
function setConfigBlockStates(blocks, state) {
|
|
|
const items = [...new Set(blocks)].filter(block => block && configBlocks.includes(block));
|
|
|
if (!items.length) return;
|
|
|
items.forEach(block => {
|
|
|
if (state && block.contains(document.activeElement)) document.activeElement.blur();
|
|
|
const key = block.dataset.section;
|
|
|
if (key && state) configLayout[key] = {...(configLayout[key] || {}), state};
|
|
|
else if (key && configLayout[key]) {
|
|
|
delete configLayout[key].state;
|
|
|
if (!Object.keys(configLayout[key]).length) delete configLayout[key];
|
|
|
}
|
|
|
block.dataset.blockState = state || '';
|
|
|
block.classList.toggle('config-block-hidden', state === 'hidden');
|
|
|
block.classList.toggle('config-block-deleted', state === 'deleted');
|
|
|
if (state) block.setAttribute('aria-hidden', 'true');
|
|
|
else block.removeAttribute('aria-hidden');
|
|
|
});
|
|
|
saveConfigLayout();
|
|
|
applyStoredConfigLayout();
|
|
|
renderConfigRestorePanel();
|
|
|
selectConfigBlock(configBlocks.find(candidate => !configBlockState(candidate)) || null);
|
|
|
}
|
|
|
function setConfigBlockState(block, state) { setConfigBlockStates([block], state); }
|
|
|
function deleteSelectedConfigBlocks() {
|
|
|
const items = [...selectedConfigBlocks].filter(block => configBlocks.includes(block) && !configBlockState(block));
|
|
|
if (!items.length) return false;
|
|
|
setConfigBlockStates(items, 'deleted');
|
|
|
setText('configLayoutState', `${items.length} блок${items.length === 1 ? '' : 'а'} перемещён${items.length === 1 ? '' : 'ы'} в корзину`);
|
|
|
return true;
|
|
|
}
|
|
|
function focusConfigBlock(block, item, event) {
|
|
|
if (!block || configBlockState(block)) return;
|
|
|
if (event?.ctrlKey || event?.metaKey) {
|
|
|
toggleConfigBlockSelection(block, true);
|
|
|
item?.classList.toggle('is-selected', selectedConfigBlocks.has(block));
|
|
|
bringConfigBlockFront(block);
|
|
|
return;
|
|
|
}
|
|
|
configBlocks.forEach(other => other.classList.remove('is-selected'));
|
|
|
$('configRestorePanel')?.querySelectorAll('.config-restore-item.is-selected').forEach(node => node.classList.remove('is-selected'));
|
|
|
selectConfigBlock(block);
|
|
|
item?.classList.add('is-selected');
|
|
|
bringConfigBlockFront(block);
|
|
|
block.scrollIntoView?.({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
|
|
|
}
|
|
|
function renderConfigItem(list, block, deleted = false) {
|
|
|
const state = configBlockState(block);
|
|
|
const item = document.createElement('div');
|
|
|
item.className = `config-restore-item${state ? ' is-hidden' : ''}${selectedConfigBlocks.has(block) ? ' is-selected' : ''}`;
|
|
|
item._configBlock = block;
|
|
|
item.title = 'Нажмите, чтобы показать блок на canvas';
|
|
|
const label = document.createElement('span');
|
|
|
label.className = 'config-item-title';
|
|
|
label.textContent = configTitle(block);
|
|
|
const stateNode = document.createElement('span');
|
|
|
stateNode.className = 'config-item-state';
|
|
|
stateNode.textContent = deleted ? 'в корзине' : state === 'hidden' ? 'скрыт' : block.dataset.custom === 'true' ? 'свой' : 'активен';
|
|
|
const actions = document.createElement('span');
|
|
|
actions.className = 'config-item-actions';
|
|
|
const visibility = document.createElement('button');
|
|
|
visibility.type = 'button';
|
|
|
visibility.textContent = deleted || state ? 'вернуть' : 'скрыть';
|
|
|
visibility.setAttribute('aria-label', `${visibility.textContent} блок ${configTitle(block)}`);
|
|
|
visibility.addEventListener('click', event => {
|
|
|
event.stopPropagation();
|
|
|
setConfigBlockState(block, deleted || state ? '' : 'hidden');
|
|
|
});
|
|
|
const duplicate = document.createElement('button');
|
|
|
duplicate.type = 'button';
|
|
|
duplicate.textContent = 'дубль';
|
|
|
duplicate.setAttribute('aria-label', `создать копию блока ${configTitle(block)}`);
|
|
|
duplicate.addEventListener('click', event => {
|
|
|
event.stopPropagation();
|
|
|
duplicateConfigBlock(block);
|
|
|
});
|
|
|
actions.append(visibility, duplicate);
|
|
|
if (block.dataset.custom === 'true') {
|
|
|
const remove = document.createElement('button');
|
|
|
remove.type = 'button';
|
|
|
remove.textContent = 'удалить навсегда';
|
|
|
remove.title = 'Удалить блок без возможности восстановления';
|
|
|
remove.addEventListener('click', event => {
|
|
|
event.stopPropagation();
|
|
|
removeConfigBlockForever(block);
|
|
|
});
|
|
|
actions.appendChild(remove);
|
|
|
} else {
|
|
|
const remove = document.createElement('button');
|
|
|
remove.type = 'button';
|
|
|
remove.textContent = 'удалить';
|
|
|
remove.title = 'Убрать блок в корзину; его можно восстановить';
|
|
|
remove.addEventListener('click', event => {
|
|
|
event.stopPropagation();
|
|
|
setConfigBlockState(block, 'deleted');
|
|
|
});
|
|
|
actions.appendChild(remove);
|
|
|
}
|
|
|
item.append(label, stateNode, actions);
|
|
|
item.addEventListener('click', event => focusConfigBlock(block, item, event));
|
|
|
list.appendChild(item);
|
|
|
}
|
|
|
function renderConfigRestorePanel() {
|
|
|
const panel = $('configRestorePanel');
|
|
|
const list = $('configRestoreList');
|
|
|
const toggle = $('configRestoreToggle');
|
|
|
if (!panel || !list || !toggle) return;
|
|
|
const deleted = configBlocks.filter(block => configBlockState(block) === 'deleted');
|
|
|
const hidden = configBlocks.filter(block => configBlockState(block) === 'hidden');
|
|
|
const workspace = $('configWorkspace');
|
|
|
const deletedToggle = $('configDeletedToggle');
|
|
|
const deletedList = $('configDeletedList');
|
|
|
list.innerHTML = '';
|
|
|
configBlocks.filter(block => configBlockState(block) !== 'deleted').forEach(block => renderConfigItem(list, block));
|
|
|
if (deletedList) {
|
|
|
deletedList.innerHTML = '';
|
|
|
deleted.forEach(block => renderConfigItem(deletedList, block, true));
|
|
|
deletedList.hidden = !deletedPanelOpen || !deleted.length;
|
|
|
}
|
|
|
if (deletedToggle) {
|
|
|
deletedToggle.hidden = !deleted.length;
|
|
|
deletedToggle.textContent = deleted.length ? `корзина · ${deleted.length}` : 'корзина';
|
|
|
deletedToggle.setAttribute('aria-expanded', String(deletedPanelOpen && !!deleted.length));
|
|
|
}
|
|
|
toggle.disabled = false;
|
|
|
toggle.textContent = hidden.length ? `структура · ${hidden.length} скрыто` : 'структура';
|
|
|
toggle.setAttribute('aria-expanded', String(restorePanelOpen));
|
|
|
panel.hidden = !restorePanelOpen;
|
|
|
workspace?.classList.toggle('has-structure', restorePanelOpen);
|
|
|
const visible = configBlocks.length - hidden.length - deleted.length;
|
|
|
const selected = selectedConfigBlocks.size > 1 ? ` · выбрано ${selectedConfigBlocks.size}` : '';
|
|
|
setText('configLayoutState', `${visible} блоков · ${hidden.length} скрыто · ${deleted.length} в корзине${selected}`);
|
|
|
if (configCanvas) applyStoredConfigLayout();
|
|
|
}
|
|
|
function removeConfigBlockForever(block) {
|
|
|
if (!block || block.dataset.custom !== 'true') return;
|
|
|
if (block.contains(document.activeElement)) document.activeElement.blur();
|
|
|
const key = block.dataset.section;
|
|
|
block.remove();
|
|
|
configBlocks = configBlocks.filter(item => item !== block);
|
|
|
if (key) delete configLayout[key];
|
|
|
saveConfigLayout();
|
|
|
renderConfigRestorePanel();
|
|
|
selectConfigBlock(configBlocks.find(candidate => !configBlockState(candidate)) || null);
|
|
|
setText('configLayoutState', 'блок удалён навсегда');
|
|
|
}
|
|
|
function nextCustomConfigKey() {
|
|
|
let index = 1;
|
|
|
while (configBlocks.some(block => block.dataset.section === `custom-${index}`)) index += 1;
|
|
|
return `custom-${index}`;
|
|
|
}
|
|
|
function createCustomConfigBlock(templateKey, title, existingKey = '') {
|
|
|
const template = configBlocks.find(block => !block.dataset.custom && block.dataset.section === templateKey);
|
|
|
if (!template || !configCanvas) return null;
|
|
|
const key = existingKey || nextCustomConfigKey();
|
|
|
const clone = template.cloneNode(true);
|
|
|
clone.dataset.section = key;
|
|
|
clone.dataset.custom = 'true';
|
|
|
clone.dataset.template = templateKey;
|
|
|
clone.classList.add('config-block', 'config-custom-block');
|
|
|
clone.removeAttribute('id');
|
|
|
clone.querySelectorAll('[id]').forEach(node => node.removeAttribute('id'));
|
|
|
clone.querySelectorAll('[for]').forEach(node => node.removeAttribute('for'));
|
|
|
const summary = clone.querySelector(':scope > summary');
|
|
|
if (summary) {
|
|
|
summary.textContent = '';
|
|
|
const titleNode = document.createElement('span');
|
|
|
titleNode.className = 'block-title';
|
|
|
titleNode.textContent = title || `Новый блок ${key.replace('custom-', '')}`;
|
|
|
summary.appendChild(titleNode);
|
|
|
}
|
|
|
configCanvas.appendChild(clone);
|
|
|
configBlocks.push(clone);
|
|
|
configBlockObserver?.observe(clone);
|
|
|
const entry = configLayout[key] || {};
|
|
|
configLayout[key] = {...entry, custom: true, template: templateKey, title: titleNodeText(clone)};
|
|
|
return clone;
|
|
|
}
|
|
|
function titleNodeText(block) {
|
|
|
return block.querySelector(':scope > summary .block-title')?.textContent?.trim() || 'Новый блок';
|
|
|
}
|
|
|
function positionNewConfigBlock(block) {
|
|
|
const bottom = configBlocks.filter(item => item !== block && !configBlockState(item)).reduce((value, item) => Math.max(value, (item.offsetTop || 0) + item.offsetHeight), 0);
|
|
|
const width = configCanvasWidth();
|
|
|
block.style.left = '12px';
|
|
|
block.style.top = `${Math.max(12, bottom + 18)}px`;
|
|
|
block.style.width = `${Math.min(width, Math.max(configMinWidth(), Math.round(width * .46)))}px`;
|
|
|
updateConfigBlockScale(block);
|
|
|
}
|
|
|
function duplicateConfigBlock(block) {
|
|
|
const clone = createCustomConfigBlock(configTemplateKey(block), `${configTitle(block)} · копия`);
|
|
|
if (!clone) return;
|
|
|
makeConfigBlockInteractive(clone);
|
|
|
positionNewConfigBlock(clone);
|
|
|
bringConfigBlockFront(clone);
|
|
|
captureConfigLayout(clone);
|
|
|
saveConfigLayout();
|
|
|
renderConfigRestorePanel();
|
|
|
setText('configLayoutState', `создан блок «${configTitle(clone)}»`);
|
|
|
}
|
|
|
function bringConfigBlockFront(block) {
|
|
|
if (!block || configBlockState(block)) return;
|
|
|
block.style.zIndex = String(++configZIndex);
|
|
|
captureConfigLayout(block);
|
|
|
saveConfigLayout();
|
|
|
}
|
|
|
function configResizeEdge(event, block) {
|
|
|
const target = event.target;
|
|
|
if (target instanceof Element && target.closest('summary') === block.firstElementChild) return '';
|
|
|
const rect = block.getBoundingClientRect();
|
|
|
const edge = 14;
|
|
|
const left = event.clientX - rect.left <= edge;
|
|
|
const right = rect.right - event.clientX <= edge;
|
|
|
const top = event.clientY - rect.top <= edge;
|
|
|
const bottom = rect.bottom - event.clientY <= edge;
|
|
|
return `${top ? 'n' : bottom ? 's' : ''}${left ? 'w' : right ? 'e' : ''}`;
|
|
|
}
|
|
|
function trashDrop(event) {
|
|
|
const trash = $('configTrash');
|
|
|
if (!trash || trash.hidden || !event) return false;
|
|
|
const rect = trash.getBoundingClientRect();
|
|
|
return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
|
|
|
}
|
|
|
function setTrashHover(event) {
|
|
|
const trash = $('configTrash');
|
|
|
if (trash) trash.classList.toggle('is-over', trashDrop(event));
|
|
|
}
|
|
|
function showConfigTrash() {
|
|
|
const trash = $('configTrash');
|
|
|
if (!trash) return;
|
|
|
trash.hidden = false;
|
|
|
trash.classList.remove('is-over');
|
|
|
}
|
|
|
function finishConfigDrag(block, event, dragged, group = [block]) {
|
|
|
const trash = $('configTrash');
|
|
|
const droppedOnTrash = trashDrop(event);
|
|
|
if (trash) {
|
|
|
trash.classList.remove('is-over');
|
|
|
trash.hidden = true;
|
|
|
}
|
|
|
if (!dragged) return;
|
|
|
group.forEach(item => captureConfigLayout(item));
|
|
|
if (droppedOnTrash) setConfigBlockStates(group, 'deleted');
|
|
|
else {
|
|
|
saveConfigLayout();
|
|
|
setText('configLayoutState', `${configBlocks.length - configBlocks.filter(item => configBlockState(item)).length} блоков · раскладка сохранена`);
|
|
|
}
|
|
|
group.forEach(item => {
|
|
|
item._suppressSummaryClick = true;
|
|
|
window.setTimeout(() => { item._suppressSummaryClick = false; }, 0);
|
|
|
});
|
|
|
}
|
|
|
function makeConfigBlockInteractive(block) {
|
|
|
const summary = block.firstElementChild;
|
|
|
if (!summary || summary.tagName !== 'SUMMARY' || block._configInteractive) return;
|
|
|
block._configInteractive = true;
|
|
|
const title = summary.textContent.trim();
|
|
|
summary.textContent = '';
|
|
|
const titleNode = document.createElement('span');
|
|
|
titleNode.className = 'block-title';
|
|
|
titleNode.textContent = title;
|
|
|
summary.appendChild(titleNode);
|
|
|
summary.setAttribute('aria-label', `${title}. Перетаскивайте заголовок для перемещения`);
|
|
|
summary.setAttribute('aria-expanded', 'true');
|
|
|
summary.addEventListener('click', event => {
|
|
|
event.preventDefault();
|
|
|
if (block._suppressSummaryClick) {
|
|
|
event.stopPropagation();
|
|
|
block._suppressSummaryClick = false;
|
|
|
}
|
|
|
block.open = true;
|
|
|
});
|
|
|
block.addEventListener('pointerdown', event => {
|
|
|
const additive = event.ctrlKey || event.metaKey;
|
|
|
const edge = configResizeEdge(event, block);
|
|
|
if (additive && !edge && !configDragBlocked(block, event)) {
|
|
|
event.preventDefault();
|
|
|
event.stopPropagation();
|
|
|
toggleConfigBlockSelection(block, true);
|
|
|
return;
|
|
|
}
|
|
|
if (edge || !selectedConfigBlocks.has(block)) selectConfigBlock(block);
|
|
|
bringConfigBlockFront(block);
|
|
|
if (!edge || configDragBlocked(block, event)) return;
|
|
|
event.preventDefault();
|
|
|
event.stopPropagation();
|
|
|
const startX = event.clientX;
|
|
|
const startY = event.clientY;
|
|
|
const startLeft = parseFloat(block.style.left) || block.offsetLeft || 0;
|
|
|
const startTop = parseFloat(block.style.top) || block.offsetTop || 0;
|
|
|
const startWidth = block.offsetWidth;
|
|
|
const startHeight = block.offsetHeight;
|
|
|
block.classList.add('is-resizing');
|
|
|
block._configManualHeight = true;
|
|
|
block.setPointerCapture?.(event.pointerId);
|
|
|
const move = moveEvent => {
|
|
|
const dx = moveEvent.clientX - startX;
|
|
|
const dy = moveEvent.clientY - startY;
|
|
|
const minWidth = configMinWidth();
|
|
|
const minHeight = 110;
|
|
|
const canvasWidth = configCanvasWidth();
|
|
|
let left = startLeft;
|
|
|
let top = startTop;
|
|
|
let width = startWidth;
|
|
|
let height = startHeight;
|
|
|
if (edge.includes('w')) { left = Math.max(0, Math.min(startLeft + dx, startLeft + startWidth - minWidth)); width = startWidth - (left - startLeft); }
|
|
|
if (edge.includes('e')) width = Math.max(minWidth, Math.min(canvasWidth - left, startWidth + dx));
|
|
|
if (edge.includes('n')) { top = Math.max(0, Math.min(startTop + dy, startTop + startHeight - minHeight)); height = startHeight - (top - startTop); }
|
|
|
if (edge.includes('s')) height = Math.max(minHeight, startHeight + dy);
|
|
|
block.style.left = `${Math.round(left)}px`;
|
|
|
block.style.top = `${Math.round(top)}px`;
|
|
|
block.style.width = `${Math.round(width)}px`;
|
|
|
block.style.height = `${Math.round(height)}px`;
|
|
|
updateConfigBlockScale(block);
|
|
|
updateConfigCanvasHeight();
|
|
|
};
|
|
|
const end = () => {
|
|
|
block.releasePointerCapture?.(event.pointerId);
|
|
|
block.removeEventListener('pointermove', move);
|
|
|
block.removeEventListener('pointerup', end);
|
|
|
block.removeEventListener('pointercancel', end);
|
|
|
block.classList.remove('is-resizing');
|
|
|
captureConfigLayout(block);
|
|
|
saveConfigLayout();
|
|
|
block._suppressSummaryClick = true;
|
|
|
window.setTimeout(() => { block._suppressSummaryClick = false; }, 0);
|
|
|
};
|
|
|
block.addEventListener('pointermove', move);
|
|
|
block.addEventListener('pointerup', end, {once: true});
|
|
|
block.addEventListener('pointercancel', end, {once: true});
|
|
|
});
|
|
|
block.addEventListener('pointermove', event => {
|
|
|
if (!block.classList.contains('is-resizing')) {
|
|
|
const edge = configResizeEdge(event, block);
|
|
|
block.style.cursor = edge ? `${edge}-resize` : '';
|
|
|
}
|
|
|
});
|
|
|
block.addEventListener('pointerleave', () => { if (!block.classList.contains('is-resizing')) block.style.cursor = ''; });
|
|
|
block.addEventListener('pointerdown', event => {
|
|
|
if (event.defaultPrevented || block.classList.contains('is-resizing')) return;
|
|
|
startConfigBlockDrag(block, event);
|
|
|
});
|
|
|
block.addEventListener('toggle', () => {
|
|
|
if (!block.open) block.open = true;
|
|
|
summary.setAttribute('aria-expanded', 'true');
|
|
|
updateConfigCanvasHeight();
|
|
|
});
|
|
|
}
|
|
|
function configDragBlocked(block, event) {
|
|
|
const target = event.target;
|
|
|
if (!(target instanceof Element)) return true;
|
|
|
const summary = block.firstElementChild;
|
|
|
const nestedSummary = target.closest('summary');
|
|
|
if (nestedSummary && nestedSummary !== summary) return true;
|
|
|
if (nestedSummary === summary) return false;
|
|
|
return !!target.closest('input, select, textarea, button, a, [contenteditable="true"], .drop-zone, .packet-byte-map, .packet-field-row');
|
|
|
}
|
|
|
function startConfigBlockDrag(block, event) {
|
|
|
if (event.button !== 0 || !configCanvas || configDragBlocked(block, event)) return;
|
|
|
const startX = event.clientX;
|
|
|
const startY = event.clientY;
|
|
|
const group = selectedConfigBlocks.has(block)
|
|
|
? [...selectedConfigBlocks].filter(item => !configBlockState(item))
|
|
|
: [block];
|
|
|
const origins = group.map(item => ({item, left: parseFloat(item.style.left) || item.offsetLeft || 0, top: parseFloat(item.style.top) || item.offsetTop || 0}));
|
|
|
let dragged = false;
|
|
|
showConfigTrash();
|
|
|
block.setPointerCapture?.(event.pointerId);
|
|
|
const move = moveEvent => {
|
|
|
if (!dragged && Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < 4) return;
|
|
|
if (!dragged) {
|
|
|
dragged = true;
|
|
|
event.preventDefault();
|
|
|
group.forEach(item => item.classList.add('is-dragging'));
|
|
|
}
|
|
|
event.preventDefault();
|
|
|
const rawDx = moveEvent.clientX - startX;
|
|
|
const minDx = -Math.min(...origins.map(origin => origin.left));
|
|
|
const maxDx = Math.min(...origins.map(origin => configCanvasWidth() - origin.item.offsetWidth - origin.left));
|
|
|
const dx = Math.max(minDx, Math.min(maxDx, rawDx));
|
|
|
const dy = moveEvent.clientY - startY;
|
|
|
origins.forEach(origin => {
|
|
|
origin.item.style.left = `${Math.max(0, Math.round(origin.left + dx))}px`;
|
|
|
origin.item.style.top = `${Math.max(0, Math.round(origin.top + dy))}px`;
|
|
|
});
|
|
|
setTrashHover(moveEvent);
|
|
|
updateConfigCanvasHeight();
|
|
|
};
|
|
|
let finished = false;
|
|
|
const end = endEvent => {
|
|
|
if (finished) return;
|
|
|
finished = true;
|
|
|
block.releasePointerCapture?.(event.pointerId);
|
|
|
block.removeEventListener('pointermove', move);
|
|
|
block.removeEventListener('pointerup', end);
|
|
|
block.removeEventListener('pointercancel', end);
|
|
|
window.removeEventListener('pointerup', end);
|
|
|
window.removeEventListener('pointercancel', end);
|
|
|
group.forEach(item => item.classList.remove('is-dragging'));
|
|
|
finishConfigDrag(block, endEvent, dragged, group);
|
|
|
};
|
|
|
block.addEventListener('pointermove', move);
|
|
|
block.addEventListener('pointerup', end, {once: true});
|
|
|
block.addEventListener('pointercancel', end, {once: true});
|
|
|
window.addEventListener('pointerup', end);
|
|
|
window.addEventListener('pointercancel', end);
|
|
|
}
|
|
|
function resetConfigLayout() {
|
|
|
const custom = {};
|
|
|
configBlocks.forEach(block => {
|
|
|
if (block.dataset.custom === 'true') custom[block.dataset.section] = {custom: true, template: configTemplateKey(block), title: configTitle(block)};
|
|
|
block.style.left = '';
|
|
|
block.style.top = '';
|
|
|
block.style.width = '';
|
|
|
block.style.height = '';
|
|
|
block.style.zIndex = '';
|
|
|
block._configUserScale = 1;
|
|
|
block.open = true;
|
|
|
block.dataset.blockState = '';
|
|
|
block.classList.remove('config-block-hidden', 'config-block-deleted');
|
|
|
block.removeAttribute('aria-hidden');
|
|
|
});
|
|
|
configLayout = custom;
|
|
|
configZIndex = 1;
|
|
|
try { localStorage.removeItem('fpv-open-sections'); } catch (_error) {}
|
|
|
saveConfigLayout();
|
|
|
applyStoredConfigLayout();
|
|
|
renderConfigRestorePanel();
|
|
|
selectConfigBlock(configBlocks.find(block => !configBlockState(block)) || null);
|
|
|
setText('configLayoutState', 'раскладка сброшена');
|
|
|
}
|
|
|
function initConfigCanvas() {
|
|
|
configCanvas = $('controlForm');
|
|
|
if (!configCanvas) return;
|
|
|
enableCanvasWheel(configCanvas, '.config-block');
|
|
|
configLayout = readConfigLayout();
|
|
|
configBlocks = Array.from(configCanvas.children).filter(node => node.matches('details[data-section]'));
|
|
|
Object.entries(configLayout).forEach(([key, entry]) => {
|
|
|
if (!entry || entry.custom !== true || configBlocks.some(block => block.dataset.section === key)) return;
|
|
|
createCustomConfigBlock(entry.template || 'source', entry.title || 'Новый блок', key);
|
|
|
});
|
|
|
configBlocks.forEach(block => {
|
|
|
block.classList.add('config-block');
|
|
|
makeConfigBlockInteractive(block);
|
|
|
const state = configLayout[block.dataset.section]?.state;
|
|
|
if (state) {
|
|
|
block.dataset.blockState = state;
|
|
|
block.classList.add(state === 'deleted' ? 'config-block-deleted' : 'config-block-hidden');
|
|
|
block.setAttribute('aria-hidden', 'true');
|
|
|
}
|
|
|
});
|
|
|
applyStoredConfigLayout();
|
|
|
renderConfigRestorePanel();
|
|
|
selectConfigBlock(configBlocks.find(block => !configBlockState(block)) || null);
|
|
|
$('configResetLayout')?.addEventListener('click', resetConfigLayout);
|
|
|
$('configDeleteSelected')?.addEventListener('click', deleteSelectedConfigBlocks);
|
|
|
$('configRestoreToggle')?.addEventListener('click', () => {
|
|
|
restorePanelOpen = !restorePanelOpen;
|
|
|
renderConfigRestorePanel();
|
|
|
});
|
|
|
$('configDeletedToggle')?.addEventListener('click', () => {
|
|
|
deletedPanelOpen = !deletedPanelOpen;
|
|
|
renderConfigRestorePanel();
|
|
|
});
|
|
|
$('configCreateBlock')?.addEventListener('click', () => {
|
|
|
const templateKey = $('configTemplateSelect')?.value || 'source';
|
|
|
const title = String($('configBlockTitle')?.value || '').trim().slice(0, 48) || 'Новый блок';
|
|
|
const block = createCustomConfigBlock(templateKey, title);
|
|
|
if (!block) return;
|
|
|
makeConfigBlockInteractive(block);
|
|
|
positionNewConfigBlock(block);
|
|
|
bringConfigBlockFront(block);
|
|
|
captureConfigLayout(block);
|
|
|
saveConfigLayout();
|
|
|
if ($('configBlockTitle')) $('configBlockTitle').value = '';
|
|
|
renderConfigRestorePanel();
|
|
|
setText('configLayoutState', `создан блок «${configTitle(block)}»`);
|
|
|
});
|
|
|
window.addEventListener('resize', applyStoredConfigLayout);
|
|
|
if (window.ResizeObserver) {
|
|
|
configBlockObserver = new ResizeObserver(entries => {
|
|
|
entries.forEach(entry => updateConfigBlockScale(entry.target));
|
|
|
updateConfigCanvasHeight();
|
|
|
});
|
|
|
configBlocks.forEach(block => configBlockObserver.observe(block));
|
|
|
}
|
|
|
selectConfigBlock(selectedConfigBlock);
|
|
|
$('configContentScale')?.addEventListener('input', event => applyConfigContentScale(event.target.value));
|
|
|
}
|
|
|
function enableCanvasWheel(canvas, blockSelector) {
|
|
|
canvas.addEventListener('wheel', event => {
|
|
|
if (event.defaultPrevented || event.target instanceof Element && event.target.closest(blockSelector)) return;
|
|
|
const maxLeft = Math.max(0, canvas.scrollWidth - canvas.clientWidth);
|
|
|
const maxTop = Math.max(0, canvas.scrollHeight - canvas.clientHeight);
|
|
|
if (!maxLeft && !maxTop) return;
|
|
|
const left = Math.max(0, Math.min(maxLeft, canvas.scrollLeft + event.deltaX));
|
|
|
const top = Math.max(0, Math.min(maxTop, canvas.scrollTop + event.deltaY));
|
|
|
if (left === canvas.scrollLeft && top === canvas.scrollTop) return;
|
|
|
canvas.scrollLeft = left;
|
|
|
canvas.scrollTop = top;
|
|
|
event.preventDefault();
|
|
|
}, {passive: false});
|
|
|
}
|
|
|
let networkCanvas = null;
|
|
|
let networkBlocks = [];
|
|
|
let networkLayout = {};
|
|
|
let networkZIndex = 1;
|
|
|
let networkSelectedBlock = null;
|
|
|
let selectedNetworkBlocks = new Set();
|
|
|
let networkStructureOpen = false;
|
|
|
let networkContentScale = 1;
|
|
|
function readNetworkLayout() {
|
|
|
try {
|
|
|
const saved = JSON.parse(localStorage.getItem('fpv-network-layout-v1') || '{}');
|
|
|
return saved && typeof saved === 'object' ? saved : {};
|
|
|
} catch (_error) { return {}; }
|
|
|
}
|
|
|
function saveNetworkLayout() {
|
|
|
try {
|
|
|
localStorage.setItem('fpv-network-layout-v1', JSON.stringify(networkLayout));
|
|
|
if (networkCanvas) localStorage.setItem('fpv-network-canvas-width', String(Math.round(networkCanvasWidth())));
|
|
|
} catch (_error) {}
|
|
|
}
|
|
|
function networkMinWidth() { return window.innerWidth <= 760 ? 220 : 240; }
|
|
|
function networkCanvasWidth() { return Math.max(networkMinWidth(), (networkCanvas?.clientWidth || 0) - 28); }
|
|
|
function networkState(block) { return block?.dataset.blockState || ''; }
|
|
|
function networkTitle(block) { return block?.querySelector(':scope > .network-summary')?.textContent?.trim() || block?.dataset.section || 'Блок'; }
|
|
|
function networkTemplateKey(block) { return block?.dataset.custom === 'true' ? (block.dataset.template || 'model') : block?.dataset.section; }
|
|
|
function nextNetworkKey() {
|
|
|
let index = 1;
|
|
|
while (networkBlocks.some(block => block.dataset.section === `network-custom-${index}`)) index += 1;
|
|
|
return `network-custom-${index}`;
|
|
|
}
|
|
|
function updateNetworkBlockScale(block) {
|
|
|
if (!block) return;
|
|
|
const width = Math.max(networkMinWidth(), block.clientWidth || block.offsetWidth || networkMinWidth());
|
|
|
const height = Math.max(110, block.clientHeight || block.offsetHeight || 110);
|
|
|
const area = Math.max(.56, Math.min(1.85, (width / 720) * (height / 320)));
|
|
|
const entry = networkLayout[block.dataset.section] || {};
|
|
|
const userScale = Number.isFinite(Number(block._networkUserScale)) ? block._networkUserScale : (Number(entry.scale) || 100) / 100;
|
|
|
block._networkUserScale = Math.max(.5, Math.min(2, userScale));
|
|
|
block.style.setProperty('--block-scale', Math.max(.5, Math.min(2, Math.sqrt(area) * block._networkUserScale)).toFixed(3));
|
|
|
}
|
|
|
function selectNetworkBlock(block) {
|
|
|
selectedNetworkBlocks = new Set(block && !networkState(block) ? [block] : []);
|
|
|
updateNetworkSelection(block);
|
|
|
}
|
|
|
function updateNetworkSelection(preferred = networkSelectedBlock) {
|
|
|
selectedNetworkBlocks = new Set([...selectedNetworkBlocks].filter(block => networkBlocks.includes(block) && !networkState(block)));
|
|
|
networkSelectedBlock = selectedNetworkBlocks.has(preferred) ? preferred : [...selectedNetworkBlocks].at(-1) || null;
|
|
|
networkBlocks.forEach(item => item.classList.toggle('is-selected', selectedNetworkBlocks.has(item)));
|
|
|
$('networkRestorePanel')?.querySelectorAll('.config-restore-item').forEach(item => item.classList.toggle('is-selected', selectedNetworkBlocks.has(item._networkBlock)));
|
|
|
const control = $('networkContentScale');
|
|
|
const remove = $('networkDeleteSelected');
|
|
|
if (!networkSelectedBlock) {
|
|
|
if (control) control.disabled = true;
|
|
|
if (remove) remove.disabled = true;
|
|
|
return;
|
|
|
}
|
|
|
const value = Math.max(50, Math.min(200, Math.round(Number(networkLayout[networkSelectedBlock.dataset.section]?.scale) || 100)));
|
|
|
networkContentScale = value / 100;
|
|
|
setValue('networkContentScale', value);
|
|
|
setText('networkContentScaleValue', `${value}%`);
|
|
|
if (control) control.disabled = selectedNetworkBlocks.size !== 1;
|
|
|
if (remove) remove.disabled = !selectedNetworkBlocks.size;
|
|
|
updateNetworkBlockScale(networkSelectedBlock);
|
|
|
if (selectedNetworkBlocks.size > 1) setText('networkLayoutState', `выбрано ${selectedNetworkBlocks.size} блока · перетаскивайте группу`);
|
|
|
}
|
|
|
function toggleNetworkBlockSelection(block, additive = false) {
|
|
|
if (!block || networkState(block)) return;
|
|
|
if (!additive) return selectNetworkBlock(block);
|
|
|
if (selectedNetworkBlocks.has(block)) selectedNetworkBlocks.delete(block);
|
|
|
else selectedNetworkBlocks.add(block);
|
|
|
updateNetworkSelection(block);
|
|
|
}
|
|
|
function applyNetworkContentScale(value) {
|
|
|
networkContentScale = Math.max(.5, Math.min(2, (Number(value) || 100) / 100));
|
|
|
const block = networkSelectedBlock;
|
|
|
if (!block || networkState(block)) return;
|
|
|
block._networkUserScale = networkContentScale;
|
|
|
const entry = networkLayout[block.dataset.section] || {};
|
|
|
entry.scale = Math.round(networkContentScale * 100);
|
|
|
networkLayout[block.dataset.section] = entry;
|
|
|
setText('networkContentScaleValue', `${Math.round(networkContentScale * 100)}%`);
|
|
|
updateNetworkBlockScale(block);
|
|
|
saveNetworkLayout();
|
|
|
}
|
|
|
function captureNetworkLayout(block) {
|
|
|
const key = block?.dataset.section;
|
|
|
if (!key) return;
|
|
|
const entry = networkLayout[key] || {};
|
|
|
entry.left = Math.round(parseFloat(block.style.left) || block.offsetLeft || 0);
|
|
|
entry.top = Math.round(parseFloat(block.style.top) || block.offsetTop || 0);
|
|
|
entry.width = Math.round(block.offsetWidth);
|
|
|
if (block.style.height && block.style.height !== 'auto') entry.height = Math.round(block.offsetHeight);
|
|
|
else delete entry.height;
|
|
|
if (Number.isFinite(Number(block._networkUserScale)) && Math.abs(block._networkUserScale - 1) > .001) entry.scale = Math.round(block._networkUserScale * 100);
|
|
|
else delete entry.scale;
|
|
|
if (block.style.zIndex) entry.z = Number(block.style.zIndex);
|
|
|
networkLayout[key] = entry;
|
|
|
}
|
|
|
function placeNetworkBlock(block, left, top, width) {
|
|
|
if (!block) return;
|
|
|
const canvasWidth = networkCanvasWidth();
|
|
|
const minWidth = networkMinWidth();
|
|
|
const safeLeft = Math.max(0, Math.round(Math.min(left, Math.max(0, canvasWidth - minWidth))));
|
|
|
const safeWidth = Math.max(minWidth, Math.min(canvasWidth - safeLeft, Math.round(width)));
|
|
|
block.style.left = `${safeLeft}px`;
|
|
|
block.style.top = `${Math.max(0, Math.round(top))}px`;
|
|
|
block.style.width = `${safeWidth}px`;
|
|
|
}
|
|
|
function updateNetworkCanvasHeight() {
|
|
|
if (!networkCanvas) return;
|
|
|
const bottom = networkBlocks.reduce((value, block) => networkState(block) ? value : Math.max(value, (block.offsetTop || 0) + block.offsetHeight), 0);
|
|
|
networkCanvas.style.minHeight = `${Math.max(window.innerWidth <= 760 ? 900 : 720, bottom + 28)}px`;
|
|
|
}
|
|
|
function applyNetworkLayout() {
|
|
|
if (!networkCanvas) return;
|
|
|
const width = networkCanvasWidth();
|
|
|
const gap = 14;
|
|
|
const visible = block => block && !networkState(block);
|
|
|
const model = networkBlocks.find(block => block.dataset.section === 'model' && visible(block));
|
|
|
const params = networkBlocks.find(block => block.dataset.section === 'model-params' && visible(block));
|
|
|
const netron = networkBlocks.find(block => block.dataset.section === 'netron' && visible(block));
|
|
|
const placeDefault = (block, left, top, blockWidth) => {
|
|
|
if (!block) return;
|
|
|
const entry = networkLayout[block.dataset.section] || {};
|
|
|
if (!Number.isFinite(Number(entry.left)) || !Number.isFinite(Number(entry.top))) placeNetworkBlock(block, left, top, blockWidth);
|
|
|
};
|
|
|
const half = Math.max(networkMinWidth(), (width - gap) / 2);
|
|
|
placeDefault(model, 0, 14, half);
|
|
|
placeDefault(params, half + gap, 14, half);
|
|
|
const rowHeight = Math.max(model?.offsetHeight || 110, params?.offsetHeight || 110);
|
|
|
placeDefault(netron, 0, rowHeight + 28, width);
|
|
|
networkBlocks.forEach(block => {
|
|
|
const entry = networkLayout[block.dataset.section];
|
|
|
if (!entry || networkState(block)) return;
|
|
|
const left = Number.isFinite(Number(entry.left)) ? Math.max(0, Math.min(width - networkMinWidth(), Number(entry.left))) : block.offsetLeft;
|
|
|
block.style.left = `${Math.round(left)}px`;
|
|
|
if (Number.isFinite(Number(entry.top))) block.style.top = `${Math.max(0, Math.round(Number(entry.top)))}px`;
|
|
|
if (Number.isFinite(Number(entry.width))) block.style.width = `${Math.max(networkMinWidth(), Math.min(width - left, Number(entry.width)))}px`;
|
|
|
if (Number.isFinite(Number(entry.height))) block.style.height = `${Math.max(110, Number(entry.height))}px`;
|
|
|
if (Number.isFinite(Number(entry.z))) { block.style.zIndex = String(Math.max(1, Number(entry.z))); networkZIndex = Math.max(networkZIndex, Number(entry.z)); }
|
|
|
updateNetworkBlockScale(block);
|
|
|
});
|
|
|
updateNetworkCanvasHeight();
|
|
|
}
|
|
|
function bringNetworkBlockFront(block) {
|
|
|
if (!block || networkState(block)) return;
|
|
|
block.style.zIndex = String(++networkZIndex);
|
|
|
captureNetworkLayout(block);
|
|
|
saveNetworkLayout();
|
|
|
}
|
|
|
function renderNetworkStructure() {
|
|
|
const panel = $('networkRestorePanel');
|
|
|
const list = $('networkRestoreList');
|
|
|
const toggle = $('networkStructureToggle');
|
|
|
const workspace = $('networkWorkspace');
|
|
|
if (!panel || !list || !toggle) return;
|
|
|
list.replaceChildren();
|
|
|
networkBlocks.forEach(block => {
|
|
|
const state = networkState(block);
|
|
|
const item = document.createElement('div');
|
|
|
item.className = `config-restore-item${state ? ' is-hidden' : ''}${selectedNetworkBlocks.has(block) ? ' is-selected' : ''}`;
|
|
|
item._networkBlock = block;
|
|
|
item.title = 'Нажмите, чтобы выбрать блок';
|
|
|
const label = document.createElement('span');
|
|
|
label.className = 'config-item-title';
|
|
|
label.textContent = networkTitle(block);
|
|
|
const stateNode = document.createElement('span');
|
|
|
stateNode.className = 'config-item-state';
|
|
|
stateNode.textContent = state === 'deleted' ? 'в корзине' : state === 'hidden' ? 'скрыт' : block.dataset.custom === 'true' ? 'свой' : 'активен';
|
|
|
const actions = document.createElement('span');
|
|
|
actions.className = 'config-item-actions';
|
|
|
const visibility = document.createElement('button');
|
|
|
visibility.type = 'button';
|
|
|
visibility.textContent = state ? 'вернуть' : 'скрыть';
|
|
|
visibility.addEventListener('click', event => {
|
|
|
event.stopPropagation();
|
|
|
setNetworkBlockState(block, state ? '' : 'hidden');
|
|
|
});
|
|
|
actions.appendChild(visibility);
|
|
|
if (block.dataset.custom === 'true' && !state) {
|
|
|
const duplicate = document.createElement('button');
|
|
|
duplicate.type = 'button';
|
|
|
duplicate.textContent = 'дубль';
|
|
|
duplicate.addEventListener('click', event => {
|
|
|
event.stopPropagation();
|
|
|
duplicateNetworkBlock(block);
|
|
|
});
|
|
|
actions.appendChild(duplicate);
|
|
|
}
|
|
|
if (block.dataset.custom === 'true') {
|
|
|
const remove = document.createElement('button');
|
|
|
remove.type = 'button';
|
|
|
remove.textContent = 'удалить навсегда';
|
|
|
remove.title = 'Удалить пользовательский блок без восстановления';
|
|
|
remove.addEventListener('click', event => {
|
|
|
event.stopPropagation();
|
|
|
removeNetworkBlockForever(block);
|
|
|
});
|
|
|
actions.appendChild(remove);
|
|
|
} else {
|
|
|
const remove = document.createElement('button');
|
|
|
remove.type = 'button';
|
|
|
remove.textContent = 'удалить';
|
|
|
remove.title = 'Убрать блок в корзину; его можно восстановить';
|
|
|
remove.addEventListener('click', event => {
|
|
|
event.stopPropagation();
|
|
|
setNetworkBlockState(block, 'deleted');
|
|
|
});
|
|
|
actions.appendChild(remove);
|
|
|
}
|
|
|
item.append(label, stateNode, actions);
|
|
|
item.addEventListener('click', event => {
|
|
|
if (state) return;
|
|
|
if (event.ctrlKey || event.metaKey) toggleNetworkBlockSelection(block, true);
|
|
|
else selectNetworkBlock(block);
|
|
|
bringNetworkBlockFront(block);
|
|
|
});
|
|
|
list.appendChild(item);
|
|
|
});
|
|
|
const hidden = networkBlocks.filter(block => networkState(block) === 'hidden').length;
|
|
|
const deleted = networkBlocks.filter(block => networkState(block) === 'deleted').length;
|
|
|
toggle.textContent = hidden || deleted ? `структура · ${hidden + deleted}` : 'структура';
|
|
|
toggle.setAttribute('aria-expanded', String(networkStructureOpen));
|
|
|
panel.hidden = !networkStructureOpen;
|
|
|
workspace?.classList.toggle('has-structure', networkStructureOpen);
|
|
|
const selected = selectedNetworkBlocks.size > 1 ? ` · выбрано ${selectedNetworkBlocks.size}` : '';
|
|
|
setText('networkLayoutState', `${networkBlocks.length - hidden - deleted} блоков · ${hidden} скрыто · ${deleted} в корзине${selected}`);
|
|
|
}
|
|
|
function setNetworkBlockStates(blocks, state) {
|
|
|
const items = [...new Set(blocks)].filter(block => block && networkBlocks.includes(block));
|
|
|
if (!items.length) return;
|
|
|
items.forEach(block => {
|
|
|
const key = block.dataset.section;
|
|
|
if (key && state) networkLayout[key] = {...(networkLayout[key] || {}), state};
|
|
|
else if (key && networkLayout[key]) {
|
|
|
delete networkLayout[key].state;
|
|
|
if (!Object.keys(networkLayout[key]).length) delete networkLayout[key];
|
|
|
}
|
|
|
block.dataset.blockState = state || '';
|
|
|
block.classList.toggle('is-hidden', state === 'hidden');
|
|
|
block.classList.toggle('is-deleted', state === 'deleted');
|
|
|
if (state) block.setAttribute('aria-hidden', 'true');
|
|
|
else block.removeAttribute('aria-hidden');
|
|
|
});
|
|
|
saveNetworkLayout();
|
|
|
applyNetworkLayout();
|
|
|
renderNetworkStructure();
|
|
|
selectNetworkBlock(networkBlocks.find(candidate => !networkState(candidate)) || null);
|
|
|
}
|
|
|
function setNetworkBlockState(block, state) { setNetworkBlockStates([block], state); }
|
|
|
function deleteSelectedNetworkBlocks() {
|
|
|
const items = [...selectedNetworkBlocks].filter(block => networkBlocks.includes(block) && !networkState(block));
|
|
|
if (!items.length) return false;
|
|
|
setNetworkBlockStates(items, 'deleted');
|
|
|
setText('networkLayoutState', `${items.length} блок${items.length === 1 ? '' : 'а'} перемещён${items.length === 1 ? '' : 'ы'} в корзину`);
|
|
|
return true;
|
|
|
}
|
|
|
function createNetworkBlock(templateKey, title, existingKey = '') {
|
|
|
const template = networkBlocks.find(block => block.dataset.custom !== 'true' && block.dataset.section === templateKey);
|
|
|
if (!template || !networkCanvas) return null;
|
|
|
const key = existingKey || nextNetworkKey();
|
|
|
const clone = template.cloneNode(true);
|
|
|
clone.dataset.section = key;
|
|
|
clone.dataset.custom = 'true';
|
|
|
clone.dataset.template = templateKey;
|
|
|
clone.classList.add('network-custom-block');
|
|
|
clone.querySelectorAll('[id]').forEach(node => node.removeAttribute('id'));
|
|
|
clone.querySelectorAll('[for]').forEach(node => node.removeAttribute('for'));
|
|
|
const summary = clone.querySelector(':scope > .network-summary');
|
|
|
if (summary) summary.textContent = title || `Новый блок ${key.replace('network-custom-', '')}`;
|
|
|
networkCanvas.appendChild(clone);
|
|
|
networkBlocks.push(clone);
|
|
|
networkLayout[key] = {...(networkLayout[key] || {}), custom: true, template: templateKey, title: networkTitle(clone)};
|
|
|
makeNetworkBlockInteractive(clone);
|
|
|
return clone;
|
|
|
}
|
|
|
function positionNewNetworkBlock(block) {
|
|
|
const bottom = networkBlocks.filter(item => item !== block && !networkState(item)).reduce((value, item) => Math.max(value, (item.offsetTop || 0) + item.offsetHeight), 0);
|
|
|
const width = networkCanvasWidth();
|
|
|
block.style.left = '12px';
|
|
|
block.style.top = `${Math.max(12, bottom + 18)}px`;
|
|
|
block.style.width = `${Math.min(width, Math.max(networkMinWidth(), Math.round(width * .46)))}px`;
|
|
|
updateNetworkBlockScale(block);
|
|
|
}
|
|
|
function duplicateNetworkBlock(block) {
|
|
|
const clone = createNetworkBlock(networkTemplateKey(block), `${networkTitle(block)} · копия`);
|
|
|
if (!clone) return;
|
|
|
positionNewNetworkBlock(clone);
|
|
|
bringNetworkBlockFront(clone);
|
|
|
captureNetworkLayout(clone);
|
|
|
saveNetworkLayout();
|
|
|
renderNetworkStructure();
|
|
|
selectNetworkBlock(clone);
|
|
|
setText('networkLayoutState', `создан блок «${networkTitle(clone)}»`);
|
|
|
}
|
|
|
function removeNetworkBlockForever(block) {
|
|
|
if (!block || block.dataset.custom !== 'true') return;
|
|
|
const key = block.dataset.section;
|
|
|
block.remove();
|
|
|
networkBlocks = networkBlocks.filter(item => item !== block);
|
|
|
if (key) delete networkLayout[key];
|
|
|
saveNetworkLayout();
|
|
|
renderNetworkStructure();
|
|
|
selectNetworkBlock(networkBlocks.find(candidate => !networkState(candidate)) || null);
|
|
|
setText('networkLayoutState', 'пользовательский блок удалён');
|
|
|
}
|
|
|
function networkResizeEdge(event, block) {
|
|
|
const target = event.target;
|
|
|
if (target instanceof Element && target.closest('.network-summary')?.parentElement === block) return '';
|
|
|
const rect = block.getBoundingClientRect();
|
|
|
const edge = 14;
|
|
|
const left = event.clientX - rect.left <= edge;
|
|
|
const right = rect.right - event.clientX <= edge;
|
|
|
const top = event.clientY - rect.top <= edge;
|
|
|
const bottom = rect.bottom - event.clientY <= edge;
|
|
|
return `${top ? 'n' : bottom ? 's' : ''}${left ? 'w' : right ? 'e' : ''}`;
|
|
|
}
|
|
|
function networkDragBlocked(block, event) {
|
|
|
const target = event.target;
|
|
|
if (!(target instanceof Element)) return true;
|
|
|
return !!target.closest('input, select, textarea, button, a, iframe, [contenteditable="true"], .netron-panel');
|
|
|
}
|
|
|
function networkTrashDrop(event) {
|
|
|
const trash = $('networkTrash');
|
|
|
if (!trash || trash.hidden || !event) return false;
|
|
|
const rect = trash.getBoundingClientRect();
|
|
|
return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
|
|
|
}
|
|
|
function showNetworkTrash() {
|
|
|
const trash = $('networkTrash');
|
|
|
if (trash) { trash.hidden = false; trash.classList.remove('is-over'); }
|
|
|
}
|
|
|
function finishNetworkDrag(block, event, dragged, group = [block]) {
|
|
|
const trash = $('networkTrash');
|
|
|
const droppedOnTrash = networkTrashDrop(event);
|
|
|
if (trash) { trash.classList.remove('is-over'); trash.hidden = true; }
|
|
|
if (!dragged) return;
|
|
|
group.forEach(item => captureNetworkLayout(item));
|
|
|
if (droppedOnTrash) setNetworkBlockStates(group, 'deleted');
|
|
|
else { saveNetworkLayout(); renderNetworkStructure(); }
|
|
|
group.forEach(item => {
|
|
|
item._networkSuppressClick = true;
|
|
|
window.setTimeout(() => { item._networkSuppressClick = false; }, 0);
|
|
|
});
|
|
|
}
|
|
|
function startNetworkDrag(block, event) {
|
|
|
if (event.button !== 0 || !networkCanvas || networkDragBlocked(block, event)) return;
|
|
|
const startX = event.clientX;
|
|
|
const startY = event.clientY;
|
|
|
const group = selectedNetworkBlocks.has(block)
|
|
|
? [...selectedNetworkBlocks].filter(item => !networkState(item))
|
|
|
: [block];
|
|
|
const origins = group.map(item => ({item, left: parseFloat(item.style.left) || item.offsetLeft || 0, top: parseFloat(item.style.top) || item.offsetTop || 0}));
|
|
|
let dragged = false;
|
|
|
showNetworkTrash();
|
|
|
block.setPointerCapture?.(event.pointerId);
|
|
|
const move = moveEvent => {
|
|
|
if (!dragged && Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < 4) return;
|
|
|
if (!dragged) { dragged = true; moveEvent.preventDefault(); group.forEach(item => item.classList.add('is-dragging')); }
|
|
|
moveEvent.preventDefault();
|
|
|
const rawDx = moveEvent.clientX - startX;
|
|
|
const minDx = -Math.min(...origins.map(origin => origin.left));
|
|
|
const maxDx = Math.min(...origins.map(origin => networkCanvasWidth() - origin.item.offsetWidth - origin.left));
|
|
|
const dx = Math.max(minDx, Math.min(maxDx, rawDx));
|
|
|
const dy = moveEvent.clientY - startY;
|
|
|
origins.forEach(origin => {
|
|
|
origin.item.style.left = `${Math.max(0, Math.round(origin.left + dx))}px`;
|
|
|
origin.item.style.top = `${Math.max(0, Math.round(origin.top + dy))}px`;
|
|
|
});
|
|
|
const trash = $('networkTrash');
|
|
|
if (trash) trash.classList.toggle('is-over', networkTrashDrop(moveEvent));
|
|
|
updateNetworkCanvasHeight();
|
|
|
};
|
|
|
let finished = false;
|
|
|
const end = endEvent => {
|
|
|
if (finished) return;
|
|
|
finished = true;
|
|
|
block.releasePointerCapture?.(event.pointerId);
|
|
|
block.removeEventListener('pointermove', move);
|
|
|
block.removeEventListener('pointerup', end);
|
|
|
block.removeEventListener('pointercancel', end);
|
|
|
window.removeEventListener('pointerup', end);
|
|
|
window.removeEventListener('pointercancel', end);
|
|
|
group.forEach(item => item.classList.remove('is-dragging'));
|
|
|
finishNetworkDrag(block, endEvent, dragged, group);
|
|
|
};
|
|
|
block.addEventListener('pointermove', move);
|
|
|
block.addEventListener('pointerup', end, {once: true});
|
|
|
block.addEventListener('pointercancel', end, {once: true});
|
|
|
window.addEventListener('pointerup', end);
|
|
|
window.addEventListener('pointercancel', end);
|
|
|
}
|
|
|
function makeNetworkBlockInteractive(block) {
|
|
|
if (!block || block._networkInteractive) return;
|
|
|
block._networkInteractive = true;
|
|
|
block.addEventListener('pointerdown', event => {
|
|
|
const additive = event.ctrlKey || event.metaKey;
|
|
|
const edge = networkResizeEdge(event, block);
|
|
|
if (additive && !edge && !networkDragBlocked(block, event)) {
|
|
|
event.preventDefault();
|
|
|
event.stopPropagation();
|
|
|
toggleNetworkBlockSelection(block, true);
|
|
|
return;
|
|
|
}
|
|
|
if (edge || !selectedNetworkBlocks.has(block)) selectNetworkBlock(block);
|
|
|
bringNetworkBlockFront(block);
|
|
|
if (!edge || networkDragBlocked(block, event)) return;
|
|
|
event.preventDefault();
|
|
|
event.stopPropagation();
|
|
|
const startX = event.clientX;
|
|
|
const startY = event.clientY;
|
|
|
const startLeft = parseFloat(block.style.left) || block.offsetLeft || 0;
|
|
|
const startTop = parseFloat(block.style.top) || block.offsetTop || 0;
|
|
|
const startWidth = block.offsetWidth;
|
|
|
const startHeight = block.offsetHeight;
|
|
|
block.classList.add('is-resizing');
|
|
|
block.setPointerCapture?.(event.pointerId);
|
|
|
const move = moveEvent => {
|
|
|
const dx = moveEvent.clientX - startX;
|
|
|
const dy = moveEvent.clientY - startY;
|
|
|
const minWidth = networkMinWidth();
|
|
|
const canvasWidth = networkCanvasWidth();
|
|
|
let left = startLeft;
|
|
|
let top = startTop;
|
|
|
let width = startWidth;
|
|
|
let height = startHeight;
|
|
|
if (edge.includes('w')) { left = Math.max(0, Math.min(startLeft + dx, startLeft + startWidth - minWidth)); width = startWidth - (left - startLeft); }
|
|
|
if (edge.includes('e')) width = Math.max(minWidth, Math.min(canvasWidth - left, startWidth + dx));
|
|
|
if (edge.includes('n')) { top = Math.max(0, Math.min(startTop + dy, startTop + startHeight - 110)); height = startHeight - (top - startTop); }
|
|
|
if (edge.includes('s')) height = Math.max(110, startHeight + dy);
|
|
|
block.style.left = `${Math.round(left)}px`;
|
|
|
block.style.top = `${Math.round(top)}px`;
|
|
|
block.style.width = `${Math.round(width)}px`;
|
|
|
block.style.height = `${Math.round(height)}px`;
|
|
|
updateNetworkBlockScale(block);
|
|
|
updateNetworkCanvasHeight();
|
|
|
};
|
|
|
const end = () => {
|
|
|
block.releasePointerCapture?.(event.pointerId);
|
|
|
block.removeEventListener('pointermove', move);
|
|
|
block.removeEventListener('pointerup', end);
|
|
|
block.removeEventListener('pointercancel', end);
|
|
|
block.classList.remove('is-resizing');
|
|
|
captureNetworkLayout(block);
|
|
|
saveNetworkLayout();
|
|
|
block._networkSuppressClick = true;
|
|
|
window.setTimeout(() => { block._networkSuppressClick = false; }, 0);
|
|
|
};
|
|
|
block.addEventListener('pointermove', move);
|
|
|
block.addEventListener('pointerup', end, {once: true});
|
|
|
block.addEventListener('pointercancel', end, {once: true});
|
|
|
});
|
|
|
block.addEventListener('pointermove', event => {
|
|
|
if (!block.classList.contains('is-resizing')) {
|
|
|
const edge = networkResizeEdge(event, block);
|
|
|
block.style.cursor = edge ? `${edge}-resize` : '';
|
|
|
}
|
|
|
});
|
|
|
block.addEventListener('pointerdown', event => {
|
|
|
if (event.defaultPrevented || block.classList.contains('is-resizing')) return;
|
|
|
startNetworkDrag(block, event);
|
|
|
});
|
|
|
}
|
|
|
function resetNetworkLayout() {
|
|
|
const custom = {};
|
|
|
networkBlocks.forEach(block => {
|
|
|
if (block.dataset.custom === 'true') custom[block.dataset.section] = {custom: true, template: networkTemplateKey(block), title: networkTitle(block)};
|
|
|
});
|
|
|
networkLayout = custom;
|
|
|
networkZIndex = 1;
|
|
|
networkBlocks.forEach(block => {
|
|
|
block.style.left = '';
|
|
|
block.style.top = '';
|
|
|
block.style.width = '';
|
|
|
block.style.height = '';
|
|
|
block.style.zIndex = '';
|
|
|
block._networkUserScale = 1;
|
|
|
block.dataset.blockState = '';
|
|
|
block.classList.remove('is-hidden', 'is-deleted');
|
|
|
block.removeAttribute('aria-hidden');
|
|
|
});
|
|
|
saveNetworkLayout();
|
|
|
applyNetworkLayout();
|
|
|
renderNetworkStructure();
|
|
|
selectNetworkBlock(networkBlocks[0] || null);
|
|
|
setText('networkLayoutState', 'раскладка сети сброшена');
|
|
|
}
|
|
|
function initNetworkCanvas() {
|
|
|
networkCanvas = $('networkCanvas');
|
|
|
if (!networkCanvas) return;
|
|
|
enableCanvasWheel(networkCanvas, '.network-block');
|
|
|
networkLayout = readNetworkLayout();
|
|
|
networkBlocks = Array.from(networkCanvas.children).filter(node => node.classList.contains('network-block'));
|
|
|
Object.entries(networkLayout).forEach(([key, entry]) => {
|
|
|
if (!entry || entry.custom !== true || networkBlocks.some(block => block.dataset.section === key)) return;
|
|
|
createNetworkBlock(entry.template || 'model', entry.title || 'Новый блок', key);
|
|
|
});
|
|
|
networkBlocks.forEach(block => {
|
|
|
makeNetworkBlockInteractive(block);
|
|
|
const state = networkLayout[block.dataset.section]?.state;
|
|
|
if (state) {
|
|
|
block.dataset.blockState = state;
|
|
|
block.classList.add(state === 'deleted' ? 'is-deleted' : 'is-hidden');
|
|
|
block.setAttribute('aria-hidden', 'true');
|
|
|
}
|
|
|
});
|
|
|
applyNetworkLayout();
|
|
|
renderNetworkStructure();
|
|
|
selectNetworkBlock(networkBlocks.find(block => !networkState(block)) || null);
|
|
|
$('networkResetLayout')?.addEventListener('click', resetNetworkLayout);
|
|
|
$('networkDeleteSelected')?.addEventListener('click', deleteSelectedNetworkBlocks);
|
|
|
$('networkStructureToggle')?.addEventListener('click', () => {
|
|
|
networkStructureOpen = !networkStructureOpen;
|
|
|
renderNetworkStructure();
|
|
|
});
|
|
|
$('networkCreateBlock')?.addEventListener('click', () => {
|
|
|
const templateKey = $('networkTemplateSelect')?.value || 'model';
|
|
|
const title = String($('networkBlockTitle')?.value || '').trim().slice(0, 48) || 'Новый блок';
|
|
|
const block = createNetworkBlock(templateKey, title);
|
|
|
if (!block) return;
|
|
|
positionNewNetworkBlock(block);
|
|
|
bringNetworkBlockFront(block);
|
|
|
captureNetworkLayout(block);
|
|
|
saveNetworkLayout();
|
|
|
if ($('networkBlockTitle')) $('networkBlockTitle').value = '';
|
|
|
renderNetworkStructure();
|
|
|
selectNetworkBlock(block);
|
|
|
setText('networkLayoutState', `создан блок «${networkTitle(block)}»`);
|
|
|
});
|
|
|
$('networkContentScale')?.addEventListener('input', event => applyNetworkContentScale(event.target.value));
|
|
|
window.addEventListener('resize', applyNetworkLayout);
|
|
|
if (window.ResizeObserver) {
|
|
|
const observer = new ResizeObserver(entries => {
|
|
|
entries.forEach(entry => updateNetworkBlockScale(entry.target));
|
|
|
updateNetworkCanvasHeight();
|
|
|
});
|
|
|
networkBlocks.forEach(block => observer.observe(block));
|
|
|
}
|
|
|
}
|
|
|
function parseFrameSize(value) {
|
|
|
const match = /^(\\d{1,5})x(\\d{1,5})$/i.exec(String(value || '').trim());
|
|
|
return match ? [Number(match[1]), Number(match[2])] : [1280, 720];
|
|
|
}
|
|
|
function frameSizeValue() {
|
|
|
const width = Math.max(16, Math.min(8192, Math.round(Number($('frameWidth').value) || 1280)));
|
|
|
const height = Math.max(16, Math.min(8192, Math.round(Number($('frameHeight').value) || 720)));
|
|
|
return `${width}x${height}`;
|
|
|
}
|
|
|
function setFrameSize(value) {
|
|
|
const [width, height] = parseFrameSize(value);
|
|
|
setValue('frameWidth', width);
|
|
|
setValue('frameHeight', height);
|
|
|
const size = `${width}x${height}`;
|
|
|
const preset = [...$('quality').options].some(option => option.value === size);
|
|
|
setValue('quality', preset ? size : 'custom');
|
|
|
}
|
|
|
function syncFrameSizeFromPreset() {
|
|
|
if ($('quality').value !== 'custom') setFrameSize($('quality').value);
|
|
|
}
|
|
|
function syncFramePresetFromInputs() {
|
|
|
const size = frameSizeValue();
|
|
|
const preset = [...$('quality').options].some(option => option.value === size);
|
|
|
setValue('quality', preset ? size : 'custom');
|
|
|
}
|
|
|
function packetInteger(id, fallback) {
|
|
|
const text = String($(id).value || '').trim();
|
|
|
if (!text) return fallback;
|
|
|
const parsed = Number(text);
|
|
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
|
|
|
}
|
|
|
function normalizePacketLayout(value) {
|
|
|
const source = Array.isArray(value) ? value : DEFAULT_PACKET_LAYOUT;
|
|
|
const result = [];
|
|
|
let remaining = 1024;
|
|
|
for (const item of source.slice(0, 128)) {
|
|
|
if (!item || typeof item !== 'object' || remaining <= 0) continue;
|
|
|
const role = PACKET_ROLES[item.role] ? item.role : 'skip';
|
|
|
const maximum = Math.min(remaining, role === 'skip' ? 1024 : 8);
|
|
|
const parsed = Math.trunc(Number(item.size));
|
|
|
const size = Math.max(1, Math.min(maximum, Number.isFinite(parsed) ? parsed : 1));
|
|
|
result.push({role, size, label: String(item.label || '').trim().slice(0, 64)});
|
|
|
remaining -= size;
|
|
|
}
|
|
|
return result;
|
|
|
}
|
|
|
function packetLayoutFromSchema(value) {
|
|
|
const schema = {...DEFAULT_PACKET_SCHEMA, ...(value || {})};
|
|
|
const fields = [];
|
|
|
for (const [role, offsetName, sizeName] of [
|
|
|
['flags', 'flags_offset', 'flags_size'],
|
|
|
['sequence', 'sequence_offset', 'sequence_size'],
|
|
|
['packet_number', 'packet_number_offset', 'packet_number_size'],
|
|
|
['value', 'value_offset', 'value_size']
|
|
|
]) {
|
|
|
const offset = Number(schema[offsetName]);
|
|
|
if (Number.isFinite(offset) && offset >= 0) {
|
|
|
fields.push({offset, size: Number(schema[sizeName]) || 1, role, label: ''});
|
|
|
}
|
|
|
}
|
|
|
for (const field of schema.read_fields || []) {
|
|
|
fields.push({
|
|
|
offset: Number(field.offset) || 0,
|
|
|
size: Number(field.size) || 1,
|
|
|
role: 'field',
|
|
|
label: String(field.name || '')
|
|
|
});
|
|
|
}
|
|
|
fields.sort((left, right) => left.offset - right.offset);
|
|
|
const result = [];
|
|
|
let cursor = 0;
|
|
|
for (const field of fields) {
|
|
|
if (field.offset < cursor) continue;
|
|
|
if (field.offset > cursor) result.push({role: 'skip', size: field.offset - cursor, label: ''});
|
|
|
result.push({role: field.role, size: field.size, label: field.label});
|
|
|
cursor = field.offset + field.size;
|
|
|
}
|
|
|
const headerSize = Math.max(0, Number(schema.header_size) || 0);
|
|
|
if (cursor < headerSize) result.push({role: 'skip', size: headerSize - cursor, label: ''});
|
|
|
return normalizePacketLayout(result);
|
|
|
}
|
|
|
function collectPacketSchema() {
|
|
|
const schema = {
|
|
|
...DEFAULT_PACKET_SCHEMA,
|
|
|
assembly: $('packetAssembly').value,
|
|
|
payload_format: $('packetPayloadFormat').value,
|
|
|
header_size: 0,
|
|
|
byte_order: $('packetByteOrder').value,
|
|
|
start_mask: packetInteger('packetStartMask', 2),
|
|
|
end_mask: packetInteger('packetEndMask', 1),
|
|
|
value_mode: $('packetValueMode').value,
|
|
|
flags_offset: -1,
|
|
|
sequence_offset: -1,
|
|
|
packet_number_offset: -1,
|
|
|
value_offset: -1,
|
|
|
read_fields: []
|
|
|
};
|
|
|
const roleFields = {
|
|
|
flags: ['flags_offset', 'flags_size'],
|
|
|
sequence: ['sequence_offset', 'sequence_size'],
|
|
|
packet_number: ['packet_number_offset', 'packet_number_size'],
|
|
|
value: ['value_offset', 'value_size']
|
|
|
};
|
|
|
const used = new Set();
|
|
|
let offset = 0;
|
|
|
for (const field of packetLayout) {
|
|
|
if (roleFields[field.role] && !used.has(field.role)) {
|
|
|
const [offsetName, sizeName] = roleFields[field.role];
|
|
|
schema[offsetName] = offset;
|
|
|
schema[sizeName] = field.size;
|
|
|
used.add(field.role);
|
|
|
} else if (field.role === 'field') {
|
|
|
schema.read_fields.push({
|
|
|
name: field.label || `field_${offset}`,
|
|
|
offset,
|
|
|
size: field.size
|
|
|
});
|
|
|
}
|
|
|
offset += field.size;
|
|
|
}
|
|
|
schema.header_size = offset;
|
|
|
return schema;
|
|
|
}
|
|
|
function renderPacketVisuals() {
|
|
|
const schema = collectPacketSchema();
|
|
|
const offsets = [];
|
|
|
let offset = 0;
|
|
|
for (const field of packetLayout) {
|
|
|
offsets.push(offset);
|
|
|
offset += field.size;
|
|
|
}
|
|
|
setValue('packetHeaderSize', schema.header_size);
|
|
|
setText('packetHeaderSummary', `Заголовок: ${schema.header_size} байт`);
|
|
|
|
|
|
const duplicateRoles = ['flags', 'sequence', 'packet_number', 'value']
|
|
|
.filter(role => packetLayout.filter(field => field.role === role).length > 1);
|
|
|
let status = '';
|
|
|
if (duplicateRoles.length) {
|
|
|
status = `Повторяются назначения: ${duplicateRoles.map(role => PACKET_ROLES[role].short).join(', ')}`;
|
|
|
} else if (
|
|
|
schema.assembly === 'fragmented'
|
|
|
&& schema.flags_offset < 0
|
|
|
&& (schema.start_mask || schema.end_mask)
|
|
|
) {
|
|
|
status = 'Для сборки фрагментов назначьте поле Flags';
|
|
|
} else if (packetLayout.some(field => field.role === 'field' && !field.label)) {
|
|
|
status = 'Задайте имя читаемому полю';
|
|
|
}
|
|
|
setText('packetLayoutStatus', status || `${packetLayout.length} полей · payload с байта ${schema.header_size}`);
|
|
|
$('packetLayoutStatus').classList.toggle('warn', !!status);
|
|
|
|
|
|
const visibleLimit = 128;
|
|
|
const hexWidth = schema.header_size > 255 ? 3 : 2;
|
|
|
const tiles = [];
|
|
|
let shown = 0;
|
|
|
packetLayout.forEach((field, fieldIndex) => {
|
|
|
const role = PACKET_ROLES[field.role];
|
|
|
const name = field.label || role.short;
|
|
|
for (let index = 0; index < field.size && shown < visibleLimit; index += 1) {
|
|
|
const byteOffset = offsets[fieldIndex] + index;
|
|
|
tiles.push(
|
|
|
`<div class="packet-byte packet-byte-${field.role}" title="${esc(name)} · байт ${byteOffset}">`
|
|
|
+ `<small>0x${byteOffset.toString(16).toUpperCase().padStart(hexWidth, '0')}</small>`
|
|
|
+ `<b>${esc(name)}</b></div>`
|
|
|
);
|
|
|
shown += 1;
|
|
|
}
|
|
|
});
|
|
|
if (schema.header_size > visibleLimit) {
|
|
|
tiles.push(`<div class="packet-byte packet-byte-skip"><small>...</small><b>+${schema.header_size - visibleLimit}</b></div>`);
|
|
|
}
|
|
|
tiles.push(
|
|
|
`<div class="packet-byte packet-byte-payload" title="Начало полезных данных">`
|
|
|
+ `<small>0x${schema.header_size.toString(16).toUpperCase().padStart(hexWidth, '0')}</small><b>PAYLOAD</b></div>`
|
|
|
);
|
|
|
$('packetByteMap').innerHTML = tiles.join('');
|
|
|
}
|
|
|
function renderPacketRows() {
|
|
|
let offset = 0;
|
|
|
$('packetFieldList').innerHTML = packetLayout.map((field, index) => {
|
|
|
const start = offset;
|
|
|
offset += field.size;
|
|
|
const usedElsewhere = new Set(
|
|
|
packetLayout
|
|
|
.filter((_item, otherIndex) => otherIndex !== index)
|
|
|
.map(item => item.role)
|
|
|
);
|
|
|
const options = Object.entries(PACKET_ROLES).map(([value, role]) => {
|
|
|
const unique = !['skip', 'field'].includes(value);
|
|
|
const disabled = unique && usedElsewhere.has(value) ? ' disabled' : '';
|
|
|
return `<option value="${value}"${field.role === value ? ' selected' : ''}${disabled}>${role.label}</option>`;
|
|
|
}).join('');
|
|
|
return `
|
|
|
<div class="packet-field-row" data-index="${index}">
|
|
|
<div class="packet-field-order">
|
|
|
<button class="packet-icon" type="button" data-action="up" data-index="${index}" title="Переместить влево" aria-label="Переместить влево"${index === 0 ? ' disabled' : ''}>↑</button>
|
|
|
<button class="packet-icon" type="button" data-action="down" data-index="${index}" title="Переместить вправо" aria-label="Переместить вправо"${index === packetLayout.length - 1 ? ' disabled' : ''}>↓</button>
|
|
|
</div>
|
|
|
<label>Название<input type="text" maxlength="64" data-field="label" data-index="${index}" value="${esc(field.label)}"></label>
|
|
|
<label class="packet-role">Назначение<select data-field="role" data-index="${index}">${options}</select></label>
|
|
|
<label>Байты<input type="number" min="1" max="${field.role === 'skip' ? 1024 : 8}" step="1" data-field="size" data-index="${index}" value="${field.size}"></label>
|
|
|
<span class="packet-field-range">0x${start.toString(16).toUpperCase()}–0x${(offset - 1).toString(16).toUpperCase()}</span>
|
|
|
<button class="packet-icon packet-remove" type="button" data-action="remove" data-index="${index}" title="Удалить поле и его байты" aria-label="Удалить поле">×</button>
|
|
|
</div>`;
|
|
|
}).join('');
|
|
|
}
|
|
|
function renderPacketBuilder() {
|
|
|
packetLayout = normalizePacketLayout(packetLayout);
|
|
|
renderPacketRows();
|
|
|
renderPacketVisuals();
|
|
|
}
|
|
|
function renderPacketSchema(value, layout) {
|
|
|
const schema = {...DEFAULT_PACKET_SCHEMA, ...(value || {})};
|
|
|
setValue('packetAssembly', schema.assembly);
|
|
|
setValue('packetPayloadFormat', schema.payload_format);
|
|
|
setValue('packetByteOrder', schema.byte_order);
|
|
|
setValue('packetStartMask', `0x${Number(schema.start_mask).toString(16).padStart(2, '0')}`);
|
|
|
setValue('packetEndMask', `0x${Number(schema.end_mask).toString(16).padStart(2, '0')}`);
|
|
|
setValue('packetValueMode', schema.value_mode);
|
|
|
packetLayout = normalizePacketLayout(Array.isArray(layout) ? layout : packetLayoutFromSchema(schema));
|
|
|
renderPacketBuilder();
|
|
|
}
|
|
|
function packetPresetForMode(mode) {
|
|
|
return {
|
|
|
udp_mik_live: 'mik',
|
|
|
udp_delimited_live: 'delimited',
|
|
|
udp_custom_live: 'custom'
|
|
|
}[mode] || 'auto';
|
|
|
}
|
|
|
function applyPacketPreset(preset) {
|
|
|
const mode = {
|
|
|
mik: 'udp_mik_live',
|
|
|
delimited: 'udp_delimited_live',
|
|
|
custom: 'udp_custom_live'
|
|
|
}[preset];
|
|
|
if (mode) setValue('sourceMode', mode);
|
|
|
syncSourceFields();
|
|
|
controlDirty = true;
|
|
|
}
|
|
|
function syncSourceFields() {
|
|
|
const mode = $('sourceMode').value;
|
|
|
const camera = mode === 'camera';
|
|
|
const live = ['udp_mik_live', 'udp_delimited_live', 'udp_custom_live'].includes(mode);
|
|
|
const custom = mode === 'udp_custom_live';
|
|
|
const delimited = mode === 'udp_delimited_live' || mode === 'udp_delimited_file' || custom;
|
|
|
const dumpFile = mode === 'udp_dump' || mode === 'udp_delimited_file';
|
|
|
showFormBlock('cameraFields', true);
|
|
|
showFormBlock('cameraIndexLabel', camera);
|
|
|
showFormBlock('fileFields', !(camera || live));
|
|
|
showFormBlock('udpNetworkFields', live);
|
|
|
showFormBlock('delimiterFields', delimited);
|
|
|
showFormBlock('packetConstructorFields', custom);
|
|
|
showFormBlock('uploadRunVideo', !(camera || live));
|
|
|
$('videoUpload').accept = '';
|
|
|
setText('inputListLabel', dumpFile ? 'UDP-лог из папки' : 'Видео из папки');
|
|
|
setText('browseVideo', dumpFile ? 'выбрать UDP-дамп...' : 'обзор...');
|
|
|
setText('filePathLabel', dumpFile ? 'Путь к UDP-логу' : 'Путь к видео');
|
|
|
setText('dropZone', dumpFile ? 'перетащите UDP-лог сюда' : 'перетащите видео сюда');
|
|
|
setText('uploadRunVideo', dumpFile ? 'загрузить выбранный UDP-дамп' : 'загрузить выбранное видео');
|
|
|
if (configCanvas) window.requestAnimationFrame(() => {
|
|
|
let fitted = false;
|
|
|
configBlocks.forEach(block => { fitted = fitConfigBlockContent(block) || fitted; updateConfigBlockScale(block); });
|
|
|
if (fitted) saveConfigLayout();
|
|
|
updateConfigCanvasHeight();
|
|
|
});
|
|
|
}
|
|
|
function collectControl() {
|
|
|
return {
|
|
|
model_path: $('modelPath').value.trim(),
|
|
|
device: Number($('modelDevice').value || 0),
|
|
|
use_half: $('modelHalf').checked,
|
|
|
conf: Number($('modelConf').value || 0.25),
|
|
|
img_size_roi: Number($('modelRoi').value || 640),
|
|
|
img_size_full: Number($('modelFull').value || 1280),
|
|
|
max_det: Number($('modelMaxDet').value || 60),
|
|
|
source_mode: $('sourceMode').value,
|
|
|
camera_index: Number($('cameraIndex').value || 0),
|
|
|
file_path: $('filePath').value.trim(),
|
|
|
input_host: $('inputHost').value.trim(),
|
|
|
input_port: Number($('inputPort').value || 59004),
|
|
|
separator_byte: Number($('separatorByte').value || 0),
|
|
|
frame_encoding: $('frameEncoding').value,
|
|
|
packet_preset: $('packetPreset').value,
|
|
|
packet_layout: packetLayout.map(field => ({...field})),
|
|
|
packet_schema: collectPacketSchema(),
|
|
|
quality: frameSizeValue(),
|
|
|
fps: Number($('fps').value || 30),
|
|
|
run_mode: $('runMode').value,
|
|
|
frame_mode: $('frameMode').value,
|
|
|
save: $('saveRecord').checked,
|
|
|
archive_mode: $('archiveMode').value,
|
|
|
fragment_gap_sec: Number($('fragmentGapSec').value || 15),
|
|
|
error_output: $('errorOutput').checked,
|
|
|
error_protocol: $('errorProtocol').value,
|
|
|
error_host: $('errorHost').value.trim(),
|
|
|
error_port: Number($('errorPort').value || 5010),
|
|
|
error_object_id: Number($('errorObjectId').value || 1),
|
|
|
error_units: $('errorUnits').value,
|
|
|
error_hfov: Number($('errorHfov').value || 90),
|
|
|
error_vfov: Number($('errorVfov').value || 60),
|
|
|
error_range_m: Number($('errorRangeM').value || 0)
|
|
|
};
|
|
|
}
|
|
|
const THEMES = {
|
|
|
dark: {bg: '#111315', surface: '#171a1e', panel: '#14171b', input: '#101317', stage: '#050607', text: '#edf1f5', muted: '#8e99a6', border: '#2b3037'},
|
|
|
light: {bg: '#dfe7ee', surface: '#e9eff4', panel: '#e5ecf2', input: '#d8e2eb', stage: '#c7d4df', text: '#172331', muted: '#4f6070', border: '#adbdcb'},
|
|
|
amber: {bg: '#17130d', surface: '#211a10', panel: '#211a10', input: '#120e08', stage: '#090705', text: '#fff0cf', muted: '#c7a873', border: '#584326'},
|
|
|
midnight: {bg: '#08111f', surface: '#0d1b2e', panel: '#10233a', input: '#07101d', stage: '#030711', text: '#e8f3ff', muted: '#8ba5c2', border: '#1c3c5f'},
|
|
|
forest: {bg: '#0d1713', surface: '#12231b', panel: '#10201a', input: '#09140f', stage: '#040806', text: '#e9fff1', muted: '#91b7a0', border: '#28523c'},
|
|
|
rose: {bg: '#1b1016', surface: '#291620', panel: '#25141e', input: '#140a10', stage: '#080406', text: '#ffeaf3', muted: '#c99aad', border: '#573043'},
|
|
|
graphite: {bg: '#15171a', surface: '#1d2024', panel: '#1a1d21', input: '#111316', stage: '#090a0c', text: '#f0f2f4', muted: '#9da3ab', border: '#353a41'},
|
|
|
solarized: {bg: '#fdf6e3', surface: '#eee8d5', panel: '#f5efdc', input: '#fffaf0', stage: '#e6dfca', text: '#34434b', muted: '#657379', border: '#c8bfa9'},
|
|
|
ocean: {bg: '#071a24', surface: '#0b2734', panel: '#0d222d', input: '#05131b', stage: '#020b10', text: '#e5f7fb', muted: '#85afba', border: '#1a4a58'},
|
|
|
neon: {bg: '#10091b', surface: '#1a0d2c', panel: '#170d26', input: '#0b0612', stage: '#050208', text: '#f6eaff', muted: '#b99bd1', border: '#4e2a70'},
|
|
|
copper: {bg: '#1b120f', surface: '#291a15', panel: '#241713', input: '#110a08', stage: '#080504', text: '#fff0e7', muted: '#c59b87', border: '#63402f'},
|
|
|
mono: {bg: '#101010', surface: '#191919', panel: '#161616', input: '#0a0a0a', stage: '#030303', text: '#f1f1f1', muted: '#9a9a9a', border: '#393939'}
|
|
|
};
|
|
|
const ACCENTS = {
|
|
|
blue: '#8fb8ff', cyan: '#67e8f9', green: '#7ee787', violet: '#c4b5fd', orange: '#ffb86b', red: '#ff8e9e', pink: '#f9a8d4', lime: '#b7f36b', indigo: '#818cf8', teal: '#2dd4bf', gold: '#facc15', sky: '#38bdf8', coral: '#fb7185'
|
|
|
};
|
|
|
const THEME_ACCENTS = {
|
|
|
dark: '#8fb8ff', light: '#4778b8', amber: '#f2b84b', midnight: '#5aa7ff', forest: '#56c788', rose: '#ef78ad', graphite: '#9bbcff', solarized: '#b58900', ocean: '#38c6df', neon: '#d38cff', copper: '#e08a5b', mono: '#d2d7dd'
|
|
|
};
|
|
|
const FONT_STACKS = {
|
|
|
system: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
|
inter: 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
|
manrope: 'Manrope, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
|
plex: '"IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
|
mono: '"SFMono-Regular", Consolas, "Liberation Mono", monospace'
|
|
|
};
|
|
|
const APPEARANCE_DEFAULTS = {font: 'system', fontSize: '100', density: 'balanced', radius: 'soft', motion: 'subtle', contrast: 'normal', cardStyle: 'soft', decor: '42'};
|
|
|
const APPEARANCE_LABELS = {
|
|
|
density: {compact: 'Компактная', balanced: 'Сбалансированная', spacious: 'Свободная'},
|
|
|
radius: {sharp: 'Чёткое', soft: 'Мягкое', round: 'Круглое'},
|
|
|
motion: {none: 'Без', subtle: 'Плавная', expressive: 'Выразительная'},
|
|
|
contrast: {soft: 'Мягкий', normal: 'Обычный', high: 'Высокий'},
|
|
|
cardStyle: {flat: 'Плоские', soft: 'Мягкие', neon: 'Неон'}
|
|
|
};
|
|
|
function mixHex(first, second, amount) {
|
|
|
const parse = value => [0, 2, 4].map(offset => parseInt(value.slice(offset + 1, offset + 3), 16));
|
|
|
const a = parse(first); const b = parse(second); const t = Math.max(0, Math.min(1, amount));
|
|
|
return '#' + a.map((value, index) => Math.round(value * (1 - t) + b[index] * t).toString(16).padStart(2, '0')).join('');
|
|
|
}
|
|
|
function applyAccent(name, custom, intensity) {
|
|
|
const key = Object.prototype.hasOwnProperty.call(ACCENTS, name) || name === 'custom' ? name : 'blue';
|
|
|
const base = key === 'custom' && /^#[0-9a-f]{6}$/i.test(custom || '') ? custom : (ACCENTS[key] || ACCENTS.blue);
|
|
|
const rawIntensity = Number(intensity ?? ($('accentIntensity').value || 100));
|
|
|
const strength = Math.max(0.55, Math.min(1, rawIntensity / 100));
|
|
|
const vivid = mixHex(base, '#ffffff', (1 - strength) * .35);
|
|
|
const root = document.documentElement;
|
|
|
root.style.setProperty('--ui-accent', vivid);
|
|
|
root.style.setProperty('--ui-accent-border', mixHex(vivid, '#000000', .32));
|
|
|
root.style.setProperty('--ui-accent-bg', mixHex(vivid, document.documentElement.style.getPropertyValue('--ui-input') || '#101317', .78));
|
|
|
setValue('accentSelect', key);
|
|
|
$('accentCustom').value = base;
|
|
|
setValue('accentIntensity', Math.round(strength * 100));
|
|
|
try {
|
|
|
localStorage.setItem('fpv-accent', key);
|
|
|
localStorage.setItem('fpv-accent-custom', base);
|
|
|
localStorage.setItem('fpv-accent-intensity', String(Math.round(strength * 100)));
|
|
|
} catch (_error) {}
|
|
|
syncAppearanceModule();
|
|
|
}
|
|
|
function applyTheme(name) {
|
|
|
const theme = Object.prototype.hasOwnProperty.call(THEMES, name) ? name : 'dark';
|
|
|
const palette = THEMES[theme];
|
|
|
const root = document.documentElement;
|
|
|
Object.entries(palette).forEach(([key, value]) => root.style.setProperty(`--ui-${key}`, value));
|
|
|
document.body.dataset.theme = theme;
|
|
|
setValue('themeSelect', theme);
|
|
|
applyContrastVariables(document.body.dataset.contrast || APPEARANCE_DEFAULTS.contrast);
|
|
|
try { localStorage.setItem('fpv-theme', theme); } catch (_error) {}
|
|
|
applyAccent($('accentSelect').value || 'blue', $('accentCustom').value);
|
|
|
}
|
|
|
function setAppearanceOpen(open) {
|
|
|
const panel = $('appearancePanel');
|
|
|
const toggle = $('appearanceToggle');
|
|
|
if (!panel || !toggle) return;
|
|
|
panel.hidden = !open;
|
|
|
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
|
}
|
|
|
function applyAppearanceSetting(name, value) {
|
|
|
const root = document.documentElement;
|
|
|
let next = String(value ?? APPEARANCE_DEFAULTS[name] ?? '');
|
|
|
if (name === 'font' && !Object.prototype.hasOwnProperty.call(FONT_STACKS, next)) next = APPEARANCE_DEFAULTS.font;
|
|
|
if (Object.prototype.hasOwnProperty.call(APPEARANCE_LABELS, name) && !Object.prototype.hasOwnProperty.call(APPEARANCE_LABELS[name], next)) next = APPEARANCE_DEFAULTS[name];
|
|
|
if (name === 'fontSize') next = String(Math.max(90, Math.min(120, Number(next) || 100)));
|
|
|
if (name === 'decor') next = String(Math.max(0, Math.min(100, Number(next) || 0)));
|
|
|
if (name === 'font') root.style.setProperty('--ui-font', FONT_STACKS[next]);
|
|
|
if (name === 'fontSize') root.style.setProperty('--ui-font-scale', (Number(next) / 100).toFixed(2));
|
|
|
if (name === 'decor') root.style.setProperty('--ui-decor', next);
|
|
|
if (name === 'contrast') applyContrastVariables(next);
|
|
|
if (name === 'density' || name === 'radius' || name === 'motion' || name === 'contrast' || name === 'cardStyle') document.body.dataset[name] = next;
|
|
|
if (name === 'font') setValue('fontSelect', next);
|
|
|
if (name === 'fontSize') setValue('fontSize', next);
|
|
|
if (name === 'decor') setValue('decorIntensity', next);
|
|
|
try { localStorage.setItem(`fpv-${name === 'fontSize' ? 'font-size' : name}`, next); } catch (_error) {}
|
|
|
syncAppearanceModule();
|
|
|
}
|
|
|
function applyContrastVariables(name) {
|
|
|
const theme = THEMES[$('themeSelect')?.value] || THEMES.dark;
|
|
|
const root = document.documentElement;
|
|
|
const muted = name === 'soft' ? mixHex(theme.muted, theme.surface, .26) : name === 'high' ? mixHex(theme.muted, theme.text, .52) : theme.muted;
|
|
|
const border = name === 'soft' ? mixHex(theme.border, theme.surface, .28) : name === 'high' ? mixHex(theme.border, theme.text, .42) : theme.border;
|
|
|
root.style.setProperty('--ui-muted-contrast', muted);
|
|
|
root.style.setProperty('--ui-border-contrast', border);
|
|
|
}
|
|
|
function restoreAppearanceSettings() {
|
|
|
for (const [name, fallback] of Object.entries(APPEARANCE_DEFAULTS)) {
|
|
|
let value = fallback;
|
|
|
try { value = localStorage.getItem(`fpv-${name === 'fontSize' ? 'font-size' : name}`) || fallback; } catch (_error) {}
|
|
|
applyAppearanceSetting(name, value);
|
|
|
}
|
|
|
}
|
|
|
function syncAppearanceModule() {
|
|
|
const theme = $('themeSelect')?.value || 'dark';
|
|
|
const accent = $('accentSelect')?.value || 'blue';
|
|
|
const themeOption = [...($('themeSelect')?.options || [])].find(option => option.value === theme);
|
|
|
const accentOption = [...($('accentSelect')?.options || [])].find(option => option.value === accent);
|
|
|
const themeLabel = themeOption?.textContent || theme;
|
|
|
const accentLabel = accentOption?.textContent || (accent === 'custom' ? 'Свой цвет' : accent);
|
|
|
setText('appearanceValue', `${themeLabel} · ${accentLabel}`);
|
|
|
setText('themePreview', themeLabel);
|
|
|
setText('accentPreview', accentLabel);
|
|
|
setText('accentIntensityValue', `${$('accentIntensity')?.value || 100}%`);
|
|
|
const font = $('fontSelect')?.value || APPEARANCE_DEFAULTS.font;
|
|
|
setText('interfacePreview', `${$('fontSelect')?.selectedOptions[0]?.textContent || font} · ${$('fontSize')?.value || 100}%`);
|
|
|
setText('fontSizeValue', `${$('fontSize')?.value || 100}%`);
|
|
|
setText('decorValue', `${$('decorIntensity')?.value || 42}%`);
|
|
|
setText('appearancePreviewTitle', `${themeLabel} · ${accentLabel}`);
|
|
|
setText('appearancePreviewMeta', `${$('fontSelect')?.selectedOptions[0]?.textContent || font} · ${$('fontSize')?.value || 100}% · ${APPEARANCE_LABELS.density[document.body.dataset.density || APPEARANCE_DEFAULTS.density] || ''}`);
|
|
|
setText('appearancePreviewStatus', `акцент ${$('accentIntensity')?.value || 100}%`);
|
|
|
for (const name of ['density', 'radius', 'motion', 'contrast', 'cardStyle']) setText(`${name}Preview`, APPEARANCE_LABELS[name]?.[document.body.dataset[name] || APPEARANCE_DEFAULTS[name]] || '');
|
|
|
const motion = document.body.dataset.motion || APPEARANCE_DEFAULTS.motion;
|
|
|
const radius = document.body.dataset.radius || APPEARANCE_DEFAULTS.radius;
|
|
|
setText('stylePreview', `${APPEARANCE_LABELS.radius[radius] || ''} · ${APPEARANCE_LABELS.motion[motion] || ''}`);
|
|
|
const dot = $('appearanceToggle')?.querySelector('.appearance-trigger-dot');
|
|
|
if (dot) dot.style.background = document.documentElement.style.getPropertyValue('--ui-accent') || ACCENTS.blue;
|
|
|
document.querySelectorAll('#themeOptions [data-theme-value]').forEach(button => {
|
|
|
const active = button.dataset.themeValue === theme;
|
|
|
button.classList.toggle('is-active', active);
|
|
|
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
|
});
|
|
|
document.querySelectorAll('#accentOptions [data-accent-value]').forEach(button => {
|
|
|
const active = button.dataset.accentValue === accent;
|
|
|
button.classList.toggle('is-active', active);
|
|
|
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
|
});
|
|
|
document.querySelectorAll('[data-setting]').forEach(button => button.classList.toggle('is-active', button.dataset.value === (document.body.dataset[button.dataset.setting] || (button.dataset.setting === 'fontSize' ? $('fontSize')?.value : APPEARANCE_DEFAULTS[button.dataset.setting]))));
|
|
|
}
|
|
|
function renderAppearanceModule() {
|
|
|
const makeOption = (value, label, kind, colors) => {
|
|
|
const button = document.createElement('button');
|
|
|
button.type = 'button';
|
|
|
button.dataset[`${kind}Value`] = value;
|
|
|
button.setAttribute('role', 'option');
|
|
|
button.setAttribute('aria-label', label);
|
|
|
if (colors[2]) button.style.setProperty('--appearance-option-accent', colors[2]);
|
|
|
const swatch = document.createElement('span');
|
|
|
swatch.className = 'appearance-swatch';
|
|
|
swatch.style.setProperty('--appearance-swatch', colors[0]);
|
|
|
if (colors[1]) swatch.style.setProperty('--appearance-swatch-2', colors[1]);
|
|
|
const text = document.createElement('span');
|
|
|
text.textContent = label;
|
|
|
button.append(swatch, text);
|
|
|
button.addEventListener('click', () => {
|
|
|
if (kind === 'theme') applyTheme(value);
|
|
|
else applyAccent(value, $('accentCustom').value);
|
|
|
});
|
|
|
return button;
|
|
|
};
|
|
|
const themeOptions = $('themeOptions');
|
|
|
const accentOptions = $('accentOptions');
|
|
|
if (!themeOptions || !accentOptions) return;
|
|
|
themeOptions.replaceChildren(...[...$('themeSelect').options].map(option => {
|
|
|
const palette = THEMES[option.value] || THEMES.dark;
|
|
|
return makeOption(option.value, option.textContent, 'theme', [palette.surface, palette.bg, THEME_ACCENTS[option.value] || ACCENTS.blue]);
|
|
|
}));
|
|
|
accentOptions.replaceChildren(...[...$('accentSelect').options].filter(option => option.value !== 'custom').map(option => makeOption(option.value, option.textContent, 'accent', [ACCENTS[option.value] || ACCENTS.blue, '', ACCENTS[option.value] || ACCENTS.blue])));
|
|
|
syncAppearanceModule();
|
|
|
}
|
|
|
async function loadModels() {
|
|
|
const data = await fetch('/api/models', {cache: 'no-store'}).then(r => r.json());
|
|
|
const select = $('modelSelect');
|
|
|
select.innerHTML = '';
|
|
|
for (const model of data.models || []) {
|
|
|
const option = document.createElement('option');
|
|
|
option.value = model.path;
|
|
|
option.textContent = `${model.name} · ${mb(model.size)}`;
|
|
|
select.appendChild(option);
|
|
|
}
|
|
|
const selected = data.selected || data.default || '';
|
|
|
if (!select.options.length) select.innerHTML = '<option value="">модели .pt не найдены</option>';
|
|
|
else if (selected) select.value = selected;
|
|
|
if (select.value) {
|
|
|
setValue('modelPath', select.value);
|
|
|
setText('modelState', `${select.selectedOptions[0]?.textContent || 'модель выбрана'}`);
|
|
|
}
|
|
|
}
|
|
|
function resetNetron() {
|
|
|
$('netronPanel').hidden = true;
|
|
|
$('netronFrame').src = 'about:blank';
|
|
|
setText('netronState', 'граф Netron не загружен');
|
|
|
}
|
|
|
async function openNetron() {
|
|
|
const path = $('modelSelect').value || $('modelPath').value;
|
|
|
if (!path) throw new Error('выберите файл модели .pt');
|
|
|
setText('netronState', 'запуск Netron...');
|
|
|
const response = await fetch('/api/model/netron?path=' + enc(path), {cache: 'no-store'});
|
|
|
const body = await response.json().catch(() => ({}));
|
|
|
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
|
$('netronPanel').hidden = false;
|
|
|
$('netronFrame').src = body.url + '?model=' + enc(body.path || path) + '&v=' + Date.now();
|
|
|
setText('netronState', `${body.path || path} · Netron готов`);
|
|
|
}
|
|
|
async function applyModel() {
|
|
|
const path = $('modelSelect').value;
|
|
|
if (!path) throw new Error('выберите файл модели .pt');
|
|
|
const body = await postJson('/api/model/select', collectControl());
|
|
|
setValue('modelPath', path);
|
|
|
controlDirty = false;
|
|
|
controlLoaded = false;
|
|
|
renderControl(body.control || {});
|
|
|
setText('modelState', 'модель выбрана; параметры применятся при старте');
|
|
|
resetNetron();
|
|
|
}
|
|
|
async function uploadModel() {
|
|
|
const file = $('modelUpload').files[0];
|
|
|
if (!file) return;
|
|
|
if (!file.name.toLowerCase().endsWith('.pt')) throw new Error('нужен файл .pt');
|
|
|
setText('modelState', `загрузка ${file.name}...`);
|
|
|
const response = await fetch('/api/model-upload?name=' + enc(file.name), {
|
|
|
method: 'POST',
|
|
|
headers: {'Content-Type': 'application/octet-stream'},
|
|
|
body: file
|
|
|
});
|
|
|
const body = await response.json().catch(() => ({}));
|
|
|
if (!response.ok || !body.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
|
await loadModels();
|
|
|
resetNetron();
|
|
|
$('modelUpload').value = '';
|
|
|
}
|
|
|
function renderControl(control) {
|
|
|
const c = control || {};
|
|
|
if (!controlLoaded && !controlDirty) {
|
|
|
setValue('modelPath', c.model_path || c.default_model || '');
|
|
|
setValue('modelDevice', c.device ?? 0);
|
|
|
$('modelHalf').checked = c.use_half !== false;
|
|
|
setValue('modelConf', c.conf ?? 0.25);
|
|
|
setValue('modelRoi', c.img_size_roi ?? 640);
|
|
|
setValue('modelFull', c.img_size_full ?? 1280);
|
|
|
setValue('modelMaxDet', c.max_det ?? 60);
|
|
|
setValue('sourceMode', c.source_mode || 'file');
|
|
|
setValue('cameraIndex', c.camera_index ?? 0);
|
|
|
setValue('filePath', c.file_path || c.default_input || '');
|
|
|
setValue('inputHost', c.input_host || '0.0.0.0');
|
|
|
setValue('inputPort', c.input_port || 59004);
|
|
|
setValue('separatorByte', c.separator_byte ?? 0);
|
|
|
setValue('frameEncoding', c.frame_encoding || 'auto');
|
|
|
setValue('packetPreset', c.packet_preset || packetPresetForMode(c.source_mode));
|
|
|
renderPacketSchema(c.packet_schema, c.packet_layout);
|
|
|
setFrameSize(c.quality || '1280x720');
|
|
|
setValue('fps', c.fps || 30);
|
|
|
setValue('runMode', c.run_mode || 'realtime');
|
|
|
setValue('frameMode', c.frame_mode || 'hd');
|
|
|
$('saveRecord').checked = c.save !== false;
|
|
|
setValue('archiveMode', c.archive_mode || 'fragments');
|
|
|
setValue('fragmentGapSec', c.fragment_gap_sec ?? 15);
|
|
|
$('errorOutput').checked = !!c.error_output;
|
|
|
setValue('errorProtocol', c.error_protocol || 'guidance_v1');
|
|
|
setValue('errorHost', c.error_host || 'host.docker.internal');
|
|
|
setValue('errorPort', c.error_port || 5010);
|
|
|
setValue('errorObjectId', c.error_object_id || 1);
|
|
|
setValue('errorUnits', c.error_units || 'px');
|
|
|
setValue('errorHfov', c.error_hfov || 90);
|
|
|
setValue('errorVfov', c.error_vfov || 60);
|
|
|
setValue('errorRangeM', c.error_range_m || 0);
|
|
|
controlLoaded = true;
|
|
|
syncSourceFields();
|
|
|
}
|
|
|
const running = !!c.running;
|
|
|
$('startRun').disabled = running || udpProbeActive;
|
|
|
$('stopRun').disabled = !running;
|
|
|
$('startRun').hidden = running;
|
|
|
$('stopRun').hidden = !running;
|
|
|
$('probeUdp').disabled = running || udpProbeActive;
|
|
|
const view = controlDirty ? collectControl() : c;
|
|
|
const sourceName = String(view.file_path || '').split(/[\\\\/]/).pop();
|
|
|
const sourceLabels = {
|
|
|
udp_mik_live: `UDP МИК ${view.input_host || '0.0.0.0'}:${view.input_port || 59004}`,
|
|
|
udp_delimited_live: `UDP с разделителем ${view.input_host || '0.0.0.0'}:${view.input_port || 59005}`,
|
|
|
udp_custom_live: `UDP пользовательский ${view.input_host || '0.0.0.0'}:${view.input_port || 59005}`,
|
|
|
udp_dump: `Файл МИК: ${sourceName}`,
|
|
|
udp_delimited_file: `Файл с разделителем: ${sourceName}`
|
|
|
};
|
|
|
const source = view.source_mode === 'camera'
|
|
|
? `USB ${view.camera_index ?? 0}`
|
|
|
: (sourceLabels[view.source_mode] || sourceName);
|
|
|
const archive = view.save === false
|
|
|
? 'без записи'
|
|
|
: (view.archive_mode === 'full' ? 'всё видео' : `фрагменты · ${view.fragment_gap_sec ?? 15} с`);
|
|
|
const mode = view.run_mode === 'fast' ? 'быстро' : 'реальное время';
|
|
|
if (!activeUpload) {
|
|
|
setText('quickSummary', `${source || 'источник'} · ${view.quality || '-'} · ${view.fps || '-'} FPS · ${mode} · ${archive}`);
|
|
|
}
|
|
|
}
|
|
|
async function postJson(url, data = {}) {
|
|
|
const r = await fetch(url, {
|
|
|
method: 'POST',
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
body: JSON.stringify(data)
|
|
|
});
|
|
|
const body = await r.json().catch(() => ({}));
|
|
|
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
|
|
|
return body;
|
|
|
}
|
|
|
const UDP_KIND_LABELS = {
|
|
|
mik_video: 'МИК: собранный видеокадр',
|
|
|
mik_fragments: 'МИК: фрагменты массива',
|
|
|
rtp_mpeg_ts: 'RTP с MPEG-TS',
|
|
|
rtp: 'RTP',
|
|
|
mpeg_ts: 'MPEG-TS',
|
|
|
jpeg: 'JPEG-кадры',
|
|
|
png: 'PNG-кадры',
|
|
|
h264_annex_b: 'H.264 Annex B',
|
|
|
h265_annex_b: 'H.265 Annex B',
|
|
|
raw_delimited: 'Raw-кадры с разделителем',
|
|
|
raw_stream: 'Непрерывные raw-кадры',
|
|
|
json: 'JSON',
|
|
|
text: 'Текст',
|
|
|
unknown: 'Формат не определён'
|
|
|
};
|
|
|
function applyUdpRecommendation(detected) {
|
|
|
const recommendation = detected?.recommended;
|
|
|
if (!recommendation || Number(detected.confidence || 0) < 85) return false;
|
|
|
if (recommendation.source_mode) setValue('sourceMode', recommendation.source_mode);
|
|
|
if (recommendation.quality) setFrameSize(recommendation.quality);
|
|
|
if (recommendation.frame_encoding) setValue('frameEncoding', recommendation.frame_encoding);
|
|
|
if (recommendation.separator_byte != null) setValue('separatorByte', recommendation.separator_byte);
|
|
|
setValue('packetPreset', packetPresetForMode($('sourceMode').value));
|
|
|
syncSourceFields();
|
|
|
controlDirty = true;
|
|
|
return true;
|
|
|
}
|
|
|
function renderUdpProbe(data) {
|
|
|
const detected = data.detected || {kind: 'unknown', confidence: 0, evidence: {}};
|
|
|
const label = UDP_KIND_LABELS[detected.kind] || detected.kind || UDP_KIND_LABELS.unknown;
|
|
|
const lines = [
|
|
|
`Прослушано: ${data.listen_host || data.requested_host}:${data.listen_port || data.requested_port} · ${data.elapsed_sec || 0} с`,
|
|
|
`Пакеты: ${data.packets || 0} · payload: ${mb(data.payload_bytes)} · ${data.packets_per_sec || 0} пак/с`
|
|
|
];
|
|
|
if (data.sources?.length) {
|
|
|
lines.push(`Отправители: ${data.sources.map(item => `${item.address} (${item.packets})`).join(', ')}`);
|
|
|
}
|
|
|
if (data.sizes?.length) {
|
|
|
lines.push(`Размеры: ${data.sizes.map(item => `${item.bytes} Б × ${item.packets}`).join(', ')}`);
|
|
|
}
|
|
|
if (detected.frame) {
|
|
|
lines.push(`Кадр: ${detected.frame.width}x${detected.frame.height}${detected.frame.encoding ? ` · ${detected.frame.encoding}` : ''}`);
|
|
|
}
|
|
|
for (const [key, value] of Object.entries(detected.evidence || {})) {
|
|
|
lines.push(`${key}: ${Array.isArray(value) ? value.join(', ') : value}`);
|
|
|
}
|
|
|
if (data.candidates?.length > 1) {
|
|
|
lines.push(`Кандидаты: ${data.candidates.map(item => `${UDP_KIND_LABELS[item.kind] || item.kind} ${item.confidence}%`).join('; ')}`);
|
|
|
}
|
|
|
for (const sample of data.samples || []) {
|
|
|
lines.push('', `Пакет #${sample.index} · ${sample.source} · ${sample.size} Б`);
|
|
|
lines.push(`SHA-256 ${sample.sha256}`);
|
|
|
lines.push(`HEX[0..] ${sample.head_hex || '-'}`);
|
|
|
if (sample.tail_hex) lines.push(`HEX[..end] ${sample.tail_hex}`);
|
|
|
lines.push(`ASCII ${sample.ascii || '-'}`);
|
|
|
}
|
|
|
if (!data.packets) {
|
|
|
lines.push('', 'Датаграммы не пришли. Камера должна отправлять на IP этого компьютера; локальный адрес обычно 0.0.0.0.');
|
|
|
}
|
|
|
const capture = data.exact_capture;
|
|
|
if (capture) {
|
|
|
lines.push('', `Точный дамп: ${capture.bytes} Б`);
|
|
|
lines.push(`SHA-256 ${capture.sha256}`);
|
|
|
$('udpProbeDownload').href = capture.dump_url;
|
|
|
$('udpProbeDownload').download = capture.dump_name;
|
|
|
showFormBlock('udpProbeDownload', true);
|
|
|
} else {
|
|
|
showFormBlock('udpProbeDownload', false);
|
|
|
}
|
|
|
setText('udpProbeTitle', `${label} · уверенность ${detected.confidence || 0}%`);
|
|
|
setText('udpProbeDetails', lines.join('\\n'));
|
|
|
showFormBlock('udpProbeResult', true);
|
|
|
const applied = applyUdpRecommendation(detected);
|
|
|
setText('udpProbeState', applied ? 'формат определён, настройки применены' : `${data.packets || 0} пакетов получено`);
|
|
|
return applied;
|
|
|
}
|
|
|
async function probeUdp() {
|
|
|
if (udpProbeActive) return;
|
|
|
udpProbeActive = true;
|
|
|
$('probeUdp').disabled = true;
|
|
|
showFormBlock('udpProbeResult', false);
|
|
|
setText('udpProbeState', `прослушивание ${$('inputHost').value}:${$('inputPort').value} · 3 с`);
|
|
|
try {
|
|
|
const request = collectControl();
|
|
|
request.duration = 3;
|
|
|
return renderUdpProbe(await postJson('/api/udp-probe', request));
|
|
|
} catch (error) {
|
|
|
setText('udpProbeState', 'ошибка проверки');
|
|
|
setText('udpProbeTitle', 'UDP не прочитан');
|
|
|
setText('udpProbeDetails', error.message);
|
|
|
showFormBlock('udpProbeDownload', false);
|
|
|
showFormBlock('udpProbeResult', true);
|
|
|
} finally {
|
|
|
udpProbeActive = false;
|
|
|
$('probeUdp').disabled = streamRunning;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
async function startRun() {
|
|
|
setText('quickSummary', 'запуск...');
|
|
|
if ($('packetPreset').value === 'auto' && ['udp_mik_live', 'udp_delimited_live', 'udp_custom_live'].includes($('sourceMode').value)) {
|
|
|
const applied = await probeUdp();
|
|
|
if (!applied) throw new Error('UDP не определён: выберите готовый режим или опишите пакет вручную');
|
|
|
}
|
|
|
const body = await postJson('/api/control/start', collectControl());
|
|
|
controlDirty = false;
|
|
|
controlLoaded = false;
|
|
|
renderControl(body);
|
|
|
}
|
|
|
async function stopRun() {
|
|
|
setText('quickSummary', 'остановка...');
|
|
|
const body = await postJson('/api/control/stop', {});
|
|
|
renderControl(body.control || {});
|
|
|
}
|
|
|
function setUploadBusy(busy) {
|
|
|
$('browseVideo').disabled = busy;
|
|
|
$('uploadRunVideo').disabled = busy;
|
|
|
showFormBlock('uploadProgress', busy);
|
|
|
showFormBlock('cancelUpload', busy);
|
|
|
if (!busy) $('uploadProgress').value = 0;
|
|
|
}
|
|
|
function uploadVideo() {
|
|
|
const file = selectedUploadFile || $('videoUpload').files[0];
|
|
|
if (!file) {
|
|
|
setText('quickSummary', 'файл не выбран');
|
|
|
return Promise.resolve();
|
|
|
}
|
|
|
if (activeUpload) return Promise.resolve();
|
|
|
return new Promise((resolve, reject) => {
|
|
|
const params = new URLSearchParams({
|
|
|
name: file.name,
|
|
|
source_mode: $('sourceMode').value,
|
|
|
});
|
|
|
const xhr = new XMLHttpRequest();
|
|
|
let lastProgressAt = 0;
|
|
|
activeUpload = xhr;
|
|
|
setUploadBusy(true);
|
|
|
setText('quickSummary', `загрузка ${file.name}`);
|
|
|
xhr.open('POST', '/api/upload?' + params.toString());
|
|
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
|
|
xhr.upload.onprogress = event => {
|
|
|
const now = performance.now();
|
|
|
if (event.loaded !== event.total && now - lastProgressAt < 200) return;
|
|
|
lastProgressAt = now;
|
|
|
const total = event.lengthComputable ? event.total : file.size;
|
|
|
const percent = total > 0 ? Math.min(100, Math.round(event.loaded * 100 / total)) : 0;
|
|
|
$('uploadProgress').value = percent;
|
|
|
setText('selectedUpload', `${file.name} · ${mb(event.loaded)} / ${mb(total)} · ${percent}%`);
|
|
|
setText('quickSummary', `загрузка · ${percent}%`);
|
|
|
};
|
|
|
xhr.onload = async () => {
|
|
|
let body = {};
|
|
|
try {
|
|
|
body = JSON.parse(xhr.responseText || '{}');
|
|
|
} catch (_error) {
|
|
|
body = {};
|
|
|
}
|
|
|
activeUpload = null;
|
|
|
setUploadBusy(false);
|
|
|
if (xhr.status < 200 || xhr.status >= 300 || !body.ok) {
|
|
|
reject(new Error(body.error || `HTTP ${xhr.status}`));
|
|
|
return;
|
|
|
}
|
|
|
controlDirty = false;
|
|
|
controlLoaded = false;
|
|
|
renderControl(body.control || {});
|
|
|
selectedUploadFile = null;
|
|
|
$('videoUpload').value = '';
|
|
|
setText('selectedUpload', 'файл не выбран');
|
|
|
setText('quickSummary', 'файл загружен');
|
|
|
await loadInputFiles(true);
|
|
|
resolve();
|
|
|
};
|
|
|
xhr.onerror = () => {
|
|
|
activeUpload = null;
|
|
|
setUploadBusy(false);
|
|
|
reject(new Error('ошибка загрузки'));
|
|
|
};
|
|
|
xhr.onabort = () => {
|
|
|
activeUpload = null;
|
|
|
setUploadBusy(false);
|
|
|
setText('quickSummary', 'загрузка отменена');
|
|
|
setText('selectedUpload', `${file.name} · ${mb(file.size)}`);
|
|
|
resolve();
|
|
|
};
|
|
|
xhr.send(file);
|
|
|
});
|
|
|
}
|
|
|
function applyScenario(name) {
|
|
|
const s = SCENARIOS[name];
|
|
|
if (!s) return;
|
|
|
setValue('sourceMode', s.sourceMode);
|
|
|
setValue('packetPreset', packetPresetForMode(s.sourceMode));
|
|
|
if (s.cameraIndex != null) setValue('cameraIndex', s.cameraIndex);
|
|
|
setFrameSize(s.quality);
|
|
|
setValue('fps', s.fps);
|
|
|
setValue('runMode', s.runMode);
|
|
|
setValue('frameMode', s.frameMode);
|
|
|
$('saveRecord').checked = s.save !== false;
|
|
|
if (s.archiveMode) setValue('archiveMode', s.archiveMode);
|
|
|
if (s.inputPort) setValue('inputPort', s.inputPort);
|
|
|
if (s.separatorByte != null) setValue('separatorByte', s.separatorByte);
|
|
|
if (s.frameEncoding) setValue('frameEncoding', s.frameEncoding);
|
|
|
syncSourceFields();
|
|
|
controlDirty = true;
|
|
|
}
|
|
|
async function loadInputFiles(force = false) {
|
|
|
const r = await fetch('/api/input-files', {cache: 'no-store'});
|
|
|
const files = await r.json();
|
|
|
const key = JSON.stringify(files.map(f => [f.path, f.size, f.mtime]));
|
|
|
if (!force && key === inputFilesKey) return;
|
|
|
inputFilesKey = key;
|
|
|
const select = $('inputVideoList');
|
|
|
const current = $('filePath').value;
|
|
|
select.innerHTML = '';
|
|
|
if (!files.length) {
|
|
|
const option = document.createElement('option');
|
|
|
option.value = '';
|
|
|
option.textContent = 'нет видео в папке input';
|
|
|
select.appendChild(option);
|
|
|
return;
|
|
|
}
|
|
|
for (const file of files) {
|
|
|
const option = document.createElement('option');
|
|
|
option.value = file.path;
|
|
|
option.textContent = `${file.kind === 'udp_dump' ? 'UDP · ' : ''}${file.name} · ${mb(file.size)}`;
|
|
|
option.title = file.path;
|
|
|
option.dataset.kind = file.kind || 'video';
|
|
|
select.appendChild(option);
|
|
|
}
|
|
|
if (files.some(f => f.path === current)) {
|
|
|
select.value = current;
|
|
|
} else {
|
|
|
select.value = files[0].path;
|
|
|
setValue('filePath', files[0].path);
|
|
|
}
|
|
|
}
|
|
|
function pickUpload(file) {
|
|
|
if (activeUpload) return;
|
|
|
selectedUploadFile = file || null;
|
|
|
setText('selectedUpload', file ? `${file.name} · ${mb(file.size)}` : 'файл не выбран');
|
|
|
}
|
|
|
function openPlayer(name) {
|
|
|
playerRequestId += 1;
|
|
|
const video = $('archivePlayer');
|
|
|
const message = $('playerMessage');
|
|
|
setText('playerTitle', name);
|
|
|
setText('playerMessage', 'запуск видео...');
|
|
|
message.hidden = false;
|
|
|
video.hidden = true;
|
|
|
video.removeAttribute('src');
|
|
|
video.src = '/archive-play/' + enc(name);
|
|
|
video.load();
|
|
|
$('playerOverlay').hidden = false;
|
|
|
video.play().catch(() => {});
|
|
|
}
|
|
|
function closePlayer() {
|
|
|
playerRequestId += 1;
|
|
|
const video = $('archivePlayer');
|
|
|
video.pause();
|
|
|
video.removeAttribute('src');
|
|
|
video.load();
|
|
|
video.hidden = false;
|
|
|
$('playerMessage').hidden = true;
|
|
|
$('playerOverlay').hidden = true;
|
|
|
}
|
|
|
function switchTab(name, updateHash = true) {
|
|
|
if (!['stream', 'archive', 'settings', 'model'].includes(name)) name = 'stream';
|
|
|
document.querySelectorAll('.tab').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
|
|
|
$('streamTab').hidden = name !== 'stream';
|
|
|
$('archiveTab').hidden = name !== 'archive';
|
|
|
$('settingsTab').hidden = name !== 'settings';
|
|
|
$('modelTab').hidden = name !== 'model';
|
|
|
if (name === 'settings' && typeof applyStoredConfigLayout === 'function') requestAnimationFrame(applyStoredConfigLayout);
|
|
|
if (name === 'model' && typeof applyNetworkLayout === 'function') requestAnimationFrame(applyNetworkLayout);
|
|
|
if (name === 'archive') loadArchive();
|
|
|
if (updateHash && location.hash !== `#${name}`) history.replaceState(null, '', `#${name}`);
|
|
|
}
|
|
|
function setLogsOpen(open) {
|
|
|
$('logDrawer').hidden = !open;
|
|
|
$('logsToggle').classList.toggle('active', open);
|
|
|
$('logsToggle').setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
|
}
|
|
|
function renderMetrics(metrics) {
|
|
|
const root = $('metrics');
|
|
|
for (const [label, value, extra = ''] of metrics) {
|
|
|
const text = value == null ? '-' : String(value);
|
|
|
let row = metricNodes.get(label);
|
|
|
if (!row) {
|
|
|
row = document.createElement('div');
|
|
|
row.innerHTML = `<label></label><b></b>`;
|
|
|
row.querySelector('label').textContent = label;
|
|
|
root.appendChild(row);
|
|
|
metricNodes.set(label, row);
|
|
|
}
|
|
|
const className = `metric${extra}`;
|
|
|
if (row.className !== className) row.className = className;
|
|
|
if (metricValues.get(label) !== text) {
|
|
|
row.querySelector('b').textContent = text;
|
|
|
metricValues.set(label, text);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function scheduleFrame(delay = 0) {
|
|
|
if (streamAttached || frameTimer) return;
|
|
|
frameTimer = setTimeout(() => {
|
|
|
frameTimer = null;
|
|
|
refreshFrame();
|
|
|
}, delay);
|
|
|
}
|
|
|
function reconnectFrame() {
|
|
|
const image = $('frame');
|
|
|
image.onload = null;
|
|
|
image.onerror = null;
|
|
|
image.removeAttribute('src');
|
|
|
streamAttached = false;
|
|
|
lastGoodFrameAt = 0;
|
|
|
if (frameTimer) {
|
|
|
clearTimeout(frameTimer);
|
|
|
frameTimer = null;
|
|
|
}
|
|
|
scheduleFrame(0);
|
|
|
}
|
|
|
function refreshFrame() {
|
|
|
if (streamAttached || document.hidden) return;
|
|
|
const image = $('frame');
|
|
|
streamAttached = true;
|
|
|
image.onload = () => {
|
|
|
lastGoodFrameAt = Date.now();
|
|
|
image.style.visibility = 'visible';
|
|
|
$('empty').style.visibility = 'hidden';
|
|
|
};
|
|
|
image.onerror = () => {
|
|
|
streamAttached = false;
|
|
|
if (streamRunning || haveFrame) scheduleFrame(300);
|
|
|
};
|
|
|
image.src = '/stream.mjpg?t=' + Date.now();
|
|
|
image.style.visibility = 'visible';
|
|
|
$('empty').style.visibility = 'hidden';
|
|
|
lastGoodFrameAt = Date.now();
|
|
|
}
|
|
|
function refreshFallbackFrame() {
|
|
|
if (fallbackLoading || document.hidden) return;
|
|
|
fallbackLoading = true;
|
|
|
const probe = new Image();
|
|
|
probe.onload = () => {
|
|
|
const fallback = $('frameFallback');
|
|
|
fallback.src = probe.src;
|
|
|
fallback.style.visibility = 'visible';
|
|
|
$('empty').style.visibility = 'hidden';
|
|
|
lastGoodFrameAt = Date.now();
|
|
|
fallbackLoading = false;
|
|
|
};
|
|
|
probe.onerror = () => {
|
|
|
fallbackLoading = false;
|
|
|
};
|
|
|
probe.src = '/frame.jpg?t=' + Date.now();
|
|
|
}
|
|
|
async function loadArchive() {
|
|
|
if ($('archiveTab').hidden) return;
|
|
|
const r = await fetch('/api/archive', {cache: 'no-store'});
|
|
|
const files = await r.json();
|
|
|
const key = JSON.stringify(files.map(f => [f.name, f.active ? 0 : f.size, f.active ? 0 : f.mtime, f.active]));
|
|
|
if (key === archiveKey) return;
|
|
|
archiveKey = key;
|
|
|
archiveFiles = files;
|
|
|
for (const name of [...selectedArchive]) {
|
|
|
if (!files.some(f => f.name === name && !f.active)) selectedArchive.delete(name);
|
|
|
}
|
|
|
renderArchive(true);
|
|
|
}
|
|
|
function filteredArchive() {
|
|
|
const query = $('archiveNameFilter').value.trim().toLowerCase();
|
|
|
const from = $('archiveDateFrom').value;
|
|
|
const to = $('archiveDateTo').value;
|
|
|
return archiveFiles.filter(f => {
|
|
|
const date = archiveDate(f.mtime);
|
|
|
return (!query || f.name.toLowerCase().includes(query)) && (!from || date >= from) && (!to || date <= to);
|
|
|
});
|
|
|
}
|
|
|
function updateArchiveActions(files) {
|
|
|
const visibleSelectable = files.filter(f => !f.active).map(f => f.name);
|
|
|
const selectedVisible = visibleSelectable.filter(name => selectedArchive.has(name)).length;
|
|
|
setText('archiveCount', `${files.length} найдено · ${selectedArchive.size} выбрано`);
|
|
|
$('archiveDeleteSelected').disabled = selectedArchive.size === 0;
|
|
|
setText('archiveDeleteSelected', selectedArchive.size ? `удалить выбранные (${selectedArchive.size})` : 'удалить выбранные');
|
|
|
$('archiveSelectVisible').disabled = visibleSelectable.length === 0;
|
|
|
setText('archiveSelectVisible', visibleSelectable.length && selectedVisible === visibleSelectable.length ? 'снять видимые' : 'выбрать видимые');
|
|
|
}
|
|
|
function renderArchive(force = false) {
|
|
|
const files = filteredArchive();
|
|
|
const renderKey = JSON.stringify([
|
|
|
files.map(f => [f.name, f.active ? 0 : f.size, f.active ? 0 : f.mtime, f.active]),
|
|
|
[...selectedArchive].sort(),
|
|
|
$('archiveNameFilter').value,
|
|
|
$('archiveDateFrom').value,
|
|
|
$('archiveDateTo').value,
|
|
|
]);
|
|
|
if (!force && renderKey === archiveRenderKey) {
|
|
|
updateArchiveActions(files);
|
|
|
return;
|
|
|
}
|
|
|
archiveRenderKey = renderKey;
|
|
|
$('archiveList').innerHTML = files.map(f => `
|
|
|
<div class="rec">
|
|
|
<input class="rec-check" type="checkbox" data-select="${esc(f.name)}" ${selectedArchive.has(f.name) ? 'checked' : ''} ${f.active ? 'disabled' : ''}>
|
|
|
<div class="rec-info">
|
|
|
<div class="rec-name" title="${esc(f.name)}">${esc(f.name)}</div>
|
|
|
<div class="rec-meta">${archiveDate(f.mtime)} · ${mb(f.size)}${f.active ? ' · идёт запись' : ''}</div>
|
|
|
</div>
|
|
|
<div class="rec-actions">
|
|
|
${f.active ? '<span class="active-note">пишется</span>' : `
|
|
|
<button data-view="${esc(f.name)}">смотреть</button>
|
|
|
<a href="/archive/${enc(f.name)}?download=1">скачать</a>
|
|
|
<button class="delete" data-name="${esc(f.name)}">удалить</button>
|
|
|
`}
|
|
|
</div>
|
|
|
</div>`).join('');
|
|
|
updateArchiveActions(files);
|
|
|
}
|
|
|
$('archiveList').addEventListener('click', async e => {
|
|
|
const selectName = e.target.dataset.select;
|
|
|
if (selectName) {
|
|
|
if (e.target.checked) selectedArchive.add(selectName);
|
|
|
else selectedArchive.delete(selectName);
|
|
|
renderArchive(true);
|
|
|
return;
|
|
|
}
|
|
|
const viewName = e.target.dataset.view;
|
|
|
if (viewName) {
|
|
|
openPlayer(viewName);
|
|
|
return;
|
|
|
}
|
|
|
const name = e.target.dataset.name;
|
|
|
if (!name) return;
|
|
|
if (!confirm(`Удалить ${name}?`)) return;
|
|
|
const r = await fetch('/api/archive/' + enc(name), {method: 'DELETE'});
|
|
|
if (r.ok) {
|
|
|
selectedArchive.delete(name);
|
|
|
archiveKey = '';
|
|
|
loadArchive();
|
|
|
}
|
|
|
else alert('Не удалось удалить запись');
|
|
|
});
|
|
|
$('archiveNameFilter').addEventListener('input', () => renderArchive());
|
|
|
$('archiveDateFrom').addEventListener('change', () => renderArchive());
|
|
|
$('archiveDateTo').addEventListener('change', () => renderArchive());
|
|
|
$('archiveReset').addEventListener('click', () => {
|
|
|
$('archiveNameFilter').value = '';
|
|
|
$('archiveDateFrom').value = '';
|
|
|
$('archiveDateTo').value = '';
|
|
|
renderArchive();
|
|
|
});
|
|
|
$('archiveSelectVisible').addEventListener('click', () => {
|
|
|
const files = filteredArchive().filter(f => !f.active);
|
|
|
const allSelected = files.length > 0 && files.every(f => selectedArchive.has(f.name));
|
|
|
files.forEach(f => allSelected ? selectedArchive.delete(f.name) : selectedArchive.add(f.name));
|
|
|
renderArchive(true);
|
|
|
});
|
|
|
$('archiveDeleteSelected').addEventListener('click', async () => {
|
|
|
const names = [...selectedArchive];
|
|
|
if (!names.length) return;
|
|
|
if (!confirm(`Удалить выбранные записи: ${names.length}?`)) return;
|
|
|
const results = await Promise.all(names.map(name => fetch('/api/archive/' + enc(name), {method: 'DELETE'})));
|
|
|
const failed = results.filter(r => !r.ok).length;
|
|
|
selectedArchive.clear();
|
|
|
archiveKey = '';
|
|
|
await loadArchive();
|
|
|
if (failed) alert(`Не удалось удалить: ${failed}`);
|
|
|
});
|
|
|
$('playerClose').addEventListener('click', closePlayer);
|
|
|
$('playerOverlay').addEventListener('click', e => {
|
|
|
if (e.target === $('playerOverlay')) closePlayer();
|
|
|
});
|
|
|
document.addEventListener('keydown', e => {
|
|
|
if (e.key === 'Escape') {
|
|
|
if (!$('playerOverlay').hidden) closePlayer();
|
|
|
else if (!$('logDrawer').hidden) setLogsOpen(false);
|
|
|
else if (!$('appearancePanel').hidden) setAppearanceOpen(false);
|
|
|
return;
|
|
|
}
|
|
|
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
|
|
const target = e.target;
|
|
|
if (target instanceof Element && target.closest('input, select, textarea, button, [contenteditable="true"]')) return;
|
|
|
const settingsVisible = !$('settingsTab')?.hidden;
|
|
|
const networkVisible = !$('modelTab')?.hidden;
|
|
|
const removed = settingsVisible ? deleteSelectedConfigBlocks() : networkVisible ? deleteSelectedNetworkBlocks() : false;
|
|
|
if (removed) e.preventDefault();
|
|
|
});
|
|
|
$('logsToggle').addEventListener('click', () => setLogsOpen($('logDrawer').hidden));
|
|
|
$('logsClose').addEventListener('click', () => setLogsOpen(false));
|
|
|
$('appearanceToggle').addEventListener('click', () => setAppearanceOpen($('appearancePanel').hidden));
|
|
|
$('appearanceClose').addEventListener('click', () => setAppearanceOpen(false));
|
|
|
$('appearanceReset').addEventListener('click', () => {
|
|
|
setValue('themeSelect', 'dark');
|
|
|
setValue('accentSelect', 'blue');
|
|
|
$('accentCustom').value = ACCENTS.blue;
|
|
|
setValue('accentIntensity', '100');
|
|
|
applyTheme('dark');
|
|
|
Object.entries(APPEARANCE_DEFAULTS).forEach(([name, value]) => applyAppearanceSetting(name, value));
|
|
|
});
|
|
|
document.addEventListener('pointerdown', e => {
|
|
|
if (!$('appearancePanel').hidden && !e.target.closest('.appearance-module')) setAppearanceOpen(false);
|
|
|
});
|
|
|
document.querySelectorAll('.tab').forEach(btn => {
|
|
|
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
|
|
});
|
|
|
window.addEventListener('hashchange', () => switchTab(location.hash.slice(1), false));
|
|
|
$('themeSelect').addEventListener('change', e => applyTheme(e.target.value));
|
|
|
$('accentSelect').addEventListener('change', e => applyAccent(e.target.value, $('accentCustom').value));
|
|
|
$('accentCustom').addEventListener('input', e => applyAccent('custom', e.target.value));
|
|
|
$('accentIntensity').addEventListener('input', e => applyAccent($('accentSelect').value, $('accentCustom').value, e.target.value));
|
|
|
$('fontSelect').addEventListener('change', e => applyAppearanceSetting('font', e.target.value));
|
|
|
$('fontSize').addEventListener('input', e => applyAppearanceSetting('fontSize', e.target.value));
|
|
|
$('decorIntensity').addEventListener('input', e => applyAppearanceSetting('decor', e.target.value));
|
|
|
document.querySelectorAll('[data-setting]').forEach(button => button.addEventListener('click', () => applyAppearanceSetting(button.dataset.setting, button.dataset.value)));
|
|
|
$('modelSelect').addEventListener('change', e => {
|
|
|
setValue('modelPath', e.target.value);
|
|
|
setText('modelState', e.target.selectedOptions[0]?.textContent || 'модель выбрана');
|
|
|
resetNetron();
|
|
|
controlDirty = true;
|
|
|
});
|
|
|
$('modelApply').addEventListener('click', () => applyModel().catch(e => setText('modelState', e.message)));
|
|
|
$('modelNetron').addEventListener('click', () => openNetron().catch(e => setText('netronState', e.message)));
|
|
|
$('modelBrowse').addEventListener('click', () => $('modelUpload').click());
|
|
|
$('modelUpload').addEventListener('change', () => uploadModel().catch(e => setText('modelState', e.message)));
|
|
|
['modelDevice', 'modelHalf', 'modelConf', 'modelRoi', 'modelFull', 'modelMaxDet'].forEach(id => {
|
|
|
$(id).addEventListener('input', () => { controlDirty = true; });
|
|
|
$(id).addEventListener('change', () => { controlDirty = true; });
|
|
|
});
|
|
|
$('controlForm').addEventListener('input', e => {
|
|
|
if (e.target.id !== 'videoUpload') controlDirty = true;
|
|
|
if (e.target.id === 'sourceMode') {
|
|
|
setValue('packetPreset', packetPresetForMode(e.target.value));
|
|
|
syncSourceFields();
|
|
|
}
|
|
|
if (e.target.id === 'quality') syncFrameSizeFromPreset();
|
|
|
if (e.target.id === 'frameWidth' || e.target.id === 'frameHeight') syncFramePresetFromInputs();
|
|
|
if (['packetAssembly', 'packetStartMask', 'packetEndMask'].includes(e.target.id)) renderPacketVisuals();
|
|
|
});
|
|
|
$('controlForm').addEventListener('change', e => {
|
|
|
if (e.target.id !== 'videoUpload') controlDirty = true;
|
|
|
if (e.target.id === 'sourceMode') {
|
|
|
setValue('packetPreset', packetPresetForMode(e.target.value));
|
|
|
syncSourceFields();
|
|
|
}
|
|
|
if (e.target.id === 'quality') syncFrameSizeFromPreset();
|
|
|
if (e.target.id === 'frameWidth' || e.target.id === 'frameHeight') syncFramePresetFromInputs();
|
|
|
if (['packetAssembly', 'packetStartMask', 'packetEndMask'].includes(e.target.id)) renderPacketVisuals();
|
|
|
});
|
|
|
$('packetFieldList').addEventListener('input', e => {
|
|
|
const index = Number(e.target.dataset.index);
|
|
|
const field = packetLayout[index];
|
|
|
if (!field) return;
|
|
|
if (e.target.dataset.field === 'label') {
|
|
|
field.label = e.target.value.slice(0, 64);
|
|
|
} else if (e.target.dataset.field === 'size') {
|
|
|
const others = packetLayout.reduce((total, item, itemIndex) => total + (itemIndex === index ? 0 : item.size), 0);
|
|
|
const maximum = Math.max(1, Math.min(field.role === 'skip' ? 1024 : 8, 1024 - others));
|
|
|
field.size = Math.max(1, Math.min(maximum, Math.trunc(Number(e.target.value) || 1)));
|
|
|
}
|
|
|
controlDirty = true;
|
|
|
renderPacketVisuals();
|
|
|
});
|
|
|
$('packetFieldList').addEventListener('change', e => {
|
|
|
const index = Number(e.target.dataset.index);
|
|
|
const field = packetLayout[index];
|
|
|
if (!field) return;
|
|
|
if (e.target.dataset.field === 'size') {
|
|
|
renderPacketBuilder();
|
|
|
return;
|
|
|
}
|
|
|
if (e.target.dataset.field !== 'role') return;
|
|
|
field.role = PACKET_ROLES[e.target.value] ? e.target.value : 'skip';
|
|
|
if (field.role !== 'skip') field.size = Math.min(8, field.size);
|
|
|
if (!field.label && field.role !== 'skip') field.label = PACKET_ROLES[field.role].short;
|
|
|
controlDirty = true;
|
|
|
renderPacketBuilder();
|
|
|
});
|
|
|
$('packetFieldList').addEventListener('click', e => {
|
|
|
const button = e.target.closest('button[data-action]');
|
|
|
if (!button) return;
|
|
|
const index = Number(button.dataset.index);
|
|
|
const action = button.dataset.action;
|
|
|
if (action === 'remove') packetLayout.splice(index, 1);
|
|
|
if (action === 'up' && index > 0) {
|
|
|
[packetLayout[index - 1], packetLayout[index]] = [packetLayout[index], packetLayout[index - 1]];
|
|
|
}
|
|
|
if (action === 'down' && index < packetLayout.length - 1) {
|
|
|
[packetLayout[index + 1], packetLayout[index]] = [packetLayout[index], packetLayout[index + 1]];
|
|
|
}
|
|
|
controlDirty = true;
|
|
|
renderPacketBuilder();
|
|
|
});
|
|
|
$('addPacketField').addEventListener('click', () => {
|
|
|
packetLayout.push({role: 'skip', size: 1, label: ''});
|
|
|
controlDirty = true;
|
|
|
renderPacketBuilder();
|
|
|
});
|
|
|
$('clearPacketLayout').addEventListener('click', () => {
|
|
|
packetLayout = [];
|
|
|
controlDirty = true;
|
|
|
renderPacketBuilder();
|
|
|
});
|
|
|
$('packetPreset').addEventListener('change', e => {
|
|
|
applyPacketPreset(e.target.value);
|
|
|
if (e.target.value === 'auto') {
|
|
|
probeUdp().catch(error => setText('udpProbeState', error.message));
|
|
|
}
|
|
|
});
|
|
|
$('scenarioPreset').addEventListener('change', e => applyScenario(e.target.value));
|
|
|
$('inputVideoList').addEventListener('change', e => {
|
|
|
if (e.target.value) {
|
|
|
setValue('filePath', e.target.value);
|
|
|
if (
|
|
|
e.target.selectedOptions[0]?.dataset.kind === 'udp_dump'
|
|
|
&& !['udp_dump', 'udp_delimited_file'].includes($('sourceMode').value)
|
|
|
) {
|
|
|
setValue('sourceMode', 'udp_dump');
|
|
|
syncSourceFields();
|
|
|
}
|
|
|
controlDirty = true;
|
|
|
}
|
|
|
});
|
|
|
$('refreshInputs').addEventListener('click', () => loadInputFiles(true).catch(e => setText('quickSummary', e.message)));
|
|
|
$('probeUdp').addEventListener('click', probeUdp);
|
|
|
$('browseVideo').addEventListener('click', () => $('videoUpload').click());
|
|
|
$('videoUpload').addEventListener('change', e => pickUpload(e.target.files[0]));
|
|
|
$('cancelUpload').addEventListener('click', () => activeUpload?.abort());
|
|
|
$('dropZone').addEventListener('dragover', e => {
|
|
|
e.preventDefault();
|
|
|
$('dropZone').classList.add('drag');
|
|
|
});
|
|
|
$('dropZone').addEventListener('dragleave', () => $('dropZone').classList.remove('drag'));
|
|
|
$('dropZone').addEventListener('drop', e => {
|
|
|
e.preventDefault();
|
|
|
$('dropZone').classList.remove('drag');
|
|
|
pickUpload(e.dataTransfer.files[0]);
|
|
|
});
|
|
|
$('startRun').addEventListener('click', () => startRun().catch(e => setText('quickSummary', e.message)));
|
|
|
$('stopRun').addEventListener('click', () => stopRun().catch(e => setText('quickSummary', e.message)));
|
|
|
$('uploadRunVideo').addEventListener('click', () => uploadVideo().catch(e => setText('quickSummary', e.message)));
|
|
|
$('archivePlayer').addEventListener('loadeddata', () => {
|
|
|
$('archivePlayer').hidden = false;
|
|
|
$('playerMessage').hidden = true;
|
|
|
});
|
|
|
$('archivePlayer').addEventListener('playing', () => {
|
|
|
$('archivePlayer').hidden = false;
|
|
|
$('playerMessage').hidden = true;
|
|
|
});
|
|
|
$('archivePlayer').addEventListener('error', () => {
|
|
|
if ($('playerOverlay').hidden) return;
|
|
|
$('archivePlayer').hidden = true;
|
|
|
setText('playerMessage', 'не удалось воспроизвести видео');
|
|
|
$('playerMessage').hidden = false;
|
|
|
});
|
|
|
|
|
|
async function tick() {
|
|
|
try {
|
|
|
const r = await fetch('/api/status', {cache: 'no-store'});
|
|
|
const s = await r.json();
|
|
|
const g = s.guidance || {};
|
|
|
const now = Date.now();
|
|
|
renderControl(s.control || {});
|
|
|
const wasRunning = streamRunning;
|
|
|
streamRunning = !!(s.control && s.control.running);
|
|
|
if (streamRunning && !wasRunning) reconnectFrame();
|
|
|
|
|
|
if (s.frame_exists) {
|
|
|
haveFrame = true;
|
|
|
frameMissingSince = 0;
|
|
|
} else if (!frameMissingSince) {
|
|
|
frameMissingSince = now;
|
|
|
} else if (!lastGoodFrameAt || now - frameMissingSince > 3000) {
|
|
|
haveFrame = false;
|
|
|
}
|
|
|
const frameFresh = lastGoodFrameAt && now - lastGoodFrameAt < 3000;
|
|
|
setText('state', streamRunning ? 'в эфире' : 'остановлено');
|
|
|
setText('videoName', s.source ? `источник: ${s.source}` : (s.video || ''));
|
|
|
setText('logs', s.logs || '');
|
|
|
|
|
|
if (!s.perf.fps && lastFrameId != null && g.frame_id != null && g.frame_id > lastFrameId) {
|
|
|
lastFps = ((g.frame_id - lastFrameId) * 1000 / Math.max(1, now - lastFrameAt)).toFixed(1);
|
|
|
} else {
|
|
|
lastFps = s.perf.fps || lastFps;
|
|
|
}
|
|
|
lastFrameId = g.frame_id ?? lastFrameId;
|
|
|
lastFrameAt = now;
|
|
|
|
|
|
if (!frameFresh && !haveFrame) {
|
|
|
$('frame').style.visibility = 'hidden';
|
|
|
$('frameFallback').style.visibility = 'hidden';
|
|
|
$('empty').style.visibility = 'visible';
|
|
|
}
|
|
|
if (haveFrame) {
|
|
|
refreshFallbackFrame();
|
|
|
if (streamRunning || !lastGoodFrameAt) scheduleFrame(0);
|
|
|
}
|
|
|
|
|
|
const metrics = [
|
|
|
['состояние', g.status || '-', g.active ? ' ok' : ''],
|
|
|
['кадр/с', lastFps],
|
|
|
['источник', s.source || '-'],
|
|
|
['запись', s.video || '-'],
|
|
|
['yolo p50/p95', `${s.perf.yolo_p50 || 'ждем'} / ${s.perf.yolo_p95 || 'ждем'}`],
|
|
|
['пропуск/легкие', `${s.perf.skip || 0} / ${s.perf.pass || 0}`],
|
|
|
['анализ каждые N', s.perf.analysis_every || '-'],
|
|
|
['уверенность', fmt(g.confidence), g.confidence >= 0.7 ? ' ok' : ''],
|
|
|
['кадр', g.frame_id],
|
|
|
['цель', g.target_id ?? '-'],
|
|
|
['на цели', g.on_target ? 'да' : 'нет', g.on_target ? ' ok' : ' warn'],
|
|
|
['команда x/y', `${fmt(g.cmd_x)} / ${fmt(g.cmd_y)}`],
|
|
|
['модуль команды', mag(g.cmd_x, g.cmd_y)],
|
|
|
['ошибка x/y', `${fmt(g.error_x)} / ${fmt(g.error_y)}`],
|
|
|
['модуль ошибки', mag(g.error_x, g.error_y)],
|
|
|
['руление x/y', `${fmt(g.steer_x)} / ${fmt(g.steer_y)}`],
|
|
|
['взгляд dx/dy', `${fmt(g.look_dx)} / ${fmt(g.look_dy)}`],
|
|
|
['модуль взгляда', mag(g.look_dx, g.look_dy)],
|
|
|
['прицел x/y', `${fmt(g.aim_x, 1)} / ${fmt(g.aim_y, 1)}`],
|
|
|
['бокс w/h', `${fmt(g.box_w, 1)} / ${fmt(g.box_h, 1)}`],
|
|
|
['площадь бокса', g.box_w == null || g.box_h == null ? '-' : Math.round(Number(g.box_w) * Number(g.box_h))],
|
|
|
['размер кадра', `${g.frame_w ?? '-'} / ${g.frame_h ?? '-'}`]
|
|
|
];
|
|
|
renderMetrics(metrics);
|
|
|
} catch (e) {
|
|
|
setText('state', 'нет связи');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
setInterval(tick, 1000);
|
|
|
setInterval(loadArchive, 5000);
|
|
|
setInterval(() => loadInputFiles().catch(() => {}), 10000);
|
|
|
tick();
|
|
|
loadInputFiles(true).catch(() => {});
|
|
|
loadModels().catch(error => setText('modelState', error.message));
|
|
|
try {
|
|
|
setValue('accentSelect', localStorage.getItem('fpv-accent') || 'blue');
|
|
|
$('accentCustom').value = localStorage.getItem('fpv-accent-custom') || '#8fb8ff';
|
|
|
setValue('accentIntensity', localStorage.getItem('fpv-accent-intensity') || '100');
|
|
|
applyTheme(localStorage.getItem('fpv-theme') || 'dark');
|
|
|
} catch (_error) { applyTheme('dark'); }
|
|
|
renderAppearanceModule();
|
|
|
restoreAppearanceSettings();
|
|
|
initCollapsibleSections();
|
|
|
initConfigCanvas();
|
|
|
initNetworkCanvas();
|
|
|
loadArchive();
|
|
|
switchTab(location.hash.slice(1) || 'stream', false);
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|
|
|
"""
|
|
|
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
|
def do_GET(self):
|
|
|
parsed = urlparse(self.path)
|
|
|
path = parsed.path
|
|
|
if path == "/":
|
|
|
return self.send_bytes(HTML.encode("utf-8"), "text/html; charset=utf-8")
|
|
|
if path == "/api/status":
|
|
|
data = json.dumps(status_payload(), ensure_ascii=False).encode("utf-8")
|
|
|
return self.send_bytes(data, "application/json; charset=utf-8")
|
|
|
if path == "/api/control":
|
|
|
return self.send_json(control_payload())
|
|
|
if path == "/api/models":
|
|
|
return self.send_json(model_payload())
|
|
|
if path == "/api/model/netron":
|
|
|
try:
|
|
|
query = parse_qs(parsed.query)
|
|
|
requested = (query.get("path") or [None])[0]
|
|
|
return self.send_json({"ok": True, **ensure_netron(requested)})
|
|
|
except FileNotFoundError as exc:
|
|
|
return self.send_json({"ok": False, "error": f"file not found: {exc}"}, status=404)
|
|
|
except RuntimeError as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=503)
|
|
|
except Exception as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=400)
|
|
|
if path == "/api/model/architecture":
|
|
|
if process_running():
|
|
|
return self.send_json({"ok": False, "error": "остановите инференс перед загрузкой архитектуры"}, status=409)
|
|
|
try:
|
|
|
query = parse_qs(parsed.query)
|
|
|
requested = (query.get("path") or [None])[0]
|
|
|
return self.send_json({"ok": True, "model": inspect_model(requested or model_payload()["selected"])})
|
|
|
except FileNotFoundError as exc:
|
|
|
return self.send_json({"ok": False, "error": f"file not found: {exc}"}, status=404)
|
|
|
except Exception as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=400)
|
|
|
if path == "/netron":
|
|
|
self.send_response(301)
|
|
|
self.send_header("Location", "/netron/")
|
|
|
self.end_headers()
|
|
|
return
|
|
|
if path.startswith("/netron/"):
|
|
|
return self.proxy_netron(parsed)
|
|
|
if path == "/api/input-files":
|
|
|
return self.send_json(input_video_files())
|
|
|
if path == "/api/archive":
|
|
|
active_name = control_payload()["active_video"] or None
|
|
|
data = json.dumps(archive_files(OUT_DIR, active_name), ensure_ascii=False).encode("utf-8")
|
|
|
return self.send_bytes(data, "application/json; charset=utf-8")
|
|
|
if path == "/frame.jpg":
|
|
|
return self.send_file(FRAME_PATH, "image/jpeg")
|
|
|
if path == "/stream.mjpg":
|
|
|
return self.send_mjpeg()
|
|
|
if path.startswith("/udp-probe/"):
|
|
|
probe = udp_probe_path(UDP_PROBE_DIR, path.removeprefix("/udp-probe/"))
|
|
|
if probe and probe.is_file():
|
|
|
return self.send_file(probe, attachment=True, download_name=probe.name)
|
|
|
if path == "/video":
|
|
|
video = latest_file(OUT_DIR, "*.mp4")
|
|
|
if video:
|
|
|
return self.send_file(video)
|
|
|
if path.startswith("/archive-play/"):
|
|
|
archive = archive_path(OUT_DIR, path.removeprefix("/archive-play/"))
|
|
|
active_name = control_payload()["active_video"] or None
|
|
|
if not archive or not archive.exists():
|
|
|
self.send_error(404)
|
|
|
return
|
|
|
if active_name and archive.name == active_name:
|
|
|
self.send_error(409, "active recording")
|
|
|
return
|
|
|
return self.send_transcoded_video(archive)
|
|
|
if path.startswith("/archive/"):
|
|
|
archive = archive_path(OUT_DIR, path.removeprefix("/archive/"))
|
|
|
if archive and archive.exists():
|
|
|
download = parse_qs(parsed.query).get("download") == ["1"]
|
|
|
if download:
|
|
|
active_name = control_payload()["active_video"] or None
|
|
|
if active_name and archive.name == active_name:
|
|
|
self.send_error(409, "active recording")
|
|
|
return
|
|
|
return self.send_h264_download(archive)
|
|
|
return self.send_file(archive)
|
|
|
self.send_error(404)
|
|
|
|
|
|
def do_POST(self):
|
|
|
parsed = urlparse(self.path)
|
|
|
path = parsed.path
|
|
|
if path == "/api/control/start":
|
|
|
try:
|
|
|
return self.send_json(start_control_process(self.read_json_body()))
|
|
|
except FileNotFoundError as exc:
|
|
|
return self.send_json({"ok": False, "error": f"file not found: {exc}"}, status=400)
|
|
|
except ValueError as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=400)
|
|
|
except Exception as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=500)
|
|
|
if path == "/api/control/stop":
|
|
|
with CONTROL_LOCK:
|
|
|
stopped = stop_control_process()
|
|
|
return self.send_json({"ok": True, "stopped": stopped, "control": control_payload()})
|
|
|
if path == "/api/model/select":
|
|
|
try:
|
|
|
return self.send_json({"ok": True, "control": select_model(self.read_json_body())})
|
|
|
except FileNotFoundError as exc:
|
|
|
return self.send_json({"ok": False, "error": f"file not found: {exc}"}, status=404)
|
|
|
except ValueError as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=400)
|
|
|
except RuntimeError as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=409)
|
|
|
if path == "/api/model-upload":
|
|
|
return self.handle_model_upload(parse_qs(parsed.query))
|
|
|
if path == "/api/udp-probe":
|
|
|
if not UDP_PROBE_LOCK.acquire(blocking=False):
|
|
|
return self.send_json({"ok": False, "error": "UDP probe is already running"}, status=409)
|
|
|
try:
|
|
|
with CONTROL_LOCK:
|
|
|
if process_running():
|
|
|
return self.send_json(
|
|
|
{"ok": False, "error": "stop the active stream before UDP probe"},
|
|
|
status=409,
|
|
|
)
|
|
|
return self.send_json({"ok": True, **udp_probe_payload(self.read_json_body())})
|
|
|
except (OSError, ValueError) as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=400)
|
|
|
finally:
|
|
|
UDP_PROBE_LOCK.release()
|
|
|
if path == "/api/upload":
|
|
|
return self.handle_upload(parse_qs(parsed.query))
|
|
|
self.send_error(404)
|
|
|
|
|
|
def do_DELETE(self):
|
|
|
path = urlparse(self.path).path
|
|
|
if path.startswith("/api/archive/"):
|
|
|
archive = archive_path(OUT_DIR, path.removeprefix("/api/archive/"))
|
|
|
active_name = control_payload()["active_video"] or None
|
|
|
if not archive or not archive.exists():
|
|
|
self.send_error(404)
|
|
|
return
|
|
|
if active_name and archive.name == active_name:
|
|
|
self.send_error(409, "active recording")
|
|
|
return
|
|
|
archive.unlink()
|
|
|
delete_h264_cache(archive)
|
|
|
self.send_bytes(b'{"ok": true}', "application/json")
|
|
|
return
|
|
|
self.send_error(404)
|
|
|
|
|
|
def log_message(self, fmt, *args):
|
|
|
return
|
|
|
|
|
|
def proxy_netron(self, parsed):
|
|
|
try:
|
|
|
ensure_netron()
|
|
|
target_path = parsed.path.removeprefix("/netron") or "/"
|
|
|
target = target_path + (f"?{parsed.query}" if parsed.query else "")
|
|
|
upstream_conn = http.client.HTTPConnection(NETRON_HOST, NETRON_PORT, timeout=10)
|
|
|
upstream_conn.request("GET", target, headers={"Host": f"{NETRON_HOST}:{NETRON_PORT}"})
|
|
|
upstream = upstream_conn.getresponse()
|
|
|
except (OSError, RuntimeError, FileNotFoundError) as exc:
|
|
|
self.send_error(502, f"Netron unavailable: {exc}")
|
|
|
return
|
|
|
|
|
|
self.send_response(upstream.status, upstream.reason)
|
|
|
hop_by_hop = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "server", "date", "content-length"}
|
|
|
for key, value in upstream.getheaders():
|
|
|
if key.lower() not in hop_by_hop:
|
|
|
self.send_header(key, value)
|
|
|
content_length = upstream.getheader("Content-Length")
|
|
|
if content_length:
|
|
|
self.send_header("Content-Length", content_length)
|
|
|
self.end_headers()
|
|
|
try:
|
|
|
while True:
|
|
|
chunk = upstream.read(1024 * 1024)
|
|
|
if not chunk:
|
|
|
break
|
|
|
self.wfile.write(chunk)
|
|
|
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
|
|
|
pass
|
|
|
finally:
|
|
|
upstream_conn.close()
|
|
|
|
|
|
def read_json_body(self):
|
|
|
try:
|
|
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
|
except (TypeError, ValueError) as exc:
|
|
|
raise ValueError("invalid Content-Length") from exc
|
|
|
if length < 0 or length > MAX_JSON_BODY_BYTES:
|
|
|
raise ValueError("JSON request body too large")
|
|
|
if length <= 0:
|
|
|
return {}
|
|
|
data = self.rfile.read(length)
|
|
|
return json.loads(data.decode("utf-8"))
|
|
|
|
|
|
def send_json(self, data, status=200):
|
|
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
|
self.send_response(status)
|
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
self.send_header("Content-Length", str(len(body)))
|
|
|
self.end_headers()
|
|
|
try:
|
|
|
self.wfile.write(body)
|
|
|
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
|
|
|
pass
|
|
|
|
|
|
def handle_upload(self, params):
|
|
|
filename = str((params.get("name") or [""])[0])
|
|
|
dst = input_upload_path(filename)
|
|
|
if dst is None:
|
|
|
return self.send_json({"ok": False, "error": "неподдерживаемое имя файла"}, status=400)
|
|
|
try:
|
|
|
size = int(self.headers.get("Content-Length", "0") or "0")
|
|
|
except ValueError:
|
|
|
return self.send_json({"ok": False, "error": "неверный размер файла"}, status=400)
|
|
|
if size <= 0:
|
|
|
return self.send_json({"ok": False, "error": "пустой файл"}, status=400)
|
|
|
max_size = int(os.environ.get("FPV_UI_MAX_UPLOAD_BYTES", str(64 * 1024**3)))
|
|
|
if size > max_size:
|
|
|
return self.send_json({"ok": False, "error": "файл превышает лимит загрузки"}, status=413)
|
|
|
|
|
|
temp = dst.with_name(f".{dst.name}.{threading.get_ident()}.upload")
|
|
|
try:
|
|
|
INPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
with temp.open("wb", buffering=4 * 1024 * 1024) as out:
|
|
|
copy_exact(self.rfile, out, size)
|
|
|
temp.replace(dst)
|
|
|
state = read_control_state()
|
|
|
requested_mode = str((params.get("source_mode") or [""])[0]).lower()
|
|
|
if requested_mode in FILE_SOURCE_MODES:
|
|
|
state["source_mode"] = requested_mode
|
|
|
else:
|
|
|
state["source_mode"] = "udp_dump" if input_file_kind(dst) == "udp_dump" else "file"
|
|
|
state["file_path"] = str(dst)
|
|
|
write_json_file(CONTROL_PATH, state)
|
|
|
return self.send_json({"ok": True, "path": str(dst), "size": size, "control": control_payload()})
|
|
|
except Exception as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=500)
|
|
|
finally:
|
|
|
try:
|
|
|
temp.unlink()
|
|
|
except OSError:
|
|
|
pass
|
|
|
|
|
|
def handle_model_upload(self, params):
|
|
|
if process_running():
|
|
|
return self.send_json({"ok": False, "error": "остановите инференс перед загрузкой модели"}, status=409)
|
|
|
filename = str((params.get("name") or [""])[0])
|
|
|
dst = model_upload_path(filename)
|
|
|
if dst is None:
|
|
|
return self.send_json({"ok": False, "error": "нужен файл модели .pt без пути"}, status=400)
|
|
|
try:
|
|
|
size = int(self.headers.get("Content-Length", "0") or "0")
|
|
|
except ValueError:
|
|
|
return self.send_json({"ok": False, "error": "неверный размер файла"}, status=400)
|
|
|
if size <= 0:
|
|
|
return self.send_json({"ok": False, "error": "пустой файл"}, status=400)
|
|
|
max_size = int(os.environ.get("FPV_UI_MAX_MODEL_UPLOAD_BYTES", str(8 * 1024**3)))
|
|
|
if size > max_size:
|
|
|
return self.send_json({"ok": False, "error": "модель превышает лимит загрузки"}, status=413)
|
|
|
|
|
|
temp = dst.with_name(f".{dst.name}.{threading.get_ident()}.upload")
|
|
|
try:
|
|
|
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
with temp.open("wb", buffering=4 * 1024 * 1024) as out:
|
|
|
copy_exact(self.rfile, out, size)
|
|
|
temp.replace(dst)
|
|
|
state = read_control_state()
|
|
|
state["model_path"] = str(dst)
|
|
|
write_json_file(CONTROL_PATH, state)
|
|
|
return self.send_json({"ok": True, "path": str(dst), "size": size, "control": control_payload()})
|
|
|
except Exception as exc:
|
|
|
return self.send_json({"ok": False, "error": str(exc)}, status=500)
|
|
|
finally:
|
|
|
try:
|
|
|
temp.unlink()
|
|
|
except OSError:
|
|
|
pass
|
|
|
|
|
|
def send_bytes(self, data, content_type):
|
|
|
self.send_response(200)
|
|
|
self.send_header("Content-Type", content_type)
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
self.send_header("Content-Length", str(len(data)))
|
|
|
self.end_headers()
|
|
|
try:
|
|
|
self.wfile.write(data)
|
|
|
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
|
|
|
pass
|
|
|
|
|
|
def send_file(self, path, content_type=None, attachment=False, download_name=None):
|
|
|
try:
|
|
|
size = path.stat().st_size
|
|
|
except FileNotFoundError:
|
|
|
self.send_error(404)
|
|
|
return
|
|
|
ctype = content_type or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
|
range_header = self.headers.get("Range")
|
|
|
if range_header:
|
|
|
parsed = parse_range_header(range_header, size)
|
|
|
if not parsed:
|
|
|
self.send_response(416)
|
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
|
self.send_header("Content-Range", f"bytes */{size}")
|
|
|
self.end_headers()
|
|
|
return
|
|
|
start, end = parsed
|
|
|
self.send_response(206)
|
|
|
self.send_header("Content-Type", ctype)
|
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
|
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
|
|
self.send_header("Content-Length", str(end - start + 1))
|
|
|
if attachment:
|
|
|
self.send_header("Content-Disposition", content_disposition(download_name or path.name))
|
|
|
self.end_headers()
|
|
|
try:
|
|
|
with path.open("rb") as f:
|
|
|
f.seek(start)
|
|
|
copy_bytes(f, self.wfile, end - start + 1)
|
|
|
except FileNotFoundError:
|
|
|
pass
|
|
|
return
|
|
|
self.send_response(200)
|
|
|
self.send_header("Content-Type", ctype)
|
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
|
self.send_header("Content-Length", str(size))
|
|
|
if attachment:
|
|
|
self.send_header("Content-Disposition", content_disposition(download_name or path.name))
|
|
|
self.end_headers()
|
|
|
try:
|
|
|
with path.open("rb") as f:
|
|
|
copy_bytes(f, self.wfile, size)
|
|
|
except FileNotFoundError:
|
|
|
pass
|
|
|
|
|
|
def send_h264_download(self, path):
|
|
|
try:
|
|
|
download_path = h264_download_file(path)
|
|
|
except RuntimeError as exc:
|
|
|
self.send_error(500, str(exc)[:160])
|
|
|
return
|
|
|
return self.send_file(
|
|
|
download_path,
|
|
|
content_type="video/mp4",
|
|
|
attachment=True,
|
|
|
download_name=h264_download_name(path),
|
|
|
)
|
|
|
|
|
|
def send_mjpeg(self):
|
|
|
try:
|
|
|
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
|
except OSError:
|
|
|
pass
|
|
|
self.send_response(200)
|
|
|
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
|
|
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
|
|
self.send_header("Pragma", "no-cache")
|
|
|
self.end_headers()
|
|
|
last_jpeg = None
|
|
|
try:
|
|
|
while True:
|
|
|
try:
|
|
|
jpeg = FRAME_PATH.read_bytes()
|
|
|
if jpeg and jpeg != last_jpeg:
|
|
|
self.wfile.write(
|
|
|
b"--frame\r\n"
|
|
|
b"Content-Type: image/jpeg\r\n"
|
|
|
+ f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii")
|
|
|
+ jpeg
|
|
|
+ b"\r\n"
|
|
|
)
|
|
|
self.wfile.flush()
|
|
|
last_jpeg = jpeg
|
|
|
except FileNotFoundError:
|
|
|
pass
|
|
|
time.sleep(0.01)
|
|
|
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
|
|
|
return
|
|
|
|
|
|
def send_transcoded_video(self, path):
|
|
|
ffmpeg = ffmpeg_executable()
|
|
|
if not ffmpeg:
|
|
|
self.send_error(500, "ffmpeg unavailable")
|
|
|
return
|
|
|
command = [
|
|
|
ffmpeg, "-hide_banner", "-loglevel", "error", "-i", str(path),
|
|
|
"-an", "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
|
|
|
"-crf", "18",
|
|
|
"-pix_fmt", "yuv420p", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
|
|
"-f", "mp4", "pipe:1",
|
|
|
]
|
|
|
try:
|
|
|
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
|
except OSError:
|
|
|
self.send_error(500, "ffmpeg unavailable")
|
|
|
return
|
|
|
self.send_response(200)
|
|
|
self.send_header("Content-Type", "video/mp4")
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
self.end_headers()
|
|
|
try:
|
|
|
copy_stream(proc.stdout, self.wfile)
|
|
|
finally:
|
|
|
if proc.poll() is None:
|
|
|
proc.kill()
|
|
|
proc.wait()
|
|
|
|
|
|
|
|
|
def main():
|
|
|
removed = cleanup_empty_recordings(OUT_DIR)
|
|
|
if removed:
|
|
|
print(f"Removed empty recordings: {removed}", flush=True)
|
|
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
|
print(f"UI listening on http://{HOST}:{PORT}", flush=True)
|
|
|
server.serve_forever()
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
main()
|