OrangePi3588Media/agent/internal/httpapi/uiconfig.go
sladro 1737419aed
Some checks are pending
CI / host-build (push) Waiting to run
CI / rk3588-cross-build (push) Waiting to run
更新agent
2026-01-10 22:21:48 +08:00

564 lines
20 KiB
Go

package httpapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"sort"
"strings"
"time"
)
type UIInstance struct {
Name string `json:"name"`
Template string `json:"template"`
Params map[string]any `json:"params,omitempty"`
Override map[string]any `json:"override,omitempty"`
}
type uiRootConfig struct {
Global map[string]any `json:"global,omitempty"`
Queue map[string]any `json:"queue,omitempty"`
Templates map[string]any `json:"templates,omitempty"`
Instances []UIInstance `json:"instances,omitempty"`
Graphs []map[string]any `json:"graphs,omitempty"`
}
type uiPlanRequest struct {
Global map[string]any `json:"global,omitempty"`
Queue map[string]any `json:"queue,omitempty"`
Instances []UIInstance `json:"instances"`
}
type uiDiff struct {
Added []string `json:"added"`
Removed []string `json:"removed"`
Changed []string `json:"changed"`
}
type uiPlanResponse struct {
Ok bool `json:"ok"`
GeneratedConfig map[string]any `json:"generated_config"`
Diff uiDiff `json:"diff"`
Warnings []string `json:"warnings,omitempty"`
}
func (s *Server) handleConfigUISchema(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !s.authorize(r, false) {
errorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
writeJSON(w, http.StatusOK, uiSchema())
}
func (s *Server) handleConfigUIState(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !s.authorize(r, false) {
errorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
root, _ := readUIRootConfig(s.agentCfg.ConfigPath)
// Force templates/instances-only view.
resp := map[string]any{
"ok": true,
"global": root.Global,
"queue": root.Queue,
"instances": root.Instances,
"templates": sortedKeys(uiTemplates()),
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleConfigUIPlan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !s.authorize(r, true) {
errorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
req, err := readRequiredJSON[uiPlanRequest](w, r, 1<<20)
if err != nil {
errorJSON(w, http.StatusBadRequest, err.Error())
return
}
resp, err := s.planUIConfig(req)
if err != nil {
errorJSON(w, http.StatusBadRequest, "validation failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleConfigUIApply(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !s.authorize(r, true) {
errorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
req, err := readRequiredJSON[uiPlanRequest](w, r, 1<<20)
if err != nil {
errorJSON(w, http.StatusBadRequest, err.Error())
return
}
resp, err := s.planUIConfig(req)
if err != nil {
errorJSON(w, http.StatusBadRequest, "validation failed: "+err.Error())
return
}
b, err := json.MarshalIndent(resp.GeneratedConfig, "", " ")
if err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: marshal config failed: "+err.Error())
return
}
if err := s.applyRootConfigBytes(r.Context(), b); err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) planUIConfig(req uiPlanRequest) (uiPlanResponse, error) {
cur, _ := readUIRootConfig(s.agentCfg.ConfigPath)
curInst := map[string]UIInstance{}
for _, it := range cur.Instances {
if strings.TrimSpace(it.Name) == "" {
continue
}
curInst[it.Name] = it
}
warnings := []string{}
tpls := uiTemplates()
seen := map[string]bool{}
for i := range req.Instances {
it := &req.Instances[i]
it.Name = strings.TrimSpace(it.Name)
it.Template = strings.TrimSpace(it.Template)
if it.Params == nil {
it.Params = map[string]any{}
}
if it.Name == "" {
return uiPlanResponse{}, errors.New("instances[].name is required")
}
if seen[it.Name] {
return uiPlanResponse{}, errors.New("instance name not unique: " + it.Name)
}
seen[it.Name] = true
if _, ok := tpls[it.Template]; !ok {
return uiPlanResponse{}, errors.New("unknown template: " + it.Template)
}
// Apply per-template defaults.
applyUIDefaults(it)
// Basic required params validation.
missing := validateUIRequiredParams(*it)
if len(missing) > 0 {
return uiPlanResponse{}, errors.New("instance " + it.Name + " missing params: " + strings.Join(missing, ", "))
}
}
// Build root config.
root := uiRootConfig{}
if req.Global != nil {
root.Global = req.Global
} else {
root.Global = cur.Global
}
if req.Queue != nil {
root.Queue = req.Queue
} else {
root.Queue = cur.Queue
}
root.Templates = tpls
root.Instances = req.Instances
gen := map[string]any{}
b, _ := json.Marshal(root)
_ = json.Unmarshal(b, &gen)
// Diff summary (instances only).
added := []string{}
removed := []string{}
changed := []string{}
for name := range seen {
if _, ok := curInst[name]; !ok {
added = append(added, name)
continue
}
prev := curInst[name]
now := seenInstance(req.Instances, name)
if prev.Template != now.Template {
changed = append(changed, name)
continue
}
// Rough compare params by json.
pb, _ := json.Marshal(prev.Params)
nb, _ := json.Marshal(now.Params)
if string(pb) != string(nb) {
changed = append(changed, name)
}
}
for name := range curInst {
if !seen[name] {
removed = append(removed, name)
}
}
sort.Strings(added)
sort.Strings(removed)
sort.Strings(changed)
return uiPlanResponse{
Ok: true,
GeneratedConfig: gen,
Diff: uiDiff{Added: added, Removed: removed, Changed: changed},
Warnings: warnings,
}, nil
}
func readUIRootConfig(path string) (uiRootConfig, error) {
var out uiRootConfig
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return out, nil
}
return out, err
}
_ = json.Unmarshal(b, &out)
if out.Instances == nil {
out.Instances = []UIInstance{}
}
if out.Global == nil {
out.Global = map[string]any{}
}
if out.Queue == nil {
out.Queue = map[string]any{}
}
return out, nil
}
func readRequiredJSON[T any](w http.ResponseWriter, r *http.Request, maxBytes int64) (T, error) {
var zero T
if r.Body == nil {
return zero, errors.New("validation failed: empty body")
}
if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" {
return zero, errors.New("validation failed: Content-Type must be application/json")
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
return zero, errors.New("payload too large")
}
return zero, fmt.Errorf("invalid json: %v", err)
}
if len(strings.TrimSpace(string(body))) == 0 {
return zero, errors.New("validation failed: empty body")
}
var v T
if err := json.Unmarshal(body, &v); err != nil {
return zero, fmt.Errorf("invalid json: %v", err)
}
return v, nil
}
func seenInstance(insts []UIInstance, name string) UIInstance {
for _, it := range insts {
if it.Name == name {
return it
}
}
return UIInstance{}
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func applyUIDefaults(it *UIInstance) {
if it.Params == nil {
it.Params = map[string]any{}
}
// Common defaults.
setDefault(it.Params, "name", it.Name)
setDefault(it.Params, "fps", 25)
setDefault(it.Params, "src_w", 1920)
setDefault(it.Params, "src_h", 1080)
setDefault(it.Params, "gop", 50)
setDefault(it.Params, "bitrate_kbps", 2000)
setDefault(it.Params, "rtsp_port", 8555)
setDefault(it.Params, "hls_path", "./web/hls/"+it.Name+"/index.m3u8")
// Face defaults.
if it.Template == "face_det_recog_rtsp_hls" {
setDefault(it.Params, "gallery_path", "./models/face_gallery.db")
setDefault(it.Params, "thr_accept", 0.45)
setDefault(it.Params, "thr_margin", 0.05)
}
// Alarm defaults.
if it.Template == "yolo_alarm_minio" {
setDefault(it.Params, "minio_region", "us-east-1")
setDefault(it.Params, "cooldown_ms", 3000)
}
}
func validateUIRequiredParams(it UIInstance) []string {
req := []string{"url"}
switch it.Template {
case "transcode_rtsp_hls":
// url is enough
case "yolo_rtsp_hls", "yolo_alarm_minio":
req = append(req, "model_path")
case "face_det_rtsp_hls":
req = append(req, "det_model_path")
case "face_det_recog_rtsp_hls":
req = append(req, "det_model_path", "recog_model_path")
}
missing := []string{}
for _, k := range req {
v, ok := it.Params[k]
if !ok {
missing = append(missing, k)
continue
}
if s, ok := v.(string); ok {
if strings.TrimSpace(s) == "" {
missing = append(missing, k)
}
}
}
return missing
}
func setDefault(m map[string]any, key string, val any) {
if _, ok := m[key]; ok {
return
}
m[key] = val
}
func uiSchema() map[string]any {
return map[string]any{
"version": 1,
"templates": sortedKeys(uiTemplates()),
"fields": map[string]any{
"common": []map[string]any{
{"key": "name", "type": "string", "required": true},
{"key": "url", "type": "string", "required": true},
{"key": "fps", "type": "int", "default": 25},
{"key": "src_w", "type": "int", "default": 1920},
{"key": "src_h", "type": "int", "default": 1080},
{"key": "gop", "type": "int", "default": 50},
{"key": "bitrate_kbps", "type": "int", "default": 2000},
},
"yolo": []map[string]any{
{"key": "model_path", "type": "string", "required": true},
},
"face": []map[string]any{
{"key": "det_model_path", "type": "string", "required": true},
{"key": "recog_model_path", "type": "string", "required": false},
{"key": "gallery_path", "type": "string", "default": "./models/face_gallery.db"},
{"key": "thr_accept", "type": "float", "default": 0.45},
{"key": "thr_margin", "type": "float", "default": 0.05},
},
"alarm_minio": []map[string]any{
{"key": "minio_endpoint", "type": "string", "required": true},
{"key": "minio_bucket", "type": "string", "required": true},
{"key": "minio_ak", "type": "string", "required": true},
{"key": "minio_sk", "type": "string", "required": true},
{"key": "cooldown_ms", "type": "int", "default": 3000},
},
},
"generated_at_ms": time.Now().UnixMilli(),
}
}
func uiTemplates() map[string]any {
// Templates are expressed in media-server native format with placeholders used by config_expand.
return map[string]any{
"transcode_rtsp_hls": map[string]any{
"nodes": []any{
map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true,
"url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}",
"use_mpp": true, "use_ffmpeg": false, "force_tcp": true,
"reconnect_sec": 5, "reconnect_backoff_max_sec": 30},
map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true,
"codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}",
"use_mpp": true, "use_ffmpeg_mux": true,
"outputs": []any{
map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"},
map[string]any{"proto": "hls", "path": "${hls_path}", "segment_sec": 2},
}},
},
"edges": []any{
[]any{"in", "pre"},
[]any{"pre", "pub"},
},
},
"yolo_rtsp_hls": map[string]any{
"nodes": []any{
map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true,
"url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}",
"use_mpp": true, "use_ffmpeg": false, "force_tcp": true,
"reconnect_sec": 5, "reconnect_backoff_max_sec": 30},
map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": 640, "dst_h": 640, "dst_format": "rgb", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "ai", "type": "ai_yolo", "role": "filter", "enable": true,
"infer_fps": 10, "model_path": "${model_path}", "model_version": "auto", "num_classes": 80,
"conf": 0.25, "nms": 0.45, "class_filter": []any{}},
map[string]any{"id": "osd", "type": "osd", "role": "filter", "enable": true,
"draw_bbox": true, "draw_text": true, "line_width": 2, "font_scale": 1, "labels": []any{}},
map[string]any{"id": "post", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true,
"codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}",
"use_mpp": true, "use_ffmpeg_mux": true,
"outputs": []any{
map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"},
map[string]any{"proto": "hls", "path": "${hls_path}", "segment_sec": 2},
}},
},
"edges": []any{
[]any{"in", "pre"},
[]any{"pre", "ai"},
[]any{"ai", "osd"},
[]any{"osd", "post"},
[]any{"post", "pub"},
},
},
"yolo_alarm_minio": map[string]any{
"nodes": []any{
map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true,
"url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}",
"use_mpp": true, "use_ffmpeg": false, "force_tcp": true,
"reconnect_sec": 5, "reconnect_backoff_max_sec": 30},
map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": 640, "dst_h": 640, "dst_format": "rgb", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "ai", "type": "ai_yolo", "role": "filter", "enable": true,
"infer_fps": 10, "model_path": "${model_path}", "model_version": "auto", "num_classes": 80,
"conf": 0.25, "nms": 0.45, "class_filter": []any{}},
map[string]any{"id": "osd", "type": "osd", "role": "filter", "enable": true,
"draw_bbox": true, "draw_text": true, "line_width": 2, "font_scale": 1, "labels": []any{}},
map[string]any{"id": "post", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true,
"codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}",
"use_mpp": true, "use_ffmpeg_mux": true,
"outputs": []any{map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"}}},
map[string]any{"id": "alarm", "type": "alarm", "role": "sink", "enable": true,
"eval_fps": 10,
"labels": []any{},
"rules": []any{
map[string]any{"name": "person_in_view", "class_ids": []any{0},
"roi": map[string]any{"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0},
"min_duration_ms": 0, "cooldown_ms": "${cooldown_ms}"},
},
"actions": map[string]any{
"log": map[string]any{"enable": true, "level": "info"},
"snapshot": map[string]any{"enable": true, "format": "jpg", "quality": 85,
"upload": map[string]any{"type": "minio", "endpoint": "${minio_endpoint}", "bucket": "${minio_bucket}", "region": "${minio_region}", "access_key": "${minio_ak}", "secret_key": "${minio_sk}"}},
"clip": map[string]any{"enable": true, "pre_sec": 5, "post_sec": 10, "format": "mp4", "fps": "${fps}",
"upload": map[string]any{"type": "minio", "endpoint": "${minio_endpoint}", "bucket": "${minio_bucket}", "region": "${minio_region}", "access_key": "${minio_ak}", "secret_key": "${minio_sk}"}},
"http": map[string]any{"enable": false, "url": "http://127.0.0.1:8080/api/alarm", "timeout_ms": 3000, "include_media_url": true, "method": "POST"},
},
},
},
"edges": []any{
[]any{"in", "pre"},
[]any{"pre", "ai"},
[]any{"ai", "osd"},
[]any{"osd", "post"},
[]any{"post", "pub"},
[]any{"pub", "alarm"},
},
},
"face_det_rtsp_hls": map[string]any{
"nodes": []any{
map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true,
"url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}",
"use_mpp": true, "use_ffmpeg": false, "force_tcp": true,
"reconnect_sec": 5, "reconnect_backoff_max_sec": 30},
map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": 320, "dst_h": 320, "dst_format": "rgb", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "face_det", "type": "ai_face_det", "role": "filter", "enable": true,
"model_path": "${det_model_path}", "conf": 0.6, "nms": 0.4, "max_faces": 10, "output_landmarks": true, "input_format": "rgb"},
map[string]any{"id": "osd", "type": "osd", "role": "filter", "enable": true,
"draw_bbox": true, "draw_text": true, "draw_face_det": true, "line_width": 2, "font_scale": 1, "labels": []any{}},
map[string]any{"id": "post", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true,
"codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}",
"use_mpp": true, "use_ffmpeg_mux": true,
"outputs": []any{map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"}}},
},
"edges": []any{
[]any{"in", "pre"},
[]any{"pre", "face_det"},
[]any{"face_det", "osd"},
[]any{"osd", "post"},
[]any{"post", "pub"},
},
},
"face_det_recog_rtsp_hls": map[string]any{
"nodes": []any{
map[string]any{"id": "in", "type": "input_rtsp", "role": "source", "enable": true,
"url": "${url}", "fps": "${fps}", "width": "${src_w}", "height": "${src_h}",
"use_mpp": true, "use_ffmpeg": false, "force_tcp": true,
"reconnect_sec": 5, "reconnect_backoff_max_sec": 30},
map[string]any{"id": "pre", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "rgb", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "face_det", "type": "ai_face_det", "role": "filter", "enable": true,
"model_path": "${det_model_path}", "conf": 0.6, "nms": 0.4, "max_faces": 10, "output_landmarks": true, "input_format": "rgb"},
map[string]any{"id": "face_recog", "type": "ai_face_recog", "role": "filter", "enable": true,
"model_path": "${recog_model_path}", "align": true, "emit_embedding": false, "max_faces": 10,
"threshold": map[string]any{"accept": "${thr_accept}", "margin": "${thr_margin}"},
"gallery": map[string]any{"backend": "sqlite", "path": "${gallery_path}", "load_on_start": true, "expected_dim": 512, "dtype": "auto"}},
map[string]any{"id": "osd", "type": "osd", "role": "filter", "enable": true,
"draw_bbox": true, "draw_text": true, "draw_face_det": false, "line_width": 2, "font_scale": 1, "labels": []any{}},
map[string]any{"id": "post", "type": "preprocess", "role": "filter", "enable": true,
"dst_w": "${src_w}", "dst_h": "${src_h}", "dst_format": "nv12", "keep_ratio": false, "use_rga": true},
map[string]any{"id": "pub", "type": "publish", "role": "filter", "enable": true,
"codec": "h264", "fps": "${fps}", "gop": "${gop}", "bitrate_kbps": "${bitrate_kbps}",
"use_mpp": true, "use_ffmpeg_mux": true,
"outputs": []any{map[string]any{"proto": "rtsp_server", "port": "${rtsp_port}", "path": "/live/${name}"}}},
},
"edges": []any{
[]any{"in", "pre"},
[]any{"pre", "face_det"},
[]any{"face_det", "face_recog"},
[]any{"face_recog", "osd"},
[]any{"osd", "post"},
[]any{"post", "pub"},
},
},
}
}