1020 lines
32 KiB
Go
1020 lines
32 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)
|
|
|
|
mode := "instances"
|
|
warnings := []string{}
|
|
if st, err := os.Stat(s.agentCfg.ConfigPath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
warnings = append(warnings, "config file not found: "+s.agentCfg.ConfigPath)
|
|
} else {
|
|
warnings = append(warnings, "config stat failed: "+err.Error())
|
|
}
|
|
} else if st.IsDir() {
|
|
warnings = append(warnings, "config path is a directory: "+s.agentCfg.ConfigPath)
|
|
}
|
|
instances := root.Instances
|
|
// If current config is graphs-only, do a best-effort import so GUI can edit.
|
|
if len(instances) == 0 && len(root.Graphs) > 0 {
|
|
mode = "graphs_imported"
|
|
inst, warn := inferInstancesFromGraphs(root.Graphs)
|
|
if len(inst) > 0 {
|
|
instances = inst
|
|
}
|
|
warnings = append(warnings, warn...)
|
|
}
|
|
|
|
// Force templates/instances-only view.
|
|
// templates = union(current templates, built-in templates)
|
|
tplKeys := map[string]bool{}
|
|
for k := range root.Templates {
|
|
tplKeys[k] = true
|
|
}
|
|
for k := range uiTemplates() {
|
|
tplKeys[k] = true
|
|
}
|
|
mergedTpls := make([]string, 0, len(tplKeys))
|
|
for k := range tplKeys {
|
|
mergedTpls = append(mergedTpls, k)
|
|
}
|
|
sort.Strings(mergedTpls)
|
|
|
|
resp := map[string]any{
|
|
"ok": true,
|
|
"global": root.Global,
|
|
"queue": root.Queue,
|
|
"instances": instances,
|
|
"templates": mergedTpls,
|
|
"mode": mode,
|
|
"warnings": warnings,
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func inferInstancesFromGraphs(graphs []map[string]any) ([]UIInstance, []string) {
|
|
instances := []UIInstance{}
|
|
warnings := []string{}
|
|
used := map[string]int{}
|
|
|
|
for _, g := range graphs {
|
|
gname := asString(g["name"])
|
|
nodes := asSlice(g["nodes"])
|
|
if len(nodes) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Try templates in priority order.
|
|
if inst, ok := inferFaceDetRecog(gname, nodes); ok {
|
|
inst.Name = uniquify(inst.Name, used)
|
|
instances = append(instances, inst)
|
|
continue
|
|
}
|
|
if inst, ok := inferFaceDet(gname, nodes); ok {
|
|
inst.Name = uniquify(inst.Name, used)
|
|
instances = append(instances, inst)
|
|
continue
|
|
}
|
|
if inst, ok := inferYoloAlarmMinio(gname, nodes); ok {
|
|
inst.Name = uniquify(inst.Name, used)
|
|
instances = append(instances, inst)
|
|
continue
|
|
}
|
|
if inst, ok := inferYolo(gname, nodes); ok {
|
|
inst.Name = uniquify(inst.Name, used)
|
|
instances = append(instances, inst)
|
|
continue
|
|
}
|
|
if inst, ok := inferTranscode(gname, nodes); ok {
|
|
inst.Name = uniquify(inst.Name, used)
|
|
instances = append(instances, inst)
|
|
continue
|
|
}
|
|
|
|
if gname == "" {
|
|
gname = "noname"
|
|
}
|
|
warnings = append(warnings, "graph not recognized for templates/instances import: "+gname)
|
|
}
|
|
|
|
return instances, warnings
|
|
}
|
|
|
|
func inferTranscode(graphName string, nodes []any) (UIInstance, bool) {
|
|
in := findNodeByType(nodes, "input_rtsp")
|
|
pub := findNodeByType(nodes, "publish")
|
|
if in == nil || pub == nil {
|
|
return UIInstance{}, false
|
|
}
|
|
if findNodeByType(nodes, "ai_yolo") != nil || findNodeByType(nodes, "ai_face_det") != nil || findNodeByType(nodes, "ai_face_recog") != nil {
|
|
return UIInstance{}, false
|
|
}
|
|
name := deriveNameFromPublish(pub, graphName)
|
|
params := map[string]any{}
|
|
params["name"] = name
|
|
if url := asString(in["url"]); url != "" {
|
|
params["url"] = url
|
|
} else {
|
|
return UIInstance{}, false
|
|
}
|
|
params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25))
|
|
params["src_w"] = asIntOr(in["width"], 1920)
|
|
params["src_h"] = asIntOr(in["height"], 1080)
|
|
params["gop"] = asIntOr(pub["gop"], 50)
|
|
params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000)
|
|
applyPublishOutputsToParams(pub, params)
|
|
return UIInstance{Name: name, Template: "transcode_rtsp_hls", Params: params}, true
|
|
}
|
|
|
|
func inferYolo(graphName string, nodes []any) (UIInstance, bool) {
|
|
in := findNodeByType(nodes, "input_rtsp")
|
|
ai := findNodeByType(nodes, "ai_yolo")
|
|
pub := findNodeByType(nodes, "publish")
|
|
if in == nil || ai == nil || pub == nil {
|
|
return UIInstance{}, false
|
|
}
|
|
if findNodeByType(nodes, "alarm") != nil {
|
|
return UIInstance{}, false
|
|
}
|
|
name := deriveNameFromPublish(pub, graphName)
|
|
params := map[string]any{}
|
|
params["name"] = name
|
|
url := asString(in["url"])
|
|
model := asString(ai["model_path"])
|
|
if url == "" || model == "" {
|
|
return UIInstance{}, false
|
|
}
|
|
params["url"] = url
|
|
params["model_path"] = model
|
|
params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25))
|
|
params["src_w"] = asIntOr(in["width"], 1920)
|
|
params["src_h"] = asIntOr(in["height"], 1080)
|
|
params["gop"] = asIntOr(pub["gop"], 50)
|
|
params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000)
|
|
applyPublishOutputsToParams(pub, params)
|
|
return UIInstance{Name: name, Template: "yolo_rtsp_hls", Params: params}, true
|
|
}
|
|
|
|
func inferYoloAlarmMinio(graphName string, nodes []any) (UIInstance, bool) {
|
|
in := findNodeByType(nodes, "input_rtsp")
|
|
ai := findNodeByType(nodes, "ai_yolo")
|
|
alarm := findNodeByType(nodes, "alarm")
|
|
pub := findNodeByType(nodes, "publish")
|
|
if in == nil || ai == nil || alarm == nil || pub == nil {
|
|
return UIInstance{}, false
|
|
}
|
|
name := deriveNameFromPublish(pub, graphName)
|
|
params := map[string]any{}
|
|
params["name"] = name
|
|
url := asString(in["url"])
|
|
model := asString(ai["model_path"])
|
|
if url == "" || model == "" {
|
|
return UIInstance{}, false
|
|
}
|
|
params["url"] = url
|
|
params["model_path"] = model
|
|
params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25))
|
|
params["src_w"] = asIntOr(in["width"], 1920)
|
|
params["src_h"] = asIntOr(in["height"], 1080)
|
|
params["gop"] = asIntOr(pub["gop"], 50)
|
|
params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000)
|
|
applyPublishOutputsToParams(pub, params)
|
|
|
|
// cooldown
|
|
if rules := asSlice(alarm["rules"]); len(rules) > 0 {
|
|
if r0 := asMap(rules[0]); r0 != nil {
|
|
params["cooldown_ms"] = asIntOr(r0["cooldown_ms"], 3000)
|
|
}
|
|
}
|
|
|
|
// minio from snapshot/clip upload
|
|
actions := asMap(alarm["actions"])
|
|
upl := firstMinioUpload(actions)
|
|
if upl == nil {
|
|
return UIInstance{}, false
|
|
}
|
|
ep := asString(upl["endpoint"])
|
|
bucket := asString(upl["bucket"])
|
|
ak := asString(upl["access_key"])
|
|
sk := asString(upl["secret_key"])
|
|
if ep == "" || bucket == "" || ak == "" || sk == "" {
|
|
return UIInstance{}, false
|
|
}
|
|
params["minio_endpoint"] = ep
|
|
params["minio_bucket"] = bucket
|
|
params["minio_ak"] = ak
|
|
params["minio_sk"] = sk
|
|
if region := asString(upl["region"]); region != "" {
|
|
params["minio_region"] = region
|
|
} else {
|
|
params["minio_region"] = "us-east-1"
|
|
}
|
|
|
|
return UIInstance{Name: name, Template: "yolo_alarm_minio", Params: params}, true
|
|
}
|
|
|
|
func inferFaceDet(graphName string, nodes []any) (UIInstance, bool) {
|
|
in := findNodeByType(nodes, "input_rtsp")
|
|
det := findNodeByType(nodes, "ai_face_det")
|
|
pub := findNodeByType(nodes, "publish")
|
|
if in == nil || det == nil || pub == nil {
|
|
return UIInstance{}, false
|
|
}
|
|
if findNodeByType(nodes, "ai_face_recog") != nil {
|
|
return UIInstance{}, false
|
|
}
|
|
name := deriveNameFromPublish(pub, graphName)
|
|
params := map[string]any{}
|
|
params["name"] = name
|
|
url := asString(in["url"])
|
|
model := asString(det["model_path"])
|
|
if url == "" || model == "" {
|
|
return UIInstance{}, false
|
|
}
|
|
params["url"] = url
|
|
params["det_model_path"] = model
|
|
params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25))
|
|
params["src_w"] = asIntOr(in["width"], 1920)
|
|
params["src_h"] = asIntOr(in["height"], 1080)
|
|
params["gop"] = asIntOr(pub["gop"], 50)
|
|
params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000)
|
|
applyPublishOutputsToParams(pub, params)
|
|
return UIInstance{Name: name, Template: "face_det_rtsp_hls", Params: params}, true
|
|
}
|
|
|
|
func inferFaceDetRecog(graphName string, nodes []any) (UIInstance, bool) {
|
|
in := findNodeByType(nodes, "input_rtsp")
|
|
det := findNodeByType(nodes, "ai_face_det")
|
|
rec := findNodeByType(nodes, "ai_face_recog")
|
|
pub := findNodeByType(nodes, "publish")
|
|
if in == nil || det == nil || rec == nil || pub == nil {
|
|
return UIInstance{}, false
|
|
}
|
|
name := deriveNameFromPublish(pub, graphName)
|
|
params := map[string]any{}
|
|
params["name"] = name
|
|
url := asString(in["url"])
|
|
detModel := asString(det["model_path"])
|
|
recModel := asString(rec["model_path"])
|
|
if url == "" || detModel == "" || recModel == "" {
|
|
return UIInstance{}, false
|
|
}
|
|
params["url"] = url
|
|
params["det_model_path"] = detModel
|
|
params["recog_model_path"] = recModel
|
|
params["fps"] = asIntOr(in["fps"], asIntOr(pub["fps"], 25))
|
|
params["src_w"] = asIntOr(in["width"], 1920)
|
|
params["src_h"] = asIntOr(in["height"], 1080)
|
|
params["gop"] = asIntOr(pub["gop"], 50)
|
|
params["bitrate_kbps"] = asIntOr(pub["bitrate_kbps"], 2000)
|
|
applyPublishOutputsToParams(pub, params)
|
|
|
|
if th := asMap(rec["threshold"]); th != nil {
|
|
params["thr_accept"] = asFloatOr(th["accept"], 0.45)
|
|
params["thr_margin"] = asFloatOr(th["margin"], 0.05)
|
|
} else {
|
|
params["thr_accept"] = 0.45
|
|
params["thr_margin"] = 0.05
|
|
}
|
|
if gal := asMap(rec["gallery"]); gal != nil {
|
|
if p := asString(gal["path"]); p != "" {
|
|
params["gallery_path"] = p
|
|
} else {
|
|
params["gallery_path"] = "./models/face_gallery.db"
|
|
}
|
|
} else {
|
|
params["gallery_path"] = "./models/face_gallery.db"
|
|
}
|
|
|
|
return UIInstance{Name: name, Template: "face_det_recog_rtsp_hls", Params: params}, true
|
|
}
|
|
|
|
func findNodeByType(nodes []any, typ string) map[string]any {
|
|
for _, n := range nodes {
|
|
m := asMap(n)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
if asString(m["type"]) == typ {
|
|
return m
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deriveNameFromPublish(pub map[string]any, fallback string) string {
|
|
outs := asSlice(pub["outputs"])
|
|
for _, o := range outs {
|
|
om := asMap(o)
|
|
if om == nil {
|
|
continue
|
|
}
|
|
if asString(om["proto"]) != "rtsp_server" {
|
|
continue
|
|
}
|
|
p := asString(om["path"])
|
|
if strings.HasPrefix(p, "/live/") {
|
|
n := strings.TrimPrefix(p, "/live/")
|
|
n = strings.Trim(n, "/")
|
|
if n != "" {
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
if strings.TrimSpace(fallback) != "" {
|
|
return fallback
|
|
}
|
|
return "cam"
|
|
}
|
|
|
|
func applyPublishOutputsToParams(pub map[string]any, params map[string]any) {
|
|
outs := asSlice(pub["outputs"])
|
|
for _, o := range outs {
|
|
om := asMap(o)
|
|
if om == nil {
|
|
continue
|
|
}
|
|
proto := asString(om["proto"])
|
|
if proto == "rtsp_server" {
|
|
params["rtsp_port"] = asIntOr(om["port"], asIntOr(params["rtsp_port"], 8555))
|
|
}
|
|
if proto == "hls" {
|
|
if p := asString(om["path"]); p != "" {
|
|
params["hls_path"] = p
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func firstMinioUpload(actions map[string]any) map[string]any {
|
|
if actions == nil {
|
|
return nil
|
|
}
|
|
// Prefer snapshot.upload.
|
|
if snap := asMap(actions["snapshot"]); snap != nil {
|
|
if up := asMap(snap["upload"]); up != nil {
|
|
if asString(up["type"]) == "minio" {
|
|
return up
|
|
}
|
|
}
|
|
}
|
|
if clip := asMap(actions["clip"]); clip != nil {
|
|
if up := asMap(clip["upload"]); up != nil {
|
|
if asString(up["type"]) == "minio" {
|
|
return up
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func uniquify(name string, used map[string]int) string {
|
|
base := name
|
|
if base == "" {
|
|
base = "cam"
|
|
}
|
|
if used[base] == 0 {
|
|
used[base] = 1
|
|
return base
|
|
}
|
|
used[base]++
|
|
return fmt.Sprintf("%s_%d", base, used[base])
|
|
}
|
|
|
|
func asMap(v any) map[string]any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if m, ok := v.(map[string]any); ok {
|
|
return m
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func asSlice(v any) []any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if s, ok := v.([]any); ok {
|
|
return s
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func asString(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func asIntOr(v any, def int) int {
|
|
if v == nil {
|
|
return def
|
|
}
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return int(x)
|
|
case int:
|
|
return x
|
|
case int32:
|
|
return int(x)
|
|
case int64:
|
|
return int(x)
|
|
case string:
|
|
// ignore parse errors
|
|
return def
|
|
default:
|
|
return def
|
|
}
|
|
}
|
|
|
|
func asFloatOr(v any, def float64) float64 {
|
|
if v == nil {
|
|
return def
|
|
}
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x
|
|
case int:
|
|
return float64(x)
|
|
case int32:
|
|
return float64(x)
|
|
case int64:
|
|
return float64(x)
|
|
default:
|
|
return def
|
|
}
|
|
}
|
|
|
|
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{}
|
|
|
|
// Allow both built-in templates and templates already present in the current config.
|
|
// Merge rule: keep existing templates as-is; add built-in templates if missing.
|
|
tpls := map[string]any{}
|
|
for k, v := range cur.Templates {
|
|
tpls[k] = v
|
|
}
|
|
for k, v := range uiTemplates() {
|
|
if _, ok := tpls[k]; !ok {
|
|
tpls[k] = v
|
|
}
|
|
}
|
|
|
|
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 a well-formed empty state.
|
|
out.Instances = []UIInstance{}
|
|
out.Global = map[string]any{}
|
|
out.Queue = map[string]any{}
|
|
out.Templates = map[string]any{}
|
|
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{}
|
|
}
|
|
if out.Templates == nil {
|
|
out.Templates = 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"},
|
|
},
|
|
},
|
|
}
|
|
}
|