mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Make input-region calibration work across MJPEG and H.264 video modes, including robust four-sided black-border detection and initial-selection edge snapping. Persist multiple reusable manual regions, keep manual and original-resolution choices mutually exclusive, migrate legacy settings, and localize the new controls for every supported language.
390 lines
10 KiB
Go
390 lines
10 KiB
Go
package vm
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"NanoKVM-Server/proto"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
var (
|
|
inputRegionFile = "/etc/kvm/input-region.json"
|
|
inputRegionMu sync.RWMutex
|
|
)
|
|
|
|
func (s *Service) GetInputResolution(c *gin.Context) {
|
|
var rsp proto.Response
|
|
width, widthErr := readPositiveInt("/kvmapp/kvm/width")
|
|
height, heightErr := readPositiveInt("/kvmapp/kvm/height")
|
|
if widthErr != nil || heightErr != nil {
|
|
rsp.ErrRsp(c, -1, "failed to read input resolution")
|
|
return
|
|
}
|
|
rsp.OkRspWithData(c, &proto.GetInputResolutionRsp{Width: width, Height: height})
|
|
}
|
|
|
|
func readPositiveInt(path string) (int, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
value, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
|
if err != nil || value <= 0 {
|
|
return 0, errors.New("invalid positive integer")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (s *Service) GetInputRegion(c *gin.Context) {
|
|
var rsp proto.Response
|
|
|
|
inputRegionMu.RLock()
|
|
region, err := readInputRegion(inputRegionFile)
|
|
inputRegionMu.RUnlock()
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}})
|
|
return
|
|
}
|
|
if err != nil {
|
|
rsp.ErrRsp(c, -1, "failed to read input region")
|
|
return
|
|
}
|
|
if region == nil {
|
|
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}})
|
|
return
|
|
}
|
|
|
|
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: *region})
|
|
}
|
|
|
|
func (s *Service) SetInputRegion(c *gin.Context) {
|
|
var req proto.SetInputRegionReq
|
|
var rsp proto.Response
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
rsp.ErrRsp(c, -1, "invalid arguments")
|
|
return
|
|
}
|
|
|
|
inputRegionMu.Lock()
|
|
defer inputRegionMu.Unlock()
|
|
|
|
if req.Mode == "off" || req.Mode == "auto" ||
|
|
(req.Mode == "manual" && req.FrameWidth == nil && req.FrameHeight == nil &&
|
|
req.Left == nil && req.Top == nil && req.Width == nil && req.Height == nil) {
|
|
region, err := readInputRegion(inputRegionFile)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
rsp.ErrRsp(c, -2, "failed to read input region")
|
|
return
|
|
}
|
|
if region == nil {
|
|
region = &proto.InputRegion{}
|
|
}
|
|
region.Mode = req.Mode
|
|
if req.Resolutions != nil {
|
|
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
|
|
rsp.ErrRsp(c, -1, "invalid original resolutions")
|
|
return
|
|
}
|
|
region.Resolutions = *req.Resolutions
|
|
}
|
|
if req.SelectedResolution != nil {
|
|
region.SelectedResolution = *req.SelectedResolution
|
|
}
|
|
if req.Regions != nil {
|
|
if err := validateManualRegions(*req.Regions); err != nil {
|
|
rsp.ErrRsp(c, -1, "invalid manual regions")
|
|
return
|
|
}
|
|
region.Regions = *req.Regions
|
|
if len(region.Regions) == 0 {
|
|
clearLegacyManualRegion(region)
|
|
}
|
|
}
|
|
if req.SelectedRegion != nil {
|
|
region.SelectedRegion = *req.SelectedRegion
|
|
}
|
|
if err := validateSelections(*region); err != nil {
|
|
rsp.ErrRsp(c, -1, "invalid selected resolution")
|
|
return
|
|
}
|
|
if err := writeInputRegion(inputRegionFile, *region); err != nil {
|
|
rsp.ErrRsp(c, -2, "failed to save input region mode")
|
|
return
|
|
}
|
|
rsp.OkRsp(c)
|
|
return
|
|
}
|
|
if req.Mode != "manual" {
|
|
rsp.ErrRsp(c, -1, "invalid input region mode")
|
|
return
|
|
}
|
|
region, err := inputRegionFromRequest(req)
|
|
if err != nil {
|
|
rsp.ErrRsp(c, -1, "invalid input region")
|
|
return
|
|
}
|
|
previous, readErr := readInputRegion(inputRegionFile)
|
|
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
|
rsp.ErrRsp(c, -2, "failed to read input region")
|
|
return
|
|
}
|
|
if previous != nil {
|
|
region.Resolutions = previous.Resolutions
|
|
region.SelectedResolution = previous.SelectedResolution
|
|
region.Regions = previous.Regions
|
|
region.SelectedRegion = previous.SelectedRegion
|
|
}
|
|
if req.Resolutions != nil {
|
|
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
|
|
rsp.ErrRsp(c, -1, "invalid original resolutions")
|
|
return
|
|
}
|
|
region.Resolutions = *req.Resolutions
|
|
}
|
|
if req.SelectedResolution != nil {
|
|
region.SelectedResolution = *req.SelectedResolution
|
|
}
|
|
if req.Regions != nil {
|
|
if err := validateManualRegions(*req.Regions); err != nil {
|
|
rsp.ErrRsp(c, -1, "invalid manual regions")
|
|
return
|
|
}
|
|
region.Regions = *req.Regions
|
|
if len(region.Regions) == 0 {
|
|
clearLegacyManualRegion(®ion)
|
|
}
|
|
}
|
|
if req.SelectedRegion != nil {
|
|
region.SelectedRegion = *req.SelectedRegion
|
|
}
|
|
if err := validateSelections(region); err != nil {
|
|
rsp.ErrRsp(c, -1, "invalid selected resolution")
|
|
return
|
|
}
|
|
if err := writeInputRegion(inputRegionFile, region); err != nil {
|
|
rsp.ErrRsp(c, -2, "failed to save input region")
|
|
return
|
|
}
|
|
|
|
rsp.OkRsp(c)
|
|
}
|
|
|
|
func validateOriginalResolutions(resolutions []proto.OriginalResolution) error {
|
|
seen := make(map[proto.OriginalResolution]struct{}, len(resolutions))
|
|
for _, resolution := range resolutions {
|
|
if resolution.Width <= 0 || resolution.Height <= 0 {
|
|
return errors.New("resolution dimensions must be positive")
|
|
}
|
|
if _, ok := seen[resolution]; ok {
|
|
return errors.New("duplicate resolution")
|
|
}
|
|
seen[resolution] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSelectedResolution(selected string, resolutions []proto.OriginalResolution) error {
|
|
if selected == "" {
|
|
return nil
|
|
}
|
|
for _, resolution := range resolutions {
|
|
if selected == resolutionKey(resolution) {
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("selected resolution not found")
|
|
}
|
|
|
|
func validateManualRegions(regions []proto.ManualRegion) error {
|
|
seen := make(map[string]struct{}, len(regions))
|
|
for _, region := range regions {
|
|
if err := validateManualRegion(region); err != nil {
|
|
return err
|
|
}
|
|
key := manualRegionKey(region)
|
|
if _, ok := seen[key]; ok {
|
|
return errors.New("duplicate manual region")
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateManualRegion(region proto.ManualRegion) error {
|
|
return validateInputRegion(proto.InputRegion{
|
|
Mode: "manual",
|
|
FrameWidth: region.FrameWidth,
|
|
FrameHeight: region.FrameHeight,
|
|
Left: region.Left,
|
|
Top: region.Top,
|
|
Width: region.Width,
|
|
Height: region.Height,
|
|
})
|
|
}
|
|
|
|
func validateSelections(region proto.InputRegion) error {
|
|
if region.SelectedResolution != "" && region.SelectedRegion != "" {
|
|
return errors.New("only one input region source may be selected")
|
|
}
|
|
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
|
|
return err
|
|
}
|
|
if region.SelectedRegion == "" {
|
|
return nil
|
|
}
|
|
for _, item := range region.Regions {
|
|
if region.SelectedRegion == manualRegionKey(item) {
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("selected manual region not found")
|
|
}
|
|
|
|
func resolutionKey(resolution proto.OriginalResolution) string {
|
|
return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height)
|
|
}
|
|
|
|
func manualRegionKey(region proto.ManualRegion) string {
|
|
return fmt.Sprintf("%dx%d", region.Width, region.Height)
|
|
}
|
|
|
|
func clearLegacyManualRegion(region *proto.InputRegion) {
|
|
region.FrameWidth = 0
|
|
region.FrameHeight = 0
|
|
region.Left = 0
|
|
region.Top = 0
|
|
region.Width = 0
|
|
region.Height = 0
|
|
}
|
|
|
|
func inputRegionFromRequest(req proto.SetInputRegionReq) (proto.InputRegion, error) {
|
|
if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil ||
|
|
req.Top == nil || req.Width == nil || req.Height == nil {
|
|
return proto.InputRegion{}, errors.New("input region must be complete")
|
|
}
|
|
|
|
region := proto.InputRegion{
|
|
Mode: "manual",
|
|
FrameWidth: *req.FrameWidth,
|
|
FrameHeight: *req.FrameHeight,
|
|
Left: *req.Left,
|
|
Top: *req.Top,
|
|
Width: *req.Width,
|
|
Height: *req.Height,
|
|
}
|
|
return region, validateInputRegion(region)
|
|
}
|
|
|
|
func validateInputRegion(region proto.InputRegion) error {
|
|
if region.Mode == "off" || region.Mode == "auto" {
|
|
return nil
|
|
}
|
|
if region.Mode == "manual" && region.FrameWidth == 0 && region.FrameHeight == 0 {
|
|
return nil
|
|
}
|
|
if region.Mode != "manual" {
|
|
return errors.New("invalid input region mode")
|
|
}
|
|
if region.FrameWidth <= 0 || region.FrameHeight <= 0 {
|
|
return errors.New("frame dimensions must be positive")
|
|
}
|
|
if region.Left < 0 || region.Top < 0 || region.Width <= 0 || region.Height <= 0 {
|
|
return errors.New("region dimensions must be positive and offsets non-negative")
|
|
}
|
|
if region.Left > region.FrameWidth-region.Width || region.Top > region.FrameHeight-region.Height {
|
|
return errors.New("region must be inside frame")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readInputRegion(path string) (*proto.InputRegion, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var region *proto.InputRegion
|
|
if err := json.Unmarshal(data, ®ion); err != nil {
|
|
return nil, nil
|
|
}
|
|
if region == nil {
|
|
return nil, nil
|
|
}
|
|
if region.Mode == "" {
|
|
region.Mode = "manual"
|
|
}
|
|
if len(region.Regions) == 0 && region.FrameWidth > 0 && region.FrameHeight > 0 {
|
|
manual := proto.ManualRegion{
|
|
FrameWidth: region.FrameWidth,
|
|
FrameHeight: region.FrameHeight,
|
|
Left: region.Left,
|
|
Top: region.Top,
|
|
Width: region.Width,
|
|
Height: region.Height,
|
|
}
|
|
region.Regions = []proto.ManualRegion{manual}
|
|
if region.SelectedResolution == "" {
|
|
region.SelectedRegion = manualRegionKey(manual)
|
|
}
|
|
}
|
|
if err := validateInputRegion(*region); err != nil {
|
|
return nil, nil
|
|
}
|
|
if err := validateManualRegions(region.Regions); err != nil {
|
|
return nil, nil
|
|
}
|
|
if err := validateSelections(*region); err != nil {
|
|
return nil, nil
|
|
}
|
|
return region, nil
|
|
}
|
|
|
|
func writeInputRegion(path string, region proto.InputRegion) error {
|
|
data, err := json.Marshal(region)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
tmp, err := os.CreateTemp(dir, ".input-region-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Chmod(0o644); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpPath, path)
|
|
}
|
|
|
|
func removeInputRegion(path string) error {
|
|
err := os.Remove(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|