Compare commits

..

29 Commits

Author SHA1 Message Date
TonyChyi
7dd665cea1 update 2022-10-18 20:26:36 +08:00
TonyChyi
3812babc58 faster ! 2022-10-17 16:16:36 +08:00
TonyChyi
c64db4a7a5 to be continued 2022-10-12 11:23:14 +08:00
Sense T
f1fe154e01 update qemu options 2022-10-11 06:07:42 +00:00
TonyChyi
3af8cf2090 tmpfs needed 2022-10-11 08:31:16 +08:00
TonyChyi
935d5ade76 video latency should be less 2022-10-11 07:47:04 +08:00
TonyChyi
07d3ae3717 to be continued 2022-10-10 19:12:26 +08:00
Sense T
4844944c37 update todo 2022-10-10 08:44:37 +00:00
Sense T
1be49c0dd2 1 2022-10-03 00:15:43 +00:00
Sense T
708732a37b some fix 2022-10-02 11:29:57 +00:00
Sense T
b945218c85 driver should no stuck 2022-10-02 10:50:08 +00:00
Sense T
c22c399f40 暂停,需要解决一些问题 2022-09-30 05:46:58 +00:00
TonyChyi
dc9b5ce0b1 update device list 2022-09-30 12:26:02 +08:00
TonyChyi
acf74153ef sdp交换存在问题 2022-09-30 12:24:32 +08:00
TonyChyi
5c93e698b6 resize! 2022-09-28 14:29:54 +08:00
TonyChyi
095b52fb5d driver done 2022-09-28 14:14:26 +08:00
TonyChyi
64584cde86 1 2022-09-28 09:25:14 +08:00
TonyChyi
a597aef7ea 还是需要重构 2022-09-28 09:11:13 +08:00
TonyChyi
24ae8d7657 稍后再做 2022-09-27 17:23:49 +08:00
TonyChyi
de24203100 音频流仍然存在问题,后续debug 2022-09-27 16:59:47 +08:00
TonyChyi
b7b7e78614 opus didnt support 44.1kHz 2022-09-27 14:45:18 +08:00
TonyChyi
edbe406f5b 声音部分的代码仍需调试,现在panic 2022-09-27 14:36:25 +08:00
TonyChyi
7ef721afb7 zero sample when no pcm data 2022-09-27 12:34:57 +08:00
TonyChyi
39a89b80e2 debug needed 2022-09-27 12:32:23 +08:00
TonyChyi
18f5d649d1 rework needed 2022-09-27 10:21:06 +08:00
TonyChyi
821c0cffb8 debug needed 2022-09-26 16:07:25 +08:00
Sense T
bd0812cc42 debug needed 2022-09-26 06:54:32 +00:00
Sense T
4c80f8a25e debug needed 2022-09-26 03:04:07 +00:00
Sense T
0ce8374276 debug needed 2022-09-26 03:03:57 +00:00
38 changed files with 8456 additions and 2 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
config.yaml
*.mp4
test
test.*
experimental
dist
.DS_Store

View File

@ -1,3 +1,3 @@
# ace
# ACE Cloud Emulator
ACE Cloud Emulator
A webrtc based qemu UI

21
TODO Normal file
View File

@ -0,0 +1,21 @@
# 2022-9-28
1. 视频分辨率切换问题->弃用vnc
2. 还是没声音以及panic
3. 整体重构!
4. 综合调试
# 2022-9-30
1. 视频没有图像
2. qemu只在声卡初始化后才开始抓取声音
# 2022-10-3
1. 虚拟设备应当一直运行,无阻塞
# 2022-10-10
1. 视频设备延迟过高
# 2022-10-11
1. 解决视频设备延迟->丢帧机制或者使用tmpfs->RGB到YUV时双for循环慢平均在30-50ms
# 2022-10-18
1. 视频稳定在15ms左右但大分辨率仍然慢

74
cmd/config/config.go Normal file
View File

