feat: support setting H.264 GOP

perf: merge H.264 SPS and PPS into I-frame
perf: refactor MJPEG frame detection
perf: update log timestamp to millisecond precision
perf: update script `update-nanokvm.py`
chore: bump Go to 1.23
chore: bump golang.org/x/net to v0.37.0
This commit is contained in:
wj-xiao
2025-03-20 18:31:09 +08:00
parent 1a3fd15fdf
commit 6ef83cb22f
24 changed files with 307 additions and 349 deletions

View File

@@ -46,32 +46,6 @@ def download_firmware() -> None:
print("Completed downloading firmware.") print("Completed downloading firmware.")
def download_lib():
print("download lib...")
device_key = read("/device_key")
url = f"https://maixvision.sipeed.com/api/v1/nanokvm/encryption?uid={device_key}"
headers = {"token": "MaixVision2024"}
response = requests.get(url, headers=headers, stream=True)
if response.status_code != 200:
raise Exception(f"download lib failed, status: {response.status_code}")
content_type = response.headers.get("content-type")
if content_type != "application/octet-stream":
raise Exception(f"download lib failed, content_type: {content_type}")
lib_file = f"{temporary}/libmaixcam_lib.so"
with open(lib_file, "wb") as f:
f.write(response.content)
lib_dir = f"{temporary}/latest/kvm_system/dl_lib/"
shutil.copy(lib_file, lib_dir)
print("download lib done")
def update() -> None: def update() -> None:
backup_dir = pathlib.Path("/root/old") backup_dir = pathlib.Path("/root/old")
firmware_dir = pathlib.Path("/kvmapp") firmware_dir = pathlib.Path("/kvmapp")
@@ -94,7 +68,7 @@ def change_permissions() -> None:
os.chmod(file_path, 0o755) os.chmod(file_path, 0o755)
print("change permissions done") print("change permissions done")
def main() -> None: def main() -> None:
try: try:
@@ -105,7 +79,6 @@ def main() -> None:
mkdir() mkdir()
download_firmware() download_firmware()
# download_lib()
update() update()
change_permissions() change_permissions()

View File

@@ -20,9 +20,7 @@ var (
kvmVisionOnce sync.Once kvmVisionOnce sync.Once
) )
type KvmVision struct { type KvmVision struct{}
mutex sync.Mutex
}
func GetKvmVision() *KvmVision { func GetKvmVision() *KvmVision {
kvmVisionOnce.Do(func() { kvmVisionOnce.Do(func() {
@@ -44,9 +42,6 @@ func GetKvmVision() *KvmVision {
} }
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) { func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) {
k.mutex.Lock()
defer k.mutex.Unlock()
var ( var (
kvmData *C.uint8_t kvmData *C.uint8_t
dataSize C.uint32_t dataSize C.uint32_t
@@ -72,10 +67,7 @@ func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data
return return
} }
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, sps []byte, pps []byte, result int) { func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, result int) {
k.mutex.Lock()
defer k.mutex.Unlock()
var ( var (
kvmData *C.uint8_t kvmData *C.uint8_t
dataSize C.uint32_t dataSize C.uint32_t
@@ -97,48 +89,10 @@ func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize)) data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
if result == 3 {
sps, _ = k.ReadH264SPS()
pps, _ = k.ReadH264PPS()
}
log.Debugf("read kvm image: %v", result) log.Debugf("read kvm image: %v", result)
return return
} }
func (k *KvmVision) ReadH264SPS() ([]byte, int) {
var (
kvmData *C.uint8_t
dataSize C.uint32_t
)
result := int(C.kvmv_get_sps_frame(&kvmData, &dataSize))
if result < 0 {
log.Errorf("failed to read sps: %v", result)
return nil, result
}
data := C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
return data, result
}
func (k *KvmVision) ReadH264PPS() ([]byte, int) {
var (
kvmData *C.uint8_t
dataSize C.uint32_t
)
result := int(C.kvmv_get_pps_frame(&kvmData, &dataSize))
if result < 0 {
log.Errorf("failed to read pps: %v", result)
return nil, result
}
data := C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
return data, result
}
func (k *KvmVision) SetHDMI(enable bool) int { func (k *KvmVision) SetHDMI(enable bool) int {
hdmiEnable := C.uint8_t(0) hdmiEnable := C.uint8_t(0)
if enable { if enable {
@@ -154,10 +108,17 @@ func (k *KvmVision) SetHDMI(enable bool) int {
return result return result
} }
func (k *KvmVision) Close() { func (k *KvmVision) SetGop(gop uint8) {
k.mutex.Lock() _gop := C.uint8_t(gop)
defer k.mutex.Unlock() C.set_h264_gop(_gop)
}
func (k *KvmVision) SetFrameDetect(frame uint8) {
_frame := C.uint8_t(frame)
C.set_frame_detact(_frame)
}
func (k *KvmVision) Close() {
C.kvmv_deinit() C.kvmv_deinit()
log.Debugf("stop kvm vision...") log.Debugf("stop kvm vision...")
} }

View File

@@ -8,6 +8,7 @@ type Screen struct {
FPS int FPS int
Quality uint16 Quality uint16
BitRate uint16 BitRate uint16
GOP uint8
} }
var ( var (
@@ -46,6 +47,7 @@ func GetScreen() *Screen {
Quality: 80, Quality: 80,
FPS: 30, FPS: 30,
BitRate: 3000, BitRate: 3000,
GOP: 30,
} }
}) })
@@ -70,6 +72,9 @@ func SetScreen(key string, value int) {
case "fps": case "fps":
screen.FPS = validateFPS(value) screen.FPS = validateFPS(value)
case "gop":
screen.GOP = uint8(value)
} }
} }

