You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

4984 lines
288 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 { 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 { 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="Открыть логи">&#8801;</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>
<label class="config-scale-control" title="Масштаб элементов и текста выбранного блока: 50200%"><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">перетаскивайте за заголовок или свободное место</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">Клик по строке фокусирует блок. Перетаскивайте на 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">&#1053;&#1072;&#1074;&#1077;&#1076;&#1077;&#1085;&#1080;&#1077; 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>&#1048;&#1055;-&#1072;&#1076;&#1088;&#1077;&#1089; &#1087;&#1086;&#1083;&#1091;&#1095;&#1072;&#1090;&#1077;&#1083;&#1103;<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>&#1053;&#1086;&#1084;&#1077;&#1088; &#1086;&#1073;&#1098;&#1077;&#1082;&#1090;&#1072;<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>
<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">перетаскивайте блоки за свободное место</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>&#1040;&#1088;&#1093;&#1080;&#1074;</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="Закрыть">&times;</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="Закрыть">&times;</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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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;
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) {
if (!block || configBlockState(block)) {
selectedConfigBlock = null;
configBlocks.forEach(other => other.classList.remove('is-selected'));
const control = $('configContentScale');
if (control) control.disabled = true;
return;
}
selectedConfigBlock = block;
configBlocks.forEach(other => other.classList.toggle('is-selected', other === block));
const entry = configLayout[block.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}%`);
const control = $('configContentScale');
if (control) control.disabled = false;
updateConfigBlockScale(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 setConfigBlockState(block, state) {
if (!block) return;
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 focusConfigBlock(block, item) {
if (!block || configBlockState(block)) return;
configBlocks.forEach(other => other.classList.remove('is-selected'));
document.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' : ''}`;
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' && !state && !deleted) {
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'удалить навсегда';
remove.title = 'Удалить блок без возможности восстановления';
remove.addEventListener('click', event => {
event.stopPropagation();
removeConfigBlockForever(block);
});
actions.appendChild(remove);
}
item.append(label, stateNode, actions);
item.addEventListener('click', () => focusConfigBlock(block, item));
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;
setText('configLayoutState', `${visible} блоков · ${hidden.length} скрыто · ${deleted.length} в корзине`);
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 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) {
const trash = $('configTrash');
const droppedOnTrash = trashDrop(event);
if (trash) {
trash.classList.remove('is-over');
trash.hidden = true;
}
if (!dragged) return;
captureConfigLayout(block);
if (droppedOnTrash) setConfigBlockState(block, 'deleted');
else {
saveConfigLayout();
setText('configLayoutState', `${configBlocks.length - configBlocks.filter(item => configBlockState(item)).length} блоков · раскладка сохранена`);
}
block._suppressSummaryClick = true;
window.setTimeout(() => { block._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 => {
selectConfigBlock(block);
bringConfigBlockFront(block);
const edge = configResizeEdge(event, 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 startLeft = parseFloat(block.style.left) || block.offsetLeft || 0;
const startTop = parseFloat(block.style.top) || block.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();
block.classList.add('is-dragging');
}
event.preventDefault();
const maxLeft = Math.max(0, configCanvasWidth() - block.offsetWidth);
block.style.left = `${Math.max(0, Math.min(maxLeft, Math.round(startLeft + moveEvent.clientX - startX)))}px`;
block.style.top = `${Math.max(0, Math.round(startTop + moveEvent.clientY - startY))}px`;
setTrashHover(moveEvent);
updateConfigCanvasHeight();
};
const end = endEvent => {
block.releasePointerCapture?.(event.pointerId);
block.removeEventListener('pointermove', move);
block.removeEventListener('pointerup', end);
block.removeEventListener('pointercancel', end);
block.classList.remove('is-dragging');
finishConfigDrag(block, endEvent, dragged);
};
block.addEventListener('pointermove', move);
block.addEventListener('pointerup', end, {once: true});
block.addEventListener('pointercancel', end, {once: true});
}
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);
$('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 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) {
if (!block || networkState(block)) {
networkSelectedBlock = null;
networkBlocks.forEach(item => item.classList.remove('is-selected'));
const control = $('networkContentScale');
if (control) control.disabled = true;
return;
}
networkSelectedBlock = block;
networkBlocks.forEach(item => item.classList.toggle('is-selected', item === block));
const value = Math.max(50, Math.min(200, Math.round(Number(networkLayout[block.dataset.section]?.scale) || 100)));
networkContentScale = value / 100;
setValue('networkContentScale', value);
setText('networkContentScaleValue', `${value}%`);
const control = $('networkContentScale');
if (control) control.disabled = false;
updateNetworkBlockScale(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' : ''}`;
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);
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'удалить';
remove.title = 'Удалить пользовательский блок без восстановления';
remove.addEventListener('click', event => {
event.stopPropagation();
removeNetworkBlockForever(block);
});
actions.appendChild(remove);
}
item.append(label, stateNode, actions);
item.addEventListener('click', () => {
if (!state) selectNetworkBlock(block);
item.classList.add('is-selected');
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);
setText('networkLayoutState', `${networkBlocks.length - hidden - deleted} блоков · ${hidden} скрыто · ${deleted} в корзине`);
}
function setNetworkBlockState(block, state) {
if (!block) return;
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 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 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) {
const trash = $('networkTrash');
const droppedOnTrash = networkTrashDrop(event);
if (trash) { trash.classList.remove('is-over'); trash.hidden = true; }
if (!dragged) return;
captureNetworkLayout(block);
if (droppedOnTrash) setNetworkBlockState(block, 'deleted');
else { saveNetworkLayout(); renderNetworkStructure(); }
block._networkSuppressClick = true;
window.setTimeout(() => { block._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 startLeft = parseFloat(block.style.left) || block.offsetLeft || 0;
const startTop = parseFloat(block.style.top) || block.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(); block.classList.add('is-dragging'); }
moveEvent.preventDefault();
const maxLeft = Math.max(0, networkCanvasWidth() - block.offsetWidth);
block.style.left = `${Math.max(0, Math.min(maxLeft, Math.round(startLeft + moveEvent.clientX - startX)))}px`;
block.style.top = `${Math.max(0, Math.round(startTop + moveEvent.clientY - startY))}px`;
const trash = $('networkTrash');
if (trash) trash.classList.toggle('is-over', networkTrashDrop(moveEvent));
updateNetworkCanvasHeight();
};
const end = endEvent => {
block.releasePointerCapture?.(event.pointerId);
block.removeEventListener('pointermove', move);
block.removeEventListener('pointerup', end);
block.removeEventListener('pointercancel', end);
block.classList.remove('is-dragging');
finishNetworkDrag(block, endEvent, dragged);
};
block.addEventListener('pointermove', move);
block.addEventListener('pointerup', end, {once: true});
block.addEventListener('pointercancel', end, {once: true});
}
function makeNetworkBlockInteractive(block) {
if (!block || block._networkInteractive) return;
block._networkInteractive = true;
block.addEventListener('pointerdown', event => {
selectNetworkBlock(block);
bringNetworkBlockFront(block);
const edge = networkResizeEdge(event, 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);
$('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') return;
if (!$('playerOverlay').hidden) closePlayer();
else if (!$('logDrawer').hidden) setLogsOpen(false);
else if (!$('appearancePanel').hidden) setAppearanceOpen(false);
});
$('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()