@ -0,0 +1,74 @@
package config
import (
"os"
"git.sense-t.eu.org/ACE/ace/servers/qemuserver"
"git.sense-t.eu.org/ACE/ace/servers/webserver"
"github.com/urfave/cli/v2"
"gopkg.in/yaml.v3"
)
var Command *cli.Command
type Config struct {
Debug bool `yaml:"debug"`
WEBServer *webserver.Options `yaml:"webserver"`
Qemu *qemuserver.Options `yaml:"qemu"`
}
func init() {
Command = &cli.Command{
Name: "config",
Usage: "config file options",
Aliases: []string{"conf", "cfg"},
Subcommands: []*cli.Command{
{
Name: "generate",
Usage: "generate config file",
Action: genconf,
Aliases: []string{"gen"},
},
{
Name: "check",
Usage: "check config file",
Action: checkconf,
},
},
}
}
func NewConfig() *Config {
return &Config{
Debug: true,
WEBServer: webserver.ExampleOptions(),
Qemu: qemuserver.ExampleOptions(),
}
}
func genconf(c *cli.Context) error {
f, err := os.OpenFile(c.String("config"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer f.Close()
return yaml.NewEncoder(f).Encode(NewConfig())
}
func checkconf(c *cli.Context) error {
_, err := ReadConfig(c)
return err
}
func ReadConfig(c *cli.Context) (*Config, error) {
f, err := os.OpenFile(c.String("config"), os.O_RDONLY, 0644)
if err != nil {
return nil, err
}
defer f.Close()
config := &Config{}
err = yaml.NewDecoder(f).Decode(config)
return config, err
}

73
cmd/server/server.go Normal file
View File

@ -0,0 +1,73 @@
package server
import (
"git.sense-t.eu.org/ACE/ace/cmd/config"
"git.sense-t.eu.org/ACE/ace/servers/qemuserver"
"git.sense-t.eu.org/ACE/ace/servers/webserver"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
type Server struct {
options *config.Config
}
var Command *cli.Command
func init() {
Command = &cli.Command{
Name: "server",
Usage: "run ace server",
Action: runServer,
}
}
func NewServer(c *config.Config) (*Server, error) {
return &Server{
options: c,
}, nil
}
func (s *Server) Run() error {
if s.options.Debug {
gin.SetMode(gin.DebugMode)
logrus.SetLevel(logrus.DebugLevel)
} else {
logrus.SetLevel(logrus.InfoLevel)
gin.SetMode(gin.ReleaseMode)
}
qemu, err := qemuserver.NewServer(s.options.Qemu)
if err != nil {
return err
}
webServer, err := webserver.NewServer(s.options.WEBServer)
if err != nil {
return err
}
webServer.RTCConnector.QEMU = qemu
go func() {
if err := qemu.Run(); err != nil {
logrus.Fatal("cannot run qemuserver with error: ", err)
}
}()
logrus.Debug("qemu server running")
return webServer.Run()
}
func runServer(c *cli.Context) error {
config, err := config.ReadConfig(c)
if err != nil {
return err
}
server, err := NewServer(config)
if err != nil {
return err
}
return server.Run()
}

93
drivers/audio/wavfifo.go Normal file
View File

@ -0,0 +1,93 @@
package audio
import (
"context"
"encoding/binary"
"io"
"time"
"github.com/pion/mediadevices/pkg/io/audio"
"github.com/pion/mediadevices/pkg/prop"
"github.com/pion/mediadevices/pkg/wave"
"github.com/sirupsen/logrus"
)
const (
DataChunkIDSize = 4
DataChunkSizeSize = 4
)
// BufferSize for pcm bytes
const BitsPerByte = 8
type PCMStreamDriver struct {
PCM <-chan []byte
BufferSizeByBytes uint16
WaveHeader *WavHeader
closed <-chan struct{}
cancel func()
}
func (w *PCMStreamDriver) Open() error {
ctx, cancel := context.WithCancel(context.Background())
w.closed = ctx.Done()
w.cancel = cancel
return nil
}
func (w *PCMStreamDriver) Close() error {
w.cancel()
return nil
}
func (w *PCMStreamDriver) Properties() []prop.Media {
logrus.Debugf("wave header: %v", w.WaveHeader)
return []prop.Media{
{
Audio: prop.Audio{
SampleRate: int(w.WaveHeader.SampleRate),
ChannelCount: int(w.WaveHeader.NumChannels),
SampleSize: int(w.WaveHeader.BitsPerSample),
Latency: w.WaveHeader.GetLatnecy(w.BufferSizeByBytes),
IsFloat: false, // just 8bit or 16bit with qemu
IsBigEndian: false, // qemu should be little endian
IsInterleaved: true,
},
},
}
}
func (w *PCMStreamDriver) AudioRecord(p prop.Media) (audio.Reader, error) {
logrus.Debug(p)
chunkInfo := wave.ChunkInfo{
Len: int(w.BufferSizeByBytes) / int(p.SampleSize/BitsPerByte),
Channels: p.ChannelCount,
SamplingRate: p.SampleRate,
}
reader := func() (wave.Audio, func(), error) {
a := wave.NewInt16Interleaved(chunkInfo)
ticker := time.NewTicker(p.Latency)
defer ticker.Stop()
select {
case <-w.closed:
return nil, func() {}, io.EOF
case pcmData := <-w.PCM:
copy(a.Data, bytesTo16BitSamples(pcmData[:]))
case <-ticker.C:
// no stuck
}
return a, func() {}, nil
}
return audio.ReaderFunc(reader), nil
}
func bytesTo16BitSamples(b []byte) []int16 {
samples := make([]int16, 0)
for i := 0; i < len(b); i += 2 {
sample := binary.LittleEndian.Uint16(b[i : i+2])
samples = append(samples, int16(sample))
}
return samples
}

124
drivers/audio/wavheader.go Normal file
View File

@ -0,0 +1,124 @@
package audio
import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"io"
"time"
)
// Skip riff header and `fmt ` just 16 bytes
const (
FmtHeaderOffset = 0x0c // 12
FmtHeaderIDSize = 4
FmtHeaderChunkSizeSize = 4
FmtHeaderSizeDefault = 16
)
type WavHeader struct {
ID [4]byte
Size uint32
AudioFormat uint16
NumChannels uint16
SampleRate uint32
ByteRate uint32
BlockAlign uint16
BitsPerSample uint16
}
func NewHeader(f io.Reader) (*WavHeader, error) {
w := &WavHeader{}
if err := w.Parse(f); err != nil {
return nil, err
}
return w, nil
}
func DefaultHeader() *WavHeader {
return &WavHeader{
Size: uint32(FmtHeaderSizeDefault),
AudioFormat: 1,
NumChannels: 2,
SampleRate: 48000, // opus only support 48kHz
BlockAlign: 4,
BitsPerSample: 16,
}
}
func (w *WavHeader) Parse(f io.Reader) error {
// skip headers
var _headers [FmtHeaderOffset]byte
if _, err := f.Read(_headers[:]); err != nil {
return err
}
var id [4]byte
if _, err := f.Read(id[:]); err != nil {
return err
}
if bytes.Equal(id[:], []byte("fmt ")) {
return errors.New("bad header")
}
w.ID = id
var size [4]byte
if _, err := f.Read(size[:]); err != nil {
return err
}
w.Size = binary.LittleEndian.Uint32(size[:])
var af [2]byte
if _, err := f.Read(af[:]); err != nil {
return err
}
w.AudioFormat = binary.LittleEndian.Uint16(af[:])
var nc [2]byte
if _, err := f.Read(nc[:]); err != nil {
return err
}
w.NumChannels = binary.LittleEndian.Uint16(nc[:])
var sr [4]byte
if _, err := f.Read(sr[:]); err != nil {
return err
}
w.SampleRate = binary.LittleEndian.Uint32(sr[:])
var br [4]byte
if _, err := f.Read(br[:]); err != nil {
return err
}
w.ByteRate = binary.LittleEndian.Uint32(br[:])
var ba [2]byte
if _, err := f.Read(ba[:]); err != nil {
return err
}
w.BlockAlign = binary.LittleEndian.Uint16(ba[:])
var bps [2]byte
if _, err := f.Read(bps[:]); err != nil {
return err
}
w.BitsPerSample = binary.LittleEndian.Uint16(bps[:])
return nil
}
func (w *WavHeader) String() string {
b, _ := json.Marshal(w)
return string(b)
}
func (w *WavHeader) GetLatnecy(bufferSizeByBytes uint16) time.Duration {
bytesPerSample := w.BitsPerSample / BitsPerByte
bufferLength := bufferSizeByBytes / bytesPerSample
return time.Second *
time.Duration(bufferLength) /
time.Duration(w.NumChannels) /
time.Duration(w.SampleRate)
}

102
drivers/video/ppm.go Normal file
View File

@ -0,0 +1,102 @@
package video
import (
"context"
"image"
"image/draw"
"io"
"time"
"github.com/pion/mediadevices/pkg/frame"
"github.com/pion/mediadevices/pkg/io/video"
"github.com/pion/mediadevices/pkg/prop"
"github.com/sirupsen/logrus"
_ "github.com/jbuchbinder/gopnm"
)
const DefaultFPS float32 = 60.0
type Frame struct {
Time time.Time
Image io.ReadCloser
}
type PPMStreamDriver struct {
Height, Width int
FPS float32
PPMImage <-chan Frame
closed <-chan struct{}
cancel func()
}
func (v *PPMStreamDriver) Open() error {
defer logrus.Debug("device opened")
ctx, cancel := context.WithCancel(context.Background())
v.closed = ctx.Done()
v.cancel = cancel
return nil
}
func (v *PPMStreamDriver) Close() error {
v.cancel()
return nil
}
func (v *PPMStreamDriver) Properties() []prop.Media {
return []prop.Media{
{
Video: prop.Video{
Width: v.Width,
Height: v.Height,
FrameRate: v.FPS,
FrameFormat: frame.FormatYUYV,
},
},
}
}
func (v *PPMStreamDriver) VideoRecord(p prop.Media) (video.Reader, error) {
logrus.Debug(p)
canvas := image.NewRGBA(image.Rect(0, 0, p.Width, p.Height))
var (
prevHeight, prevWidth int
)
r := video.ReaderFunc(func() (img image.Image, release func(), err error) {
select {
case <-v.closed:
return nil, func() {}, io.EOF
case ppmF := <-v.PPMImage:
defer ppmF.Image.Close()
// skip timeouted frame
if time.Since(ppmF.Time) > time.Second/time.Duration(p.FrameRate) {
return canvas, func() {}, nil
}
img, _, err := image.Decode(ppmF.Image)
if err != nil {
return nil, func() {}, err
}
// screen geometroy change
if img.Bounds().Dx() != prevWidth || img.Bounds().Dy() != prevHeight {
draw.Draw(canvas, canvas.Rect, image.Black, image.Black.Bounds().Min, draw.Over)
prevWidth = img.Bounds().Dx()
prevHeight = img.Bounds().Dy()
}
offsetX := (canvas.Rect.Dx() - img.Bounds().Dx()) / 2
offsetY := (canvas.Rect.Dy() - img.Bounds().Dy()) / 2
draw.Draw(canvas, image.Rect(
offsetX, offsetY, offsetX+img.Bounds().Dx(), offsetY+img.Bounds().Dy(),
), img, img.Bounds().Min, draw.Over)
default:
}
return canvas, func() {}, nil
})
return r, nil
}

61
go.mod Normal file
View File

@ -0,0 +1,61 @@
module git.sense-t.eu.org/ACE/ace
go 1.19
require (
github.com/jbuchbinder/gopnm v0.0.0-20220507095634-e31f54490ce0
github.com/pion/mediadevices v0.3.11
github.com/urfave/cli/v2 v2.11.2
)
require (
github.com/digitalocean/go-libvirt v0.0.0-20220804181439-8648fbde413e // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.10.0 // indirect
github.com/goccy/go-json v0.9.7 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
github.com/pion/datachannel v1.5.2 // indirect
github.com/pion/dtls/v2 v2.1.5 // indirect
github.com/pion/ice/v2 v2.2.6 // indirect
github.com/pion/interceptor v0.1.12 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns v0.0.5 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.10 // indirect
github.com/pion/rtp v1.7.13 // indirect
github.com/pion/sctp v1.8.2 // indirect
github.com/pion/sdp/v3 v3.0.5 // indirect
github.com/pion/srtp/v2 v2.0.10 // indirect
github.com/pion/stun v0.3.5 // indirect
github.com/pion/transport v0.13.1 // indirect
github.com/pion/turn/v2 v2.0.8 // indirect
github.com/pion/udp v0.1.1 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 // indirect
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect
golang.org/x/net v0.0.0-20220630215102-69896b714898 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/digitalocean/go-qemu v0.0.0-20220804221245-2002801203aa
github.com/gin-gonic/gin v1.8.1
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/pion/webrtc/v3 v3.1.43
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sirupsen/logrus v1.9.0
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gopkg.in/yaml.v3 v3.0.1
)

256
go.sum Normal file
View File

@ -0,0 +1,256 @@
github.com/blackjack/webcam v0.0.0-20220329180758-ba064708e165/go.mod h1:G0X+rEqYPWSq0dG8OMf8M446MtKytzpPjgS3HbdOJZ4=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/digitalocean/go-libvirt v0.0.0-20220804181439-8648fbde413e h1:SCnqm8SjSa0QqRxXbo5YY//S+OryeJioe17nK+iDZpg=
github.com/digitalocean/go-libvirt v0.0.0-20220804181439-8648fbde413e/go.mod h1:o129ljs6alsIQTc8d6eweihqpmmrbxZ2g1jhgjhPykI=
github.com/digitalocean/go-qemu v0.0.0-20220804221245-2002801203aa h1:MiYy1HQgJcyj9OZpsrJThjUTN15mId070zUZTfHqCnY=
github.com/digitalocean/go-qemu v0.0.0-20220804221245-2002801203aa/go.mod h1:GUMRsCBc7W7BzUF0zizFJ7Pna929325F+v9hvnWD+pI=
github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gen2brain/malgo v0.10.35/go.mod h1:zHSUNZAXfCeNsZou0RtQ6Zk7gDYLIcKOrUWtAdksnEs=
github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jbuchbinder/gopnm v0.0.0-20220507095634-e31f54490ce0 h1:9GwwkVzUn1vRWAQ8GRu7UOaoM+FZGnvw88DsjyiqfXc=
github.com/jbuchbinder/gopnm v0.0.0-20220507095634-e31f54490ce0/go.mod h1:6U0E76+sB1jTuSSXJjePtLd44vExeoYThOWgOoXo3x8=
github.com/jezek/xgb v0.0.0-20210312150743-0e0f116e1240/go.mod h1:3P4UH/k22rXyHIJD2w4h2XMqPX4Of/eySEZq9L6wqc4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kbinani/screenshot v0.0.0-20210720154843-7d3a670d8329/go.mod h1:2VPVQDR4wO7KXHwP+DAypEy67rXf+okUx2zjgpCxZw4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pion/datachannel v1.5.2 h1:piB93s8LGmbECrpO84DnkIVWasRMk3IimbcXkTQLE6E=
github.com/pion/datachannel v1.5.2/go.mod h1:FTGQWaHrdCwIJ1rw6xBIfZVkslikjShim5yr05XFuCQ=
github.com/pion/dtls/v2 v2.1.3/go.mod h1:o6+WvyLDAlXF7YiPB/RlskRoeK+/JtuaZa5emwQcWus=
github.com/pion/dtls/v2 v2.1.5 h1:jlh2vtIyUBShchoTDqpCCqiYCyRFJ/lvf/gQ8TALs+c=
github.com/pion/dtls/v2 v2.1.5/go.mod h1:BqCE7xPZbPSubGasRoDFJeTsyJtdD1FanJYL0JGheqY=
github.com/pion/ice/v2 v2.2.6 h1:R/vaLlI1J2gCx141L5PEwtuGAGcyS6e7E0hDeJFq5Ig=
github.com/pion/ice/v2 v2.2.6/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE=
github.com/pion/interceptor v0.1.11/go.mod h1:tbtKjZY14awXd7Bq0mmWvgtHB5MDaRN7HV3OZ/uy7s8=
github.com/pion/interceptor v0.1.12 h1:CslaNriCFUItiXS5o+hh5lpL0t0ytQkFnUcbbCs2Zq8=
github.com/pion/interceptor v0.1.12/go.mod h1:bDtgAD9dRkBZpWHGKaoKb42FhDHTG2rX8Ii9LRALLVA=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns v0.0.5 h1:Q2oj/JB3NqfzY9xGZ1fPzZzK7sDSD8rZPOvcIQ10BCw=
github.com/pion/mdns v0.0.5/go.mod h1:UgssrvdD3mxpi8tMxAXbsppL3vJ4Jipw1mTCW+al01g=
github.com/pion/mediadevices v0.3.11 h1:1orX6LMr+byAMRiulzXEGsodHYtHNvcv13U2VAeJI0o=
github.com/pion/mediadevices v0.3.11/go.mod h1:sKLty4bEcD45Q+cp4tbIGflx4veFrbMHuIg0+5HWWe4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo=
github.com/pion/rtcp v1.2.10 h1:nkr3uj+8Sp97zyItdN60tE/S6vk4al5CPRR6Gejsdjc=
github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
github.com/pion/sctp v1.8.0/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s=
github.com/pion/sctp v1.8.2 h1:yBBCIrUMJ4yFICL3RIvR4eh/H2BTTvlligmSTy+3kiA=
github.com/pion/sctp v1.8.2/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s=
github.com/pion/sdp/v3 v3.0.5 h1:ouvI7IgGl+V4CrqskVtr3AaTrPvPisEOxwgpdktctkU=
github.com/pion/sdp/v3 v3.0.5/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
github.com/pion/srtp/v2 v2.0.10 h1:b8ZvEuI+mrL8hbr/f1YiJFB34UMrOac3R3N1yq2UN0w=
github.com/pion/srtp/v2 v2.0.10/go.mod h1:XEeSWaK9PfuMs7zxXyiN252AHPbH12NX5q/CFDWtUuA=
github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg=
github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA=
github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q=
github.com/pion/transport v0.12.3/go.mod h1:OViWW9SP2peE/HbwBvARicmAVnesphkNkCVZIWJ6q9A=
github.com/pion/transport v0.13.0/go.mod h1:yxm9uXpK9bpBBWkITk13cLo1y5/ur5VQpG22ny6EP7g=
github.com/pion/transport v0.13.1 h1:/UH5yLeQtwm2VZIPjxwnNFxjS4DFhyLfS4GlfuKUzfA=
github.com/pion/transport v0.13.1/go.mod h1:EBxbqzyv+ZrmDb82XswEE0BjfQFtuw1Nu6sjnjWCsGg=
github.com/pion/turn/v2 v2.0.8 h1:KEstL92OUN3k5k8qxsXHpr7WWfrdp7iJZHx99ud8muw=
github.com/pion/turn/v2 v2.0.8/go.mod h1:+y7xl719J8bAEVpSXBXvTxStjJv3hbz9YFflvkpcGPw=
github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o=
github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M=
github.com/pion/webrtc/v3 v3.1.43 h1:YT3ZTO94UT4kSBvZnRAH82+0jJPUruiKr9CEstdlQzk=
github.com/pion/webrtc/v3 v3.1.43/go.mod h1:G/J8k0+grVsjC/rjCZ24AKoCCxcFFODgh7zThNZGs0M=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/urfave/cli/v2 v2.11.2 h1:FVfNg4m3vbjbBpLYxW//WjxUoHvJ9TlppXcqY9Q9ZfA=
github.com/urfave/cli/v2 v2.11.2/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 h1:y+mHpWoQJNAHt26Nhh6JP7hvM71IRZureyvZhoVALIs=
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220630215102-69896b714898 h1:K7wO6V1IrczY9QOQ2WkVpw4JQSwCd52UsxVEirZUfiw=
golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,86 @@
package qemuconnection
import (
"encoding/json"
"fmt"
"github.com/digitalocean/go-qemu/qmp"
)
const (
MouseMoveEvent = iota
MouseButtonEvent
KeyboardEvent
ControlEvent
QueryStatusEvent
)
const (
ControlEventRestart = iota
)
type Event struct {
Type int `json:"type"`
Args map[string]any `json:"args"`
}
type CommandLine struct {
Command string `json:"command-line"`
}
func ParseEvent(b []byte) (*Event, error) {
event := &Event{}
if err := json.Unmarshal(b, event); err != nil {
return nil, err
}
return event, nil
}
func (e *Event) ToQemuCommand() []qmp.Command {
switch e.Type {
case MouseMoveEvent:
return []qmp.Command{
makeHMCommand("mouse_move %d %d %d", e.Args["dx"], e.Args["dy"], e.Args["dz"]),
}
case MouseButtonEvent:
return []qmp.Command{
makeHMCommand("mouse_button %d", e.Args["button"]),
}
case KeyboardEvent:
return []qmp.Command{
makeHMCommand("sendkey %s", e.Args["key"]),
}
case ControlEvent:
t, ok := e.Args["cmd"].(int)
if ok {
return makeControlCommand(t)
}
case QueryStatusEvent:
return []qmp.Command{
{
Execute: "query-status",
},
}
}
return make([]qmp.Command, 0)
}
func makeControlCommand(t int) []qmp.Command {
switch t {
case ControlEventRestart:
return []qmp.Command{
makeHMCommand("system_reset"),
makeHMCommand("cont"),
}
}
return make([]qmp.Command, 0)
}
func makeHMCommand(cmdTemplate string, args ...any) qmp.Command {
return qmp.Command{
Execute: "human-monitor-command",
Args: CommandLine{
Command: fmt.Sprintf(cmdTemplate, args...),
},
}
}

View File

@ -0,0 +1,134 @@
package webrtcconnection
import (
"git.sense-t.eu.org/ACE/ace/servers/qemuserver"
"github.com/pion/mediadevices"
"github.com/pion/mediadevices/pkg/codec/opus"
"github.com/pion/mediadevices/pkg/codec/x264"
"github.com/pion/mediadevices/pkg/driver"
"github.com/pion/webrtc/v3"
"github.com/sirupsen/logrus"
)
const DefaultStreamID = "ace-server"
type Connection struct {
option *Options
api *webrtc.API
stream mediadevices.MediaStream
QEMU *qemuserver.Server
}
func New(o *Options) (*Connection, error) {
connection := &Connection{
option: o,
}
codecSelector, err := setupCodec(o.VideoBPS)
if err != nil {
return nil, err
}
me := &webrtc.MediaEngine{}
codecSelector.Populate(me)
connection.api = webrtc.NewAPI(webrtc.WithMediaEngine(me))
logrus.Debug("list devices:")
devices := driver.GetManager().Query(func(d driver.Driver) bool { return true })
for _, device := range devices {
logrus.Debug("\t", device.ID())
logrus.Debug("\t", device.Info())
logrus.Debug("\t", device.Properties())
logrus.Debug("\t", device.Status())
logrus.Debug("\t------")
}
s, err := mediadevices.GetUserMedia(mediadevices.MediaStreamConstraints{
Video: func(mtc *mediadevices.MediaTrackConstraints) {},
Audio: func(mtc *mediadevices.MediaTrackConstraints) {},
Codec: codecSelector,
})
if err != nil {
return nil, err
}
connection.stream = s
return connection, nil
}
func (c *Connection) Regist(offer *webrtc.SessionDescription) (*webrtc.SessionDescription, error) {
logrus.Debug("received offer ")
rtc, err := c.api.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: c.option.STUNServers,
},
},
})
if err != nil {
return nil, err
}
rtc.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
logrus.Debug("connection state has changed: ", connectionState.String())
})
rtc.OnICECandidate(func(i *webrtc.ICECandidate) {
logrus.Debug("cadidate: ", i)
})
for _, track := range c.stream.GetTracks() {
track.OnEnded(func(err error) {
logrus.Errorf("Track (ID: %s, kind: %s) ended with error: %v", track.ID(), track.Kind().String(), err)
})
_, err := rtc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendonly,
})
if err != nil {
logrus.Error(err)
}
}
rtc.OnDataChannel(c.dataChannel)
if err := rtc.SetRemoteDescription(*offer); err != nil {
return nil, err
}
logrus.Debug("offer set")
answer, err := rtc.CreateAnswer(nil)
if err != nil {
return nil, err
}
if err := rtc.SetLocalDescription(answer); err != nil {
return nil, err
}
logrus.Debug("answer set")
<-webrtc.GatheringCompletePromise(rtc)
defer logrus.Debug("regist complete")
return rtc.LocalDescription(), nil
}
func setupCodec(videoBPS int) (*mediadevices.CodecSelector, error) {
x264Prarm, err := x264.NewParams()
if err != nil {
return nil, err
}
x264Prarm.BitRate = videoBPS
x264Prarm.Preset = x264.PresetFaster
opusParam, err := opus.NewParams()
if err != nil {
return nil, err
}
codecSelector := mediadevices.NewCodecSelector(
mediadevices.WithAudioEncoders(&opusParam),
mediadevices.WithVideoEncoders(&x264Prarm),
)
return codecSelector, nil
}

View File

@ -0,0 +1,49 @@
package webrtcconnection
import (
"encoding/json"
"time"
"github.com/pion/webrtc/v3"
"github.com/sirupsen/logrus"
)
func (c *Connection) dataChannel(d *webrtc.DataChannel) {
d.OnOpen(func() {
for {
status := c.QEMU.GetStatus().String()
currentTime := time.Now().UnixMilli()
b, err := json.Marshal(map[string]any{
"qemu_status": status,
"server_time": currentTime,
})
if err != nil {
logrus.Errorf(
"failed to parse to json on '%s-%d' with error: %v",
d.Label(), *d.ID(), err,
)
}
if err := d.Send(b); err != nil {
logrus.Errorf(
"failed to send qemu status to '%s-%d' with error: %v",
d.Label(), *d.ID(), err,
)
}
time.Sleep(time.Second)
}
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {
logrus.Debugf("received %d bytes message from '%s-%d'", len(msg.Data), d.Label(), *d.ID())
if !msg.IsString {
return
}
if err := c.QEMU.SendEvent(msg.Data); err != nil {
logrus.Errorf(
"cannot parse message from '%s-%d' to qemu controll event: %v",
d.Label(), *d.ID(), err,
)
}
})
}

View File

@ -0,0 +1,17 @@
package webrtcconnection
type Options struct {
STUNServers []string `yaml:"stun_servers"`
VideoBPS int `yaml:"video_bps"`
}
func ExampleOptions() *Options {
options := &Options{
STUNServers: []string{
"stun:stun.l.google.com:19302",
"stun:wetofu.me:3478",
},
VideoBPS: 2_048_000,
}
return options
}

38
main.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"os"
"git.sense-t.eu.org/ACE/ace/cmd/config"
"git.sense-t.eu.org/ACE/ace/cmd/server"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
func init() {
logrus.SetReportCaller(true)
}
func main() {
app := &cli.App{
Name: "ace",
Usage: "cloud emulator",
Commands: []*cli.Command{
config.Command,
server.Command,
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config",
Usage: "config file",
Aliases: []string{"c"},
EnvVars: []string{"ACE_CONFIG"},
Value: "config.yaml",
},
},
}
if err := app.Run(os.Args); err != nil {
logrus.Panic(err)
}
}

View File

@ -0,0 +1,58 @@
package qemuserver
import (
"fmt"
"net/url"
"os"
"path"
"syscall"
"time"
)
type Options struct {
QmpAddress string `yaml:"address"`
Timeout time.Duration `yaml:"timeout"`
Name string `yaml:"name"`
Audio AudioOptions `yaml:"audio_options"`
Video VideoOptions `yaml:"video_options"`
}
type AudioOptions struct {
Device string `yaml:"device"`
BufferSize uint16 `yaml:"buffer_size"` // in bytes
}
type VideoOptions struct {
Height int `yaml:"height"`
Width int `yaml:"width"`
FPS float32 `yaml:"fps"`
}
func ExampleOptions() *Options {
return &Options{
QmpAddress: (&url.URL{
Scheme: "tcp",
Host: "localhost:4444",
}).String(),
Timeout: time.Duration(30 * time.Second),
Name: "ace-qemu",
Audio: AudioOptions{
Device: "snd0",
BufferSize: 2048,
},
Video: VideoOptions{
Height: 768,
Width: 1024,
FPS: 30,
},
}
}
func (o *Options) MakeFIFO() (string, error) {
path := path.Join(
os.TempDir(),
fmt.Sprintf("%s-%s-audio", o.Name, o.Audio.Device),
)
os.Remove(path)
return path, syscall.Mkfifo(path, 0600)
}

View File

@ -0,0 +1,218 @@
package qemuserver
import (
"fmt"
"net/url"
"os"
"time"
"git.sense-t.eu.org/ACE/ace/drivers/audio"
"git.sense-t.eu.org/ACE/ace/drivers/video"
"git.sense-t.eu.org/ACE/ace/lib/qemuconnection"
"github.com/digitalocean/go-qemu/qemu"
"github.com/digitalocean/go-qemu/qmp"
"github.com/pion/mediadevices/pkg/driver"
"github.com/sirupsen/logrus"
)
const waveHeaderSize = audio.FmtHeaderSizeDefault
var waveHeader *audio.WavHeader
func init() {
waveHeader = audio.DefaultHeader()
}
type Server struct {
options *Options
qemu *qemu.Domain
audioHeader chan *audio.WavHeader
pcm chan []byte
ppm chan video.Frame
}
var DefaultServer *Server
func NewServer(o *Options) (*Server, error) {
server := &Server{
options: o,
audioHeader: make(chan *audio.WavHeader, 1),
pcm: make(chan []byte),
ppm: make(chan video.Frame), // to be configured
}
u, err := url.Parse(o.QmpAddress)
if err != nil {
return nil, err
}
var address string
if u.Scheme == "unix" {
address = u.Path
} else {
address = u.Host
}
logrus.Debugf("trying to connect qmp with %s://%s", u.Scheme, address)
qmpConnection, err := qmp.NewSocketMonitor(u.Scheme, address, o.Timeout)
if err != nil {
return nil, err
}
if err := qmpConnection.Connect(); err != nil {
return nil, err
}
logrus.Debug("qmp connected")
qemu, err := qemu.NewDomain(qmpConnection, o.Name)
if err != nil {
return nil, err
}
server.qemu = qemu
audio := &audio.PCMStreamDriver{
PCM: server.pcm,
WaveHeader: waveHeader,
BufferSizeByBytes: o.Audio.BufferSize, // to be configured
}
if err := driver.GetManager().Register(
audio,
driver.Info{
Label: "audioFifo",
DeviceType: driver.Microphone,
Priority: driver.PriorityNormal,
},
); err != nil {
return nil, err
}
video := &video.PPMStreamDriver{
Height: o.Video.Height,
Width: o.Video.Width,
FPS: o.Video.FPS,
PPMImage: server.ppm,
}
if err := driver.GetManager().Register(
video,
driver.Info{
Label: "vnc",
DeviceType: driver.Camera,
Priority: driver.PriorityNormal,
},
); err != nil {
return nil, err
}
return server, nil
}
func (s *Server) Run() error {
logrus.Debug("qemu server running")
path, err := s.options.MakeFIFO()
if err != nil {
return err
}
go func() {
logrus.Debug("screen capture start")
defer close(s.ppm)
ticker := time.NewTicker(time.Second / time.Duration(s.options.Video.FPS))
defer ticker.Stop()
for { // to be configured
now := time.Now()
ppm, err := s.qemu.ScreenDump()
if err != nil {
logrus.Error(err)
continue
}
select {
case s.ppm <- video.Frame{
Time: now,
Image: ppm,
}:
<-ticker.C
case <-ticker.C:
}
}
}()
go func() {
f, err := os.Open(path)
if err != nil {
logrus.Fatal(err)
}
defer f.Close()
logrus.Debug("start reading fifo")
logrus.Debug("skip wave headers, to the PCM!")
// skip to pcm data, for 44 bytes.
var _dataChunkHeader [audio.FmtHeaderOffset +
audio.FmtHeaderIDSize + audio.FmtHeaderChunkSizeSize + waveHeaderSize +
audio.DataChunkIDSize + audio.DataChunkSizeSize]byte
if _, err := f.Read(_dataChunkHeader[:]); err != nil {
logrus.Fatal(err)
}
logrus.Debug("start reading PCM")
defer close(s.pcm)
ticker := time.NewTicker(waveHeader.GetLatnecy(s.options.Audio.BufferSize))
defer ticker.Stop()
for {
b := make([]byte, s.options.Audio.BufferSize) // to be configured
if _, err := f.Read(b[:]); err != nil {
logrus.Error(err)
}
select {
case s.pcm <- b:
<-ticker.C
case <-ticker.C:
}
}
}()
go func() {
logrus.Debug("setting audio capture")
if _, err := s.qemu.Run(qmp.Command{
Execute: "human-monitor-command",
Args: map[string]string{
"command-line": fmt.Sprintf(
"wavcapture %s %s %d %d %d",
path,
s.options.Audio.Device,
waveHeader.SampleRate,
waveHeader.BitsPerSample,
waveHeader.NumChannels,
),
},
}); err != nil {
logrus.Fatal("run audio command failed: ", err)
}
logrus.Debug("audio capture set")
}()
select {}
}
func (s *Server) SendEvent(b []byte) error {
ev, err := qemuconnection.ParseEvent(b)
if err != nil {
return err
}
for _, cmd := range ev.ToQemuCommand() {
_, err := s.qemu.Run(cmd)
if err != nil {
return err
}
}
return nil
}
func (s *Server) GetStatus() qemu.Status {
status, err := s.qemu.Status()
if err != nil {
return qemu.StatusIOError
}
return status
}