Binary file not shown.

View File

@@ -1,6 +1,8 @@
module NanoKVM-Server module NanoKVM-Server
go 1.22.1 go 1.23.0
toolchain go1.23.2
require ( require (
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
@@ -15,7 +17,7 @@ require (
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
github.com/unrolled/secure v1.15.0 github.com/unrolled/secure v1.15.0
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.36.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -71,9 +73,9 @@ require (
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.33.0 // indirect golang.org/x/net v0.37.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
) )

View File

@@ -160,17 +160,25 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -50,20 +50,20 @@ void set_venc_auto_recyc(uint8_t _enable);
-5: Retrieving image, please wait -5: Retrieving image, please wait
-4: Modifying image resolution, please wait -4: Modifying image resolution, please wait
-3: img buffer full -3: img buffer full
-2: VENC Errorl -2: VENC Error
-1: No images were acquired -1: No images were acquired
0: Acquire MJPEG encoded images 0: Acquire MJPEG encoded images
1: Acquire H264 encoded images(SPS) 1: Acquire H264 encoded images(SPS)[Deprecated]
2: Acquire H264 encoded images(PPS) 2: Acquire H264 encoded images(PPS)[Deprecated]
3: Acquire H264 encoded images(I) 3: Acquire H264 encoded images(I)
4: Acquire H264 encoded images(P) 4: Acquire H264 encoded images(P)
5: IMG not changed
**********************************************************************************/ **********************************************************************************/
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size); int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
int kvmv_get_sps_frame(uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
int kvmv_get_pps_frame(uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
int free_kvmv_data(uint8_t ** _pp_kvm_data); int free_kvmv_data(uint8_t ** _pp_kvm_data);
void free_all_kvmv_data(); void free_all_kvmv_data();
void set_h264_gop(uint8_t _gop); void set_h264_gop(uint8_t _gop);
void set_frame_detact(uint8_t _frame_detact);
void kvmv_deinit(); void kvmv_deinit();
uint8_t kvmv_hdmi_control(uint8_t _en); uint8_t kvmv_hdmi_control(uint8_t _en);
@@ -71,4 +71,4 @@ uint8_t kvmv_hdmi_control(uint8_t _en);
} }
#endif #endif
#endif // KVM_VISION_H_ #endif // KVM_VISION_H_

View File

@@ -22,7 +22,7 @@ func (f *formatter) Format(entry *logrus.Entry) ([]byte, error) {
buffer = &bytes.Buffer{} buffer = &bytes.Buffer{}
} }
now := entry.Time.Format("2006-01-02 15:04:05") now := entry.Time.Format("2006-01-02 15:04:05.000")
if entry.HasCaller() { if entry.HasCaller() {
fileName := filepath.Base(entry.Caller.File) fileName := filepath.Base(entry.Caller.File)

View File

@@ -1,9 +1,9 @@
package proto package proto
type GetFrameDetectRsp struct { type UpdateFrameDetectReq struct {
Enabled bool `json:"enabled"` Enabled bool `validate:"omitempty"`
} }
type UpdateFrameDetectRsp struct { type StopFrameDetectReq struct {
Enabled bool `json:"enabled"` Duration int `validate:"omitempty"`
} }

View File

@@ -12,8 +12,7 @@ func streamRouter(r *gin.Engine) {
api := r.Group("/api").Use(middleware.CheckToken()) api := r.Group("/api").Use(middleware.CheckToken())
api.GET("/stream/mjpeg", mjpeg.Connect) // mjpeg stream api.GET("/stream/mjpeg", mjpeg.Connect) // mjpeg stream
api.GET("/stream/mjpeg/detect", mjpeg.GetFrameDetect) // get frame detect state api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect
api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect state
api.POST("/stream/mjpeg/detect/stop", mjpeg.StopFrameDetect) // temporary stop frame detect api.POST("/stream/mjpeg/detect/stop", mjpeg.StopFrameDetect) // temporary stop frame detect
api.GET("/stream/h264", h264.Connect) // h264 stream api.GET("/stream/h264", h264.Connect) // h264 stream

View File

@@ -2,6 +2,7 @@ package h264
import ( import (
"encoding/json" "encoding/json"
"sync"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
@@ -9,8 +10,9 @@ import (
) )
type Client struct { type Client struct {
ws *websocket.Conn ws *websocket.Conn
pc *webrtc.PeerConnection pc *webrtc.PeerConnection
mutex sync.Mutex
} }
type Message struct { type Message struct {
@@ -36,9 +38,7 @@ func (c *Client) addTrack() {
return return
} }
mutex.Lock()
trackMap[c.ws] = videoTrack trackMap[c.ws] = videoTrack
mutex.Unlock()
} }
// register callback events // register callback events
@@ -61,13 +61,9 @@ func (c *Client) register() {
// ICE connection state has changed // ICE connection state has changed
c.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { c.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state == webrtc.ICEConnectionStateConnected && !isSending { if state == webrtc.ICEConnectionStateConnected && !isSending {
mutex.Lock() // start sending h264 data
if !isSending { go send()
// start sending h264 data isSending = true
go send()
isSending = true
}
mutex.Unlock()
} }
log.Debugf("ice connection state has changed to %s", state.String()) log.Debugf("ice connection state has changed to %s", state.String())
@@ -81,13 +77,11 @@ func (c *Client) readMessage() {
for { for {
_, raw, err := c.ws.ReadMessage() _, raw, err := c.ws.ReadMessage()
if err != nil { if err != nil {
mutex.Lock()
delete(trackMap, c.ws) delete(trackMap, c.ws)
if len(trackMap) == 0 && isSending { if isSending && len(trackMap) == 0 {
// stop sending when all websocket connections are closed // stop sending when all websocket connections are closed
exitSig <- true isSending = false
} }
mutex.Unlock()
log.Debugf("failed to read message: %s", err) log.Debugf("failed to read message: %s", err)
return return
@@ -144,9 +138,6 @@ func (c *Client) readMessage() {
return return
} }
case "heartbeat":
_ = c.sendMessage("heartbeat", "")
default: default:
log.Debugf("unhandled message event: %s", message.Event) log.Debugf("unhandled message event: %s", message.Event)
} }
@@ -155,8 +146,8 @@ func (c *Client) readMessage() {
// send websocket message // send websocket message
func (c *Client) sendMessage(event string, data string) error { func (c *Client) sendMessage(event string, data string) error {
mutex.RLock() c.mutex.Lock()
defer mutex.RUnlock() defer c.mutex.Unlock()
message := &Message{ message := &Message{
Event: event, Event: event,

View File

@@ -4,6 +4,7 @@ import (
"NanoKVM-Server/config" "NanoKVM-Server/config"
"net/http" "net/http"
"sync" "sync"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -18,9 +19,7 @@ var (
}, },
} }
trackMap = make(map[*websocket.Conn]*webrtc.TrackLocalStaticSample) trackMap = make(map[*websocket.Conn]*webrtc.TrackLocalStaticSample)
mutex = sync.RWMutex{}
isSending = false isSending = false
exitSig = make(chan bool, 1)
) )
func Connect(c *gin.Context) { func Connect(c *gin.Context) {
@@ -35,6 +34,9 @@ func Connect(c *gin.Context) {
log.Debugf("h264 websocket disconnected") log.Debugf("h264 websocket disconnected")
}() }()
var zeroTime time.Time
_ = wsConn.SetReadDeadline(zeroTime)
conf := config.GetInstance() conf := config.GetInstance()
var iceServers []webrtc.ICEServer var iceServers []webrtc.ICEServer
@@ -67,8 +69,9 @@ func Connect(c *gin.Context) {
}() }()
client := &Client{ client := &Client{
ws: wsConn, ws: wsConn,
pc: peerConn, pc: peerConn,
mutex: sync.Mutex{},
} }
client.addTrack() client.addTrack()

View File

@@ -2,7 +2,6 @@ package h264
import ( import (
"NanoKVM-Server/common" "NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"time" "time"
"github.com/pion/webrtc/v4/pkg/media" "github.com/pion/webrtc/v4/pkg/media"
@@ -18,63 +17,45 @@ func send() {
defer ticker.Stop() defer ticker.Stop()
vision := common.GetKvmVision() vision := common.GetKvmVision()
for { for range ticker.C {
select { if !isSending && len(trackMap) == 0 {
case <-ticker.C:
height := screen.Height
width, ok := common.ResolutionMap[height]
if !ok {
width = 0
height = 0
}
bitRate := screen.BitRate
if _, ok := common.BitRateMap[bitRate]; !ok {
bitRate = 3000
}
data, sps, pps, result := vision.ReadH264(width, height, bitRate)
if result < 0 {
continue
}
if result == 3 {
writeSample(sps, duration)
writeSample(pps, duration)
}
writeSample(data, duration)
stream.GetFrameRateCounter().Update()
if screen.FPS != fps {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
}
case <-exitSig:
mutex.Lock()
isSending = false
mutex.Unlock()
return return
} }
}
}
func writeSample(data []byte, duration time.Duration) { height := screen.Height
sample := media.Sample{ width, ok := common.ResolutionMap[height]
Data: data, if !ok {
Duration: duration, width = 0
} height = 0
}
mutex.RLock() bitRate := screen.BitRate
defer mutex.RUnlock() if _, ok := common.BitRateMap[bitRate]; !ok {
bitRate = 3000
}
for _, track := range trackMap { data, result := vision.ReadH264(width, height, bitRate)
if err := track.WriteSample(sample); err != nil { if result < 0 {
log.Errorf("failed to send h264 data: %s", err) continue
}
sample := media.Sample{
Data: data,
Duration: duration,
}
for _, track := range trackMap {
if err := track.WriteSample(sample); err != nil {
log.Errorf("failed to send h264 data: %s", err)
}
}
log.Debugf("send h264 data: %d", len(data))
if screen.FPS != fps {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
} }
} }
log.Debugf("send h264 data: %d", len(data))
} }

View File

@@ -1,132 +1,55 @@
package mjpeg package mjpeg
import ( import (
"errors" "NanoKVM-Server/common"
"os" "NanoKVM-Server/proto"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto"
) )
const ( const FrameDetectInterval uint8 = 60
frameDetect = "/etc/kvm/frame_detact"
frameDetectTmp = "/etc/kvm/frame_detact_tmp"
)
type UpdateFrameDetectRsp struct {
Enabled bool `json:"enabled"`
}
func GetFrameDetect(c *gin.Context) {
var rsp proto.Response
isEnabled, err := isFrameDetectEnabled()
if err != nil {
rsp.ErrRsp(c, -1, "unknown frame detect status")
return
}
rsp.OkRspWithData(c, &proto.GetFrameDetectRsp{
Enabled: isEnabled,
})
log.Debugf("get frame detect success, enabled: %t", isEnabled)
}
func UpdateFrameDetect(c *gin.Context) { func UpdateFrameDetect(c *gin.Context) {
var req proto.UpdateFrameDetectReq
var rsp proto.Response var rsp proto.Response
isEnabled, err := isFrameDetectEnabled() if err := proto.ParseFormRequest(c, &req); err != nil {
if err != nil { rsp.ErrRsp(c, -1, "invalid parameters")
rsp.ErrRsp(c, -1, "unknown frame status")
return return
} }
if isEnabled { var frame uint8 = 0
_ = os.Remove(frameDetect) if req.Enabled {
_ = os.Remove(frameDetectTmp) frame = FrameDetectInterval
} else {
file, err2 := os.OpenFile(frameDetect, os.O_CREATE|os.O_TRUNC, 0o644)
if err2 != nil {
rsp.ErrRsp(c, -3, "enable frame detect failed")
return
}
defer func() {
_ = file.Close()
}()
} }
isEnabled, err = isFrameDetectEnabled() common.GetKvmVision().SetFrameDetect(frame)
if err != nil {
rsp.ErrRsp(c, -4, "unknown frame status")
return
}
rsp.OkRspWithData(c, &UpdateFrameDetectRsp{ rsp.OkRsp(c)
Enabled: isEnabled, log.Debugf("update frame detect: %t", req.Enabled)
})
log.Debugf("update frame detect success, enabled: %t", isEnabled)
} }
func StopFrameDetect(c *gin.Context) { func StopFrameDetect(c *gin.Context) {
var req proto.StopFrameDetectReq
var rsp proto.Response var rsp proto.Response
exist, err := isFileExist(frameDetect) if err := proto.ParseFormRequest(c, &req); err != nil {
if err != nil { rsp.ErrRsp(c, -1, "invalid parameters")
rsp.ErrRsp(c, -1, "unknown frame status")
return return
} }
if !exist { duration := 10 * time.Second
rsp.OkRsp(c) if req.Duration > 0 {
return duration = time.Duration(req.Duration) * time.Second
} }
err = os.Rename(frameDetect, frameDetectTmp) vision := common.GetKvmVision()
if err != nil {
rsp.ErrRsp(c, -2, "stop operation failed")
return
}
go func() { vision.SetFrameDetect(0)
time.Sleep(20 * time.Second) time.Sleep(duration)
_ = os.Rename(frameDetectTmp, frameDetect) vision.SetFrameDetect(FrameDetectInterval)
log.Debug("frame detect started")
}()
rsp.OkRsp(c) rsp.OkRsp(c)
log.Debug("frame detect stopped")
}
func isFrameDetectEnabled() (bool, error) {
exist, err := isFileExist(frameDetect)
if err != nil {
return false, err
}
if exist {
return true, nil
}
exist, err = isFileExist(frameDetectTmp)
if err != nil {
return false, err
}
return exist, nil
}
func isFileExist(name string) (bool, error) {
_, err := os.Stat(name)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, nil
}
return true, nil
} }

View File

@@ -11,8 +11,8 @@ import (
) )
var ( var (
chanMap = make(map[*gin.Context]chan struct{}) chanMap = make(map[*gin.Context]bool)
mutex = sync.RWMutex{} mutex = sync.Mutex{}
exitSig = make(chan bool, 1) exitSig = make(chan bool, 1)
) )
@@ -23,13 +23,13 @@ func Connect(c *gin.Context) {
c.Header("Pragma", "no-cache") c.Header("Pragma", "no-cache")
mutex.Lock() mutex.Lock()
chanMap[c] = make(chan struct{}, 1) chanMap[c] = true
if len(chanMap) == 1 { if len(chanMap) == 1 {
go send() go send()
} }
mutex.Unlock() mutex.Unlock()
<-chanMap[c] <-c.Request.Context().Done()
mutex.Lock() mutex.Lock()
delete(chanMap, c) delete(chanMap, c)
@@ -63,14 +63,13 @@ func send() {
} }
data, result := vision.ReadMjpeg(width, height, quality) data, result := vision.ReadMjpeg(width, height, quality)
if result < 0 { if result < 0 || result == 5 {
continue continue
} }
for c, ch := range chanMap { for c := range chanMap {
if err := write(c, data); err != nil { if err := write(c, data); err != nil {
log.Debugf("failed to write mjpeg data: %s", err) log.Debugf("failed to write mjpeg data: %s", err)
close(ch)
} }
} }
log.Debugf("send mjpeg data: %d", len(data)) log.Debugf("send mjpeg data: %d", len(data))

View File

@@ -4,6 +4,7 @@ import (
"NanoKVM-Server/common" "NanoKVM-Server/common"
"fmt" "fmt"
"os" "os"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -22,30 +23,34 @@ func (s *Service) SetScreen(c *gin.Context) {
var req proto.SetScreenReq var req proto.SetScreenReq
var rsp proto.Response var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil { err := proto.ParseFormRequest(c, &req)
if err != nil {
rsp.ErrRsp(c, -1, "invalid arguments") rsp.ErrRsp(c, -1, "invalid arguments")
return return
} }
file, ok := screenFileMap[req.Type] switch req.Type {
if !ok { case "type":
rsp.ErrRsp(c, -2, "invalid arguments") data := "h264"
return
}
data := fmt.Sprintf("%d", req.Value)
if req.Type == "type" {
if req.Value == 0 { if req.Value == 0 {
data = "mjpeg" data = "mjpeg"
} else {
data = "h264"
} }
err = writeScreen("type", data)
case "gop":
gop := 30
if req.Value >= 1 && req.Value <= 100 {
gop = req.Value
}
common.GetKvmVision().SetGop(uint8(gop))
default:
data := strconv.Itoa(req.Value)
err = writeScreen(req.Type, data)
} }
err := os.WriteFile(file, []byte(data), 0o666)
if err != nil { if err != nil {
log.Errorf("write kvm %s failed: %s", file, err) rsp.ErrRsp(c, -2, "update screen failed")
rsp.ErrRsp(c, -3, "update screen failed")
return return
} }
@@ -54,3 +59,18 @@ func (s *Service) SetScreen(c *gin.Context) {
log.Debugf("update screen: %+v", req) log.Debugf("update screen: %+v", req)
rsp.OkRsp(c) rsp.OkRsp(c)
} }
func writeScreen(key string, value string) error {
file, ok := screenFileMap[key]
if !ok {
return fmt.Errorf("invalid argument %s", key)
}
err := os.WriteFile(file, []byte(value), 0o666)
if err != nil {
log.Errorf("write kvm %s failed: %s", file, err)
return err
}
return nil
}

View File

@@ -1,16 +1,17 @@
import { http } from '@/lib/http.ts'; import { http } from '@/lib/http.ts';
// get whether the frame detect is enabled
export function getFrameDetect() {
return http.get('/api/stream/mjpeg/detect');
}
// enable/disable frame detect // enable/disable frame detect
export function updateFrameDetect() { export function updateFrameDetect(enabled: boolean) {
return http.post('/api/stream/mjpeg/detect'); const data = {
enabled
};
return http.post('/api/stream/mjpeg/detect', data);
} }
// pause frame detect for a while (prevent a black screen when opening the page for the first time) // pause frame detect for a while (prevent a black screen when opening the page for the first time)
export function stopFrameDetect() { export function stopFrameDetect(duration: number) {
return http.post('/api/stream/mjpeg/detect/stop'); const data = {
duration
};
return http.post('/api/stream/mjpeg/detect/stop', data);
} }

View File

@@ -5,6 +5,8 @@ const VIDEO_MODE_KEY = 'nano-kvm-vide-mode';
const WEB_RESOLUTION_KEY = 'nano-kvm-web-resolution'; const WEB_RESOLUTION_KEY = 'nano-kvm-web-resolution';
const FPS_KEY = 'nano-kvm-fps'; const FPS_KEY = 'nano-kvm-fps';
const QUALITY_KEY = 'nano-kvm-quality'; const QUALITY_KEY = 'nano-kvm-quality';
const GOP_KEY = 'nano-kvm-gop';
const FRAME_DETECT_KEY = 'nano-kvm-frame-detect';
const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style'; const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode'; const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
const SKIP_UPDATE_KEY = 'nano-kvm-check-update'; const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
@@ -92,6 +94,24 @@ export function setQuality(quality: number) {
localStorage.setItem(QUALITY_KEY, String(quality)); localStorage.setItem(QUALITY_KEY, String(quality));
} }
export function getGop() {
const gop = localStorage.getItem(GOP_KEY);
return gop ? Number(gop) : null;
}
export function setGop(gop: number) {
localStorage.setItem(GOP_KEY, String(gop));
}
export function getFrameDetect(): boolean {
const enabled = localStorage.getItem(FRAME_DETECT_KEY);
return enabled ? Boolean(enabled) : true;
}
export function setFrameDetect(enabled: boolean) {
localStorage.setItem(FRAME_DETECT_KEY, String(enabled));
}
export function getMouseStyle() { export function getMouseStyle() {
return localStorage.getItem(MOUSE_STYLE_KEY); return localStorage.getItem(MOUSE_STYLE_KEY);
} }

View File

@@ -1,31 +1,39 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import clsx from 'clsx';
import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react'; import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getFrameDetect, updateFrameDetect } from '@/api/stream.ts'; import * as api from '@/api/stream.ts';
import * as ls from '@/lib/localstorage.ts';
export const FrameDetect = () => { export const FrameDetect = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => { useEffect(() => {
getFrameDetect().then((rsp) => { const enabled = ls.getFrameDetect();
if (rsp.code === 0) { if (enabled) {
setIsEnabled(rsp.data.enabled); setIsEnabled(true);
} } else {
}); api.updateFrameDetect(false);
}
}, []); }, []);
function update() { function update() {
if (isLoading) return; if (isLoading) return;
setIsLoading(true); setIsLoading(true);
updateFrameDetect() const enabled = !isEnabled;
api
.updateFrameDetect(enabled)
.then((rsp) => { .then((rsp) => {
if (rsp.code === 0) { if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled); setIsEnabled(enabled);
ls.setFrameDetect(enabled);
} }
}) })
.finally(() => { .finally(() => {
@@ -41,18 +49,20 @@ export const FrameDetect = () => {
> >
{isLoading ? ( {isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} /> <LoaderCircleIcon className="animate-spin" size={18} />
) : isEnabled ? (
<div>
<Tally4Icon color="#22c55e" size={18} className="block group-hover:hidden" />
<Tally5Icon color="#ef4444" size={18} className="hidden group-hover:block" />
</div>
) : ( ) : (
<div> <>
<Tally5Icon size={18} className="block group-hover:hidden" /> {isEnabled ? <Tally4Icon color="#22c55e" size={18} /> : <Tally5Icon size={18} />}
<Tally4Icon color="#22c55e" size={18} className="hidden group-hover:block" />
</div> <span
className={clsx(
'select-none text-sm',
isEnabled ? 'group-hover:text-red-500' : 'group-hover:text-green-500'
)}
>
{t('screen.frameDetect')}
</span>
</>
)} )}
<span className="select-none text-sm">{t('screen.frameDetect')}</span>
</div> </div>
</Tooltip> </Tooltip>
); );

View File

@@ -0,0 +1,57 @@
import { Popover } from 'antd';
import { CheckIcon, SquareKanbanIcon } from 'lucide-react';
import { updateScreen } from '@/api/vm.ts';
import { setGop as setCookie } from '@/lib/localstorage';
type GopProps = {
gop: number;
setGop: (gop: number) => void;
};
const gopList = [
{ key: 10, label: '10' },
{ key: 30, label: '30' },
{ key: 50, label: '50' },
{ key: 100, label: '100' }
];
export const Gop = ({ gop, setGop }: GopProps) => {
async function update(value: number) {
if (value === gop) return;
const rsp = await updateScreen('gop', value);
if (rsp.code !== 0) {
return;
}
setGop(value);
setCookie(value);
}
const content = (
<>
{gopList.map((item) => (
<div
key={item.key}
className="flex cursor-pointer select-none items-center rounded py-1 pl-1 pr-6 hover:bg-neutral-700/70"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
{item.key === gop && <CheckIcon size={14} />}
</div>
<span>{item.label}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
<SquareKanbanIcon size={18} />
<span className="select-none text-sm">GOP</span>
</div>
</Popover>
);
};

View File

@@ -11,6 +11,7 @@ import { MenuItem } from '@/components/menu-item.tsx';
import { BitRateMap, QualityMap } from './constants.ts'; import { BitRateMap, QualityMap } from './constants.ts';
import { Fps } from './fps'; import { Fps } from './fps';
import { FrameDetect } from './frame-detect'; import { FrameDetect } from './frame-detect';
import { Gop } from './gop.tsx';
import { Quality } from './quality'; import { Quality } from './quality';
import { Reset } from './reset.tsx'; import { Reset } from './reset.tsx';
import { Resolution } from './resolution'; import { Resolution } from './resolution';
@@ -24,37 +25,47 @@ export const Screen = () => {
const [fps, setFps] = useState(30); const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(2); const [quality, setQuality] = useState(2);
const [gop, setGop] = useState(30);
useEffect(() => { useEffect(() => {
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1); updateScreen('type', videoMode === 'mjpeg' ? 0 : 1);
updateScreen('resolution', resolution!.height); updateScreen('resolution', resolution!.height);
updateQuality(); updateQuality();
updateFps(); updateFps();
updateGop();
}, []); }, []);
function updateQuality() { async function updateQuality() {
const cookieQuality = ls.getQuality(); const cookieQuality = ls.getQuality();
if (!cookieQuality) return; if (!cookieQuality) return;
const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2; const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2;
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!; const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
updateScreen('quality', value).then((rsp) => { const rsp = await updateScreen('quality', value);
if (rsp.code === 0) { if (rsp.code === 0) {
setQuality(key); setQuality(key);
} }
});
} }
function updateFps() { async function updateFps() {
const cookieFps = ls.getFps(); const cookieFps = ls.getFps();
if (!cookieFps) return; if (!cookieFps) return;
updateScreen('fps', cookieFps).then((rsp) => { const rsp = await updateScreen('fps', cookieFps);
if (rsp.code === 0) { if (rsp.code === 0) {
setFps(cookieFps); setFps(cookieFps);
} }
}); }
async function updateGop() {
const cookieGop = ls.getGop();
if (!cookieGop) return;
const rsp = await updateScreen('gop', cookieGop);
if (rsp.code === 0) {
setGop(cookieGop);
}
} }
const content = ( const content = (
@@ -63,6 +74,7 @@ export const Screen = () => {
<Resolution /> <Resolution />
<Quality quality={quality} setQuality={setQuality} /> <Quality quality={quality} setQuality={setQuality} />
<Fps fps={fps} setFps={setFps} /> <Fps fps={fps} setFps={setFps} />
{videoMode === 'h264' && <Gop gop={gop} setGop={setGop} />}
{videoMode === 'mjpeg' && <FrameDetect />} {videoMode === 'mjpeg' && <FrameDetect />}
<Reset /> <Reset />
</div> </div>

View File

@@ -55,9 +55,9 @@ export const Resolution = () => {
</div> </div>
) : ( ) : (
<> <>
<span className="flex w-[32px]">{res.width}</span> <span>{res.width}</span>
<span>x</span> <span className="px-1">x</span>
<span className="w-[36px]">{res.height}</span> <span>{res.height}</span>
</> </>
)} )}
</div> </div>

View File

@@ -15,7 +15,6 @@ export const H264 = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
let heartbeatTimer: any;
const videoElement = document.getElementById('screen') as HTMLVideoElement; const videoElement = document.getElementById('screen') as HTMLVideoElement;
const url = `${getBaseUrl('ws')}/api/stream/h264`; const url = `${getBaseUrl('ws')}/api/stream/h264`;
@@ -52,10 +51,6 @@ export const H264 = () => {
ws.send(JSON.stringify({ event: 'offer', data: JSON.stringify(offer) })); ws.send(JSON.stringify({ event: 'offer', data: JSON.stringify(offer) }));
}) })
.catch(console.log); .catch(console.log);
heartbeatTimer = setInterval(() => {
ws.send(JSON.stringify({ event: 'heartbeat', data: '' }));
}, 60 * 1000);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -75,9 +70,6 @@ export const H264 = () => {
pc.addIceCandidate(data).catch(console.log); pc.addIceCandidate(data).catch(console.log);
break; break;
case 'heartbeat':
break;
default: default:
console.log('unhandled event: ', msg.event); console.log('unhandled event: ', msg.event);
} }
@@ -91,9 +83,6 @@ export const H264 = () => {
}, 15 * 1000); }, 15 * 1000);
return () => { return () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
}
ws.close(); ws.close();
pc.close(); pc.close();
}; };

View File

@@ -5,6 +5,7 @@ import { useAtomValue } from 'jotai';
import MonitorXIcon from '@/assets/images/monitor-x.svg'; import MonitorXIcon from '@/assets/images/monitor-x.svg';
import { stopFrameDetect } from '@/api/stream.ts'; import { stopFrameDetect } from '@/api/stream.ts';
import { getFrameDetect } from '@/lib/localstorage.ts';
import { getBaseUrl } from '@/lib/service.ts'; import { getBaseUrl } from '@/lib/service.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts'; import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts'; import { resolutionAtom } from '@/jotai/screen.ts';
@@ -15,7 +16,10 @@ export const Mjpeg = () => {
useEffect(() => { useEffect(() => {
// stop frame detect for a while // stop frame detect for a while
stopFrameDetect(); const enabled = getFrameDetect();
if (enabled) {
stopFrameDetect(10);
}
}, [resolution]); }, [resolution]);
return ( return (