View File

@ -0,0 +1,70 @@
package webserver
import (
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/pion/webrtc/v3"
"github.com/sirupsen/logrus"
)
type Response struct {
Succeed bool `json:"succeed"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
func (s *Server) getInstruction(c *gin.Context) {
f, _ := os.ReadFile(s.options.InstructionFile)
c.JSON(http.StatusOK, Response{
Succeed: true,
Message: "",
Data: string(f),
})
}
func (s *Server) exchangeSDP(c *gin.Context) {
offer := &webrtc.SessionDescription{}
if err := c.BindJSON(offer); err != nil {
c.JSON(http.StatusBadRequest, Response{
Succeed: false,
Message: "bad request",
Data: err.Error(),
})
logrus.Error(err)
return
}
answer, err := s.RTCConnector.Regist(offer)
if err != nil {
c.JSON(http.StatusInternalServerError, Response{
Succeed: false,
Message: "internal error",
Data: err.Error(),
})
return
}
c.JSON(http.StatusOK, Response{
Succeed: true,
Message: "",
Data: answer,
})
}
func (s *Server) getICEConfig(c *gin.Context) {
c.JSON(http.StatusOK, Response{
Succeed: true,
Message: "",
Data: s.options.WebRTC.STUNServers,
})
}
func (s *Server) getName(c *gin.Context) {
c.JSON(http.StatusOK, Response{
Succeed: true,
Message: "",
Data: s.options.Name,
})
}

View File

@ -0,0 +1,21 @@
package webserver
import (
"git.sense-t.eu.org/ACE/ace/lib/webrtcconnection"
)
type Options struct {
Name string `yaml:"name"`
Listen string `yaml:"listen"`
InstructionFile string `yaml:"instruction_file"`
WebRTC *webrtcconnection.Options `yaml:"webrtc"`
}
func ExampleOptions() *Options {
return &Options{
Name: "ACE-test",
Listen: "localhost:8080",
InstructionFile: "",
WebRTC: webrtcconnection.ExampleOptions(),
}
}

View File

@ -0,0 +1,30 @@
package webserver
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
func (s *Server) setupRoute() {
apiHandler := gin.New()
groupV1 := apiHandler.Group("/api/v1")
{
groupV1.
POST("/sdp", s.exchangeSDP).
GET("/instruction", s.getInstruction).
GET("/iceserver/url", s.getICEConfig).
GET("/name", s.getName)
}
s.webServer.Use(func(ctx *gin.Context) {
path := ctx.Request.RequestURI
logrus.Debug(path)
if strings.HasPrefix(path, "/api") {
apiHandler.HandleContext(ctx)
} else {
staticFileHandler()(ctx)
}
})
}

View File

@ -0,0 +1,34 @@
package webserver
import (
"git.sense-t.eu.org/ACE/ace/lib/webrtcconnection"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
type Server struct {
options *Options
webServer *gin.Engine
RTCConnector *webrtcconnection.Connection
}
func NewServer(o *Options) (*Server, error) {
rtc, err := webrtcconnection.New(o.WebRTC)
if err != nil {
return nil, err
}
s := &Server{
options: o,
webServer: gin.New(),
RTCConnector: rtc,
}
return s, nil
}
func (s *Server) Run() error {
logrus.Debug("webserver running")
defer logrus.Debug("webserver exit")
s.setupRoute()
return s.webServer.Run(s.options.Listen)
}

View File

@ -0,0 +1,35 @@
package webserver
import (
"embed"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
//go:generate cp -r ../../web/dist ./
//go:embed dist
var staticFiles embed.FS
func staticFileHandler() gin.HandlerFunc {
sf, err := fs.Sub(staticFiles, "dist")
if err != nil {
logrus.Fatal("compile error: ", err)
}
fs := http.FileServer(http.FS(sf))
return func(ctx *gin.Context) {
defer ctx.Abort()
filename := strings.TrimLeft(ctx.Request.RequestURI, "/")
if filename == "" {
filename = "index.html"
}
fs.ServeHTTP(ctx.Writer, ctx.Request)
}
}

23
web/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
web/README.md Normal file
View File

@ -0,0 +1,24 @@
# web
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Lints and fixes files
```
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
web/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

22
web/jsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"vueCompilerOptions": {
"experimentalDisableTemplateSupport": true
}
}

48
web/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@vicons/ionicons5": "^0.12.0",
"core-js": "^3.8.3",
"marked": "^4.1.0",
"naive-ui": "^2.33.2",
"vue": "^3.2.13"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser",
"requireConfigFile": false
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
],
"_id": "web@0.1.0",
"readme": "ERROR: No README data found!"
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

17
web/public/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

27
web/src/App.vue Normal file
View File

@ -0,0 +1,27 @@
<template>
<n-message-provider placement="top-right">
<main-app />
</n-message-provider>
</template>
<script setup>
import MainApp from "./components/MainApp.vue";
import { NMessageProvider } from "naive-ui";
import { onMounted } from "vue";
import { store } from "./store";
const setTitle = async () => {
document.title = await store.loadTitle();
};
onMounted(() => {
setTitle();
});
</script>
<style>
body {
background-color: black;
}
</style>

16
web/src/ajax/ajax.js Normal file
View File

@ -0,0 +1,16 @@
export const ajax = async (url, options) => {
try {
const r = await fetch(url, options)
if (r.status >= 400) {
throw Error(`request error with status ${r.status}`)
}
return await r.json()
} catch (e) {
console.log(e)
return {
succeed: false,
message: e,
data: null
}
}
}

23
web/src/ajax/index.js Normal file
View File

@ -0,0 +1,23 @@
import { ajax } from './ajax'
export default {
getTitle: async () => {
const j = await ajax('api/v1/name')
return j.data
},
getInstruction: async () => {
const j = await ajax('api/v1/instruction')
return j.data
},
getICEUrl: async () => {
const j = await ajax('api/v1/iceserver/url')
return j.data
},
exchangeSDP: async offer => {
const answer = ajax('api/v1/sdp', {
method: "POST",
body: JSON.stringify(offer)
})
return answer
}
}

View File

@ -0,0 +1,188 @@
<template>
<div>
<canvas id="vnc" />
<div id="data">
<video id="video" muted autoplay controls="none" />
<audio id="audio" autoplay />
</div>
</div>
</template>
<script setup>
import { onMounted, defineProps } from "vue";
import { store } from "../store";
import ajax from "../ajax";
const props = defineProps(["methods"]);
const eventTypes = {
mouseMove: 0,
mouseButton: 1,
keyboard: 2,
control: 3,
};
const controlEventTypes = {
restart: 0,
};
const makeEvent = (evType, args) => ({
type: eventTypes[evType],
args: args,
});
let dataChannel;
// eslint-disable-next-line
const sendSpecialKey = (key) => {
console.log(key);
const specialKey = "ctrl-alt-" + key;
dataChannel.send(
JSON.stringify(
makeEvent("keyboard", {
key: specialKey,
})
)
);
};
// eslint-disable-next-line
const resetVM = () => {
dataChannel.send(
JSON.stringify(
makeEvent("control", {
cmd: controlEventTypes.restart,
})
)
);
};
onMounted(() => {
console.log(props.methods);
// eslint-disable-next-line
props.methods.sendSpecialKey = sendSpecialKey;
// eslint-disable-next-line
props.methods.resetVM = resetVM;
const video = document.querySelector("canvas#vnc");
video.oncontextmenu = () => false;
store.getICEServers().then((servers) => {
const pc = new RTCPeerConnection({
iceServers: [
{
urls: servers,
},
],
});
pc.oniceconnectionstatechange = () => console.log(pc.iceConnectionState);
pc.addTransceiver("video", {
direction: "recvonly",
});
pc.addTransceiver("audio", {
direction: "recvonly",
});
dataChannel = pc.createDataChannel("control");
dataChannel.onmessage = (e) => {
const enc = new TextDecoder("utf-8");
const buf = new Uint8Array(e.data);
const d = JSON.parse(enc.decode(buf));
store.delay = +new Date() - d.server_time;
store.qemuStatus = d.qemu_status;
};
pc.ontrack = (ev) => {
console.log(ev);
const el = document.querySelector(`${ev.track.kind}#${ev.track.kind}`);
el.srcObject = ev.streams[0];
el.autoplay = true;
el.controls = false;
el.oncontextmenu = () => false;
document.getElementById("data").appendChild(el);
if (ev.track.kind === "video") {
const ctx = video.getContext("2d");
setTimeout(() => {
ctx.drawImage(this.video, 0, 0);
}, 0);
}
};
dataChannel.onopen = () => {
video.onmousemove = (ev) => {
dataChannel.send(
JSON.stringify(
makeEvent("mouseMove", {
dx: ev.clientX,
dy: ev.clientY,
dz: 0,
})
)
);
};
video.onmousedown = (ev) => {
dataChannel.send(
JSON.stringify(
makeEvent("mouseButton", {
button: ev.button << 1,
})
)
);
};
//video.onmousewheel = (ev) => {};
window.onkeydown = (ev) => {
let key = ev.key;
if (ev.ctrlKey && ev.which !== 17) key = "ctrl-" + key;
if (ev.shiftKey && ev.which !== 16) key = "shift-" + key;
if (ev.altKey && ev.which !== 18) key = "alt-" + key;
if (ev.metaKey && ev.which !== 91 && ev.which !== 93)
key = "meta-" + key;
if (!ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey)
key = "0x" + ev.which.toString(16);
dataChannel.send(
JSON.stringify(
makeEvent("keyboard", {
key: key,
})
)
);
};
};
pc.createOffer().then((offer) => {
pc.setLocalDescription(offer);
ajax
.exchangeSDP(offer)
.then((answer) =>
pc.setRemoteDescription(new RTCSessionDescription(answer.data))
);
});
});
});
</script>
<style scoped>
/*
video#video {
vertical-align: middle;
top: 50%;
transform: translateY(-50%) translateX(-50%);
left: 50%;
position: absolute;
max-height: 100%;
max-width: 100%;
}
*/
canvas#vnc {
vertical-align: middle;
top: 50%;
transform: translateY(-50%) translateX(-50%);
left: 50%;
position: absolute;
max-height: 100%;
max-width: 100%;
}
</style>

View File

@ -0,0 +1,176 @@
<template>
<div @contextmenu="noContextMenu">
<n-watermark
cross
fullscreen
v-if="show"
:content="'ACE-' + title"
:font-size="16"
:line-height="16"
:width="384"
:height="384"
:x-offset="12"
:y-offset="60"
:rotate="-15"
></n-watermark>
<div @mouseenter="menu" id="showMenu"></div>
<div id="screenArea"><ace-screen :methods="screenController" /></div>
<!-- eslint-disable vue/no-v-model-argument -->
<n-drawer :width="480" v-model:show="show" :native-scrollbar="true">
<n-drawer-content :title="title">
<n-collapse
:default-expanded-names="['controlButtons', 'instructions']"
>
<n-collapse-item title="控制按钮" name="controlButtons">
<n-grid :cols="1" :y-gap="8">
<n-grid-item>
<p>Ctrl+Alt+?</p>
<n-button-group>
<n-button type="error" strong @click="sendCtrlAltDelete">
Delete
</n-button>
<n-button strong @click="sendCtrlAltF1">F1</n-button>
<n-button strong @click="sendCtrlAltF2">F2</n-button>
<n-button strong @click="sendCtrlAltF3">F3</n-button>
<n-button strong @click="sendCtrlAltF4">F4</n-button>
<n-button strong @click="sendCtrlAltF5">F5</n-button>
<n-button strong @click="sendCtrlAltF6">F6</n-button>
<n-button strong @click="sendCtrlAltF7">F7</n-button>
</n-button-group>
</n-grid-item>
<n-grid-item>
<n-button type="error" strong @click="resetVM">
重置
<template #icon>
<n-icon>
<reload-icon />
</n-icon>
</template>
</n-button>
</n-grid-item>
</n-grid>
</n-collapse-item>
<n-collapse-item title="介绍" name="instructions">
<n-card>
<n-skeleton v-if="loading" text :repeat="10" />
<n-skeleton v-if="loading" text style="width: 60%" />
<div v-else v-html="renderMarkdown"></div>
</n-card>
</n-collapse-item>
<n-collapse-item title="统计信息" name="stat">
<n-card>
<p>当前延迟{{ store.delay }}ms</p>
<p>云电脑状态{{ store.qemuStatus }}</p>
</n-card>
</n-collapse-item>
</n-collapse>
</n-drawer-content>
</n-drawer>
</div>
</template>
<script setup>
import {
NDrawer,
NDrawerContent,
NWatermark,
NCollapse,
NCollapseItem,
NButtonGroup,
NButton,
NIcon,
NGridItem,
NGrid,
//NSpace,
useMessage,
NCard,
NSkeleton,
} from "naive-ui";
import { Reload as ReloadIcon } from "@vicons/ionicons5";
import { ref, onMounted, computed } from "vue";
import AceScreen from "./AceScreen.vue";
import { store } from "../store";
import { marked } from "marked";
const show = ref(false);
const title = ref("test123");
const loading = ref(true);
const menu = () => {
show.value = true;
};
const noContextMenu = () => false;
const message = useMessage();
const instruction = ref("");
const renderMarkdown = computed(() => {
return marked.parse(
instruction.value ? instruction.value : "# To be continued..."
);
});
const screenController = {};
const sendCtrlAltDelete = () => {
sendSpecialKey("del");
};
const sendCtrlAltF1 = () => {
sendSpecialKey("f1");
};
const sendCtrlAltF2 = () => {
sendSpecialKey("f2");
};
const sendCtrlAltF3 = () => {
sendSpecialKey("f3");
};
const sendCtrlAltF4 = () => {
sendSpecialKey("f4");
};
const sendCtrlAltF5 = () => {
sendSpecialKey("f5");
};
const sendCtrlAltF6 = () => {
sendSpecialKey("f6");
};
const sendCtrlAltF7 = () => {
sendSpecialKey("f7");
};
const sendSpecialKey = (key) => {
screenController.sendSpecialKey(key);
};
const resetVM = () => {
screenController.resetVM();
};
onMounted(() => {
message.info("鼠标移动到屏幕右侧可打开控制面板");
store.getTitle().then((t) => (title.value = t));
store.loadInstruction().then((i) => {
loading.value = false;
instruction.value = i;
});
});
</script>
<style scoped>
div#showMenu {
float: right;
color: white;
height: 100vh;
width: 1px;
z-index: 100;
}
div#screenArea {
height: 100vh;
width: 100vw;
zoom: 1;
}
</style>

4
web/src/main.js Normal file
View File

@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

33
web/src/store/index.js Normal file
View File

@ -0,0 +1,33 @@
import { reactive } from 'vue'
import ajax from '../ajax'
export const store = reactive({
title: '',
iceServers: [],
instruction: "",
delay: 0,
qemuStatus: "",
async loadTitle() {
this.title = await ajax.getTitle()
return this.title
},
async loadICEServers() {
this.iceServers = await ajax.getICEUrl()
return this.iceServers
},
async loadInstruction() {
this.instruction = await ajax.getInstruction()
return this.instruction
},
async getTitle() {
return this.title
},
async getICEServers() {
return this.iceServers
},
async getInstruction() {
return this.instruction
}
})

4
web/vue.config.js Normal file
View File

@ -0,0 +1,4 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true
})

6243
web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff