diff --git a/configs/config.yaml b/configs/config.yaml index 5612a4fe..9617e6ab 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -203,6 +203,33 @@ encoder: # (experimental) withoutGame: false +# game recording +# (experimental) +# recording allows export RAW a/v streams of games +# by default, it will export audio as WAV files, +# video as a list of PNG-encoded images, and +# one additional FFMPEG concat demux file +recording: + enabled: false + # image compression level: + # 0 - default compression + # -1 - no compression + # -2 - best speed + # -3 - best compression + compressLevel: 0 + # name contains the name of the recording dir (or zip) + # format: + # %date:go_time_format% -- refer: https://go.dev/src/time/format.go + # %user% -- user name who started the recording + # %game% -- game name (game ROM name) + # %rand:len% -- a random string of given length + # as example: 20210101101010_yeE_user1_badApple + name: "%date:20060102150405%_%rand:3%_%user%_%game%" + # zip and remove recording dir on completion + zip: true + # save directory + folder: ./recording + storage: # cloud storage provider: # - empty (No op storage stub) diff --git a/go.mod b/go.mod index 12825cb4..a03b23cc 100644 --- a/go.mod +++ b/go.mod @@ -7,35 +7,35 @@ require ( github.com/cavaliercoder/grab v1.0.1-0.20201108051000-98a5bfe305ec github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect - github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect - github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect - github.com/envoyproxy/go-control-plane v0.10.0 // indirect + github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect + github.com/envoyproxy/go-control-plane v0.10.1 // indirect github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3 github.com/fsnotify/fsnotify v1.5.1 github.com/go-gl/gl v0.0.0-20211025173605-bda47ffaa784 github.com/gofrs/flock v0.8.1 - github.com/gofrs/uuid v4.1.0+incompatible + github.com/gofrs/uuid v4.2.0+incompatible github.com/golang/glog v1.0.0 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/gorilla/websocket v1.4.2 + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 github.com/kkyr/fig v0.3.0 - github.com/mitchellh/mapstructure v1.4.2 // indirect + github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/pelletier/go-toml v1.9.4 // indirect - github.com/pion/interceptor v0.1.0 - github.com/pion/rtp v1.7.4 - github.com/pion/webrtc/v3 v3.1.7 + github.com/pion/interceptor v0.1.2 + github.com/pion/webrtc/v3 v3.1.11 github.com/prometheus/client_golang v1.11.0 github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/procfs v0.7.3 // indirect github.com/spf13/pflag v1.0.5 github.com/veandco/go-sdl2 v0.4.10 - golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 + golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 golang.org/x/image v0.0.0-20211028202545-6944b10bf410 - golang.org/x/net v0.0.0-20211101193420-4a448f8816b3 // indirect - golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 // indirect - golang.org/x/sys v0.0.0-20211102061401-a2f17f7b995c // indirect + golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c // indirect + golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect + golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 // indirect google.golang.org/api v0.60.0 // indirect - google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145 // indirect - google.golang.org/grpc v1.41.0 // indirect + google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12 // indirect + google.golang.org/grpc v1.42.0 // indirect ) diff --git a/go.sum b/go.sum index 8b5ee9c8..8dd9ba4a 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,9 @@ github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 h1:KwaoQzs/WeUxxJqiJsZ4euOly1Az/IgZXXSxlD/UBNk= +github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= 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= @@ -95,8 +96,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/go-control-plane v0.10.0 h1:WVt4HEPbdRbRD/PKKPbPnIVavO6gk/h673jWyIJ016k= -github.com/envoyproxy/go-control-plane v0.10.0/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= +github.com/envoyproxy/go-control-plane v0.10.1 h1:cgDRLG7bs59Zd+apAWuzLQL95obVYAymNJek76W3mgw= +github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE= github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= @@ -122,8 +123,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= -github.com/gofrs/uuid v4.1.0+incompatible h1:sIa2eCvUTwgjbqXrPLfNwUf9S3i3mpH1O1atV+iL/Wk= -github.com/gofrs/uuid v4.1.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ= @@ -210,6 +211,11 @@ github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0 github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -240,8 +246,8 @@ github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc8 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo= -github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -259,15 +265,15 @@ github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQ github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pion/datachannel v1.5.0 h1:Jy6xWr9hysxet69qP23ibiJ6M0P30ZRnndHU+N6cpkY= -github.com/pion/datachannel v1.5.0/go.mod h1:TVbgWP+PVM9TlwL1IkG3JqXXfjGxLvsu9QUeFdpTegI= +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.0.9/go.mod h1:O0Wr7si/Zj5/EBFlDzDd6UtVxx25CE1r7XM7BQKYQho= github.com/pion/dtls/v2 v2.0.10 h1:wgys7gPR1NMbWjmjJ3CW7lkUGaun8djgH8nahpNLnxI= github.com/pion/dtls/v2 v2.0.10/go.mod h1:00OxfeCRWHShcqT9jx8pKKmBWuTt0NCZoVPCaC4VKvU= -github.com/pion/ice/v2 v2.1.13 h1:/YNYcIw56LT/whwuzkTnrprcRnapj2ZNqUsR0W8elmo= -github.com/pion/ice/v2 v2.1.13/go.mod h1:ovgYHUmwYLlRvcCLI67PnQ5YGe+upXZbGgllBDG/ktU= -github.com/pion/interceptor v0.1.0 h1:SlXKaDlEvSl7cr4j8fJykzVz4UdH+7UDtcvx+u01wLU= -github.com/pion/interceptor v0.1.0/go.mod h1:j5NIl3tJJPB3u8+Z2Xz8MZs/VV6rc+If9mXEKNuFmEM= +github.com/pion/ice/v2 v2.1.14 h1:nD9GZs3MiR1/dPa5EiMRMe8hLBG3/qqCdx/hTS2g8VE= +github.com/pion/ice/v2 v2.1.14/go.mod h1:ovgYHUmwYLlRvcCLI67PnQ5YGe+upXZbGgllBDG/ktU= +github.com/pion/interceptor v0.1.2 h1:1IfrJ+AQ0HhwxNl4hqh9hMvl1hBKiNhAAr7DrUHsC6s= +github.com/pion/interceptor v0.1.2/go.mod h1:Lh3JSl/cbJ2wP8I3ccrjh1K/deRGRn3UlSPuOTiHb6U= 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= @@ -275,14 +281,13 @@ github.com/pion/mdns v0.0.5/go.mod h1:UgssrvdD3mxpi8tMxAXbsppL3vJ4Jipw1mTCW+al01 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.6/go.mod h1:52rMNPWFsjr39z9B9MhnkqhPLoeHTv1aN63o/42bWE0= -github.com/pion/rtcp v1.2.8 h1:Cys8X6r0xxU65ESTmXkqr8eU1Q1Wx+lNkoZCUH4zD7E= -github.com/pion/rtcp v1.2.8/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo= +github.com/pion/rtcp v1.2.9 h1:1ujStwg++IOLIEoOiIQ2s+qBuJ1VN81KW+9pMPsif+U= +github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo= github.com/pion/rtp v1.7.0/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= -github.com/pion/rtp v1.7.2/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= github.com/pion/rtp v1.7.4 h1:4dMbjb1SuynU5OpA3kz1zHK+u+eOCQjW3MAeVHf1ODA= github.com/pion/rtp v1.7.4/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= -github.com/pion/sctp v1.7.12 h1:GsatLufywVruXbZZT1CKg+Jr8ZTkwiPnmUC/oO9+uuY= -github.com/pion/sctp v1.7.12/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s= +github.com/pion/sctp v1.8.0 h1:6erMF2qmQwXr+0iB1lm0AUSmDr9LdmpaBzgSVAEgehw= +github.com/pion/sctp v1.8.0/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s= github.com/pion/sdp/v3 v3.0.4 h1:2Kf+dgrzJflNCSw3TV5v2VLeI0s/qkzy2r5jlR0wzf8= github.com/pion/sdp/v3 v3.0.4/go.mod h1:bNiSknmJE0HYBprTHXKPQ3+JjacTv5uap92ueJZKsRk= github.com/pion/srtp/v2 v2.0.5 h1:ks3wcTvIUE/GHndO3FAvROQ9opy0uLELpwHJaQ1yqhQ= @@ -297,8 +302,8 @@ github.com/pion/turn/v2 v2.0.5 h1:iwMHqDfPEDEOFzwWKT56eFmh6DYC6o/+xnLAEzgISbA= github.com/pion/turn/v2 v2.0.5/go.mod h1:APg43CFyt/14Uy7heYUOGWdkem/Wu4PhCO/bjyrTqMw= 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.7 h1:BXkjdVmj+HYiou8eBX6F3Y8xdRkQejg8px5JudTOv0M= -github.com/pion/webrtc/v3 v3.1.7/go.mod h1:SQxttydYlKo2rCQjHzkUCJFAfEO/Oh2efMWbKYXLk98= +github.com/pion/webrtc/v3 v3.1.11 h1:8Q5BEsxvlDn3botM8U8n/Haln745FBa5TWgm8v2c2FA= +github.com/pion/webrtc/v3 v3.1.11/go.mod h1:h9pbP+CADYb/99s5rfjflEcBLgdVKm55Rm7heQ/gIvY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -370,8 +375,9 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U 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-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -457,8 +463,9 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211005001312-d4b1ae081e3b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211101193420-4a448f8816b3 h1:VrJZAjbekhoRn7n5FBujY31gboH+iB3pdLxn3gE9FjU= -golang.org/x/net v0.0.0-20211101193420-4a448f8816b3/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c h1:WtYZ93XtWSO5KlOMgPZu7hXY9WhMZpprvlm5VwvAl8c= +golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -475,8 +482,8 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 h1:v79phzBz03tsVCUTbvTBmmC3CUXF5mKYt7DA4ZVldpM= -golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -550,8 +557,8 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211102061401-a2f17f7b995c h1:QOfDMdrf/UwlVR0UBq2Mpr58UzNtvgJRXA4BgPfFACs= -golang.org/x/sys v0.0.0-20211102061401-a2f17f7b995c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 h1:TyHqChC80pFkXWraUUf6RuB5IqFdQieMLwwCJokV2pc= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -722,8 +729,8 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145 h1:vum3nDKdleYb+aePXKFEDT2+ghuH00EgYp9B7Q7EZZE= -google.golang.org/genproto v0.0.0-20211101144312-62acf1d99145/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12 h1:DN5b3HU13J4sMd/QjDx34U6afpaexKTDdop+26pdjdk= +google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -749,8 +756,8 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E= -google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= +google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= 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= diff --git a/pkg/config/coordinator/config.go b/pkg/config/coordinator/config.go index 80eb12c8..f326f193 100644 --- a/pkg/config/coordinator/config.go +++ b/pkg/config/coordinator/config.go @@ -20,6 +20,7 @@ type Config struct { } Emulator emulator.Emulator Environment shared.Environment + Recording shared.Recording Webrtc webrtcConfig.Webrtc } diff --git a/pkg/config/shared/config.go b/pkg/config/shared/config.go index 129f71bb..d6d0c95b 100644 --- a/pkg/config/shared/config.go +++ b/pkg/config/shared/config.go @@ -18,6 +18,14 @@ type Server struct { } } +type Recording struct { + Enabled bool + CompressLevel int + Name string + Folder string + Zip bool +} + func (s *Server) WithFlags() { flag.StringVar(&s.Address, "address", s.Address, "HTTP server address (host:port)") flag.StringVar(&s.Tls.Address, "httpsAddress", s.Tls.Address, "HTTPS server address (host:port)") diff --git a/pkg/config/worker/config.go b/pkg/config/worker/config.go index c0aaaf3a..ab8dc3ce 100644 --- a/pkg/config/worker/config.go +++ b/pkg/config/worker/config.go @@ -21,6 +21,7 @@ type Config struct { Encoder encoder.Encoder Emulator emulator.Emulator Environment shared.Environment + Recording shared.Recording Storage storage.Storage Worker Worker Webrtc webrtcConfig.Webrtc diff --git a/pkg/coordinator/http.go b/pkg/coordinator/http.go index 99fd5260..a59b7133 100644 --- a/pkg/coordinator/http.go +++ b/pkg/coordinator/http.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/config/shared" "github.com/giongto35/cloud-game/v2/pkg/network/httpx" ) @@ -36,7 +37,11 @@ func index(conf coordinator.Config) http.Handler { return } // render index page with some tpl values - if err = tpl.Execute(w, conf.Coordinator.Analytics); err != nil { + tplData := struct { + Analytics coordinator.Analytics + Recording shared.Recording + }{conf.Coordinator.Analytics, conf.Recording} + if err = tpl.Execute(w, tplData); err != nil { log.Fatal(err) } }) diff --git a/pkg/coordinator/routes.go b/pkg/coordinator/routes.go index 5652fb9a..87531e8d 100644 --- a/pkg/coordinator/routes.go +++ b/pkg/coordinator/routes.go @@ -29,4 +29,5 @@ func (s *Server) useragentRoutes(bc *BrowserClient) { bc.Receive(api.GameLoad, bc.handleGameLoad(s)) bc.Receive(api.GamePlayerSelect, bc.handleGamePlayerSelect(s)) bc.Receive(api.GameMultitap, bc.handleGameMultitap(s)) + bc.Receive(api.GameRecording, bc.handleGameRecording(s)) } diff --git a/pkg/coordinator/useragenthandlers.go b/pkg/coordinator/useragenthandlers.go index fbbcb7b4..14997656 100644 --- a/pkg/coordinator/useragenthandlers.go +++ b/pkg/coordinator/useragenthandlers.go @@ -75,7 +75,7 @@ func (bc *BrowserClient) handleGameStart(o *Server) cws.PacketHandler { } // +injects game data into the original game request - gameStartCall, err := newGameStartCall(resp.RoomID, resp.Data, o.library) + gameStartCall, err := newGameStartCall(resp.RoomID, resp.Data, o.library, o.cfg.Recording.Enabled) if err != nil { return cws.EmptyPacket } @@ -85,11 +85,23 @@ func (bc *BrowserClient) handleGameStart(o *Server) cws.PacketHandler { resp.Data = packet } workerResp := wc.SyncSend(resp) - // Response from worker contains initialized roomID. Set roomID to the session bc.RoomID = workerResp.RoomID bc.Println("Received room response from browser: ", workerResp.RoomID) + if o.cfg.Recording.Enabled && gameStartCall.Record { + bc.Send(cws.WSPacket{ + ID: api.GameRecording, + Data: "ok", + RoomID: workerResp.RoomID, + PlayerIndex: workerResp.PlayerIndex, + PacketID: workerResp.PacketID, + SessionID: workerResp.SessionID, + }, func(response cws.WSPacket) { + + }) + } + return workerResp } } @@ -179,8 +191,41 @@ func (bc *BrowserClient) handleGameMultitap(o *Server) cws.PacketHandler { } } +func (bc *BrowserClient) handleGameRecording(o *Server) cws.PacketHandler { + return func(resp cws.WSPacket) (req cws.WSPacket) { + bc.Println("Received recording request from a browser -> relay to worker") + + if !o.cfg.Recording.Enabled { + bc.Printf("Recording should be disabled!") + return cws.EmptyPacket + } + + request := api.GameRecordingRequest{} + if err := request.From(resp.Data); err != nil { + return cws.EmptyPacket + } + + bc.Printf("Session: %v, room: %v, rec: %v user: %v", bc.SessionID, bc.RoomID, request.Active, request.User) + + if bc.RoomID == "" { + bc.Printf("Recording in the empty room is not allowed!") + return cws.EmptyPacket + } + + resp.SessionID = bc.SessionID + resp.RoomID = bc.RoomID + wc, ok := o.workerClients[bc.WorkerID] + if !ok { + return cws.EmptyPacket + } + resp = wc.SyncSend(resp) + + return resp + } +} + // newGameStartCall gathers data for a new game start call of the worker -func newGameStartCall(roomId string, data string, library games.GameLibrary) (api.GameStartCall, error) { +func newGameStartCall(roomId string, data string, library games.GameLibrary, recording bool) (api.GameStartCall, error) { request := api.GameStartRequest{} if err := request.From(data); err != nil { return api.GameStartCall{}, errors.New("invalid request") @@ -203,10 +248,15 @@ func newGameStartCall(roomId string, data string, library games.GameLibrary) (ap return api.GameStartCall{}, fmt.Errorf("couldn't find game info for the game %v", game) } - return api.GameStartCall{ + call := api.GameStartCall{ Name: gameInfo.Name, Base: gameInfo.Base, Path: gameInfo.Path, Type: gameInfo.Type, - }, nil + } + if recording { + call.Record = request.Record + call.RecordUser = request.RecordUser + } + return call, nil } diff --git a/pkg/cws/api/coordinator.go b/pkg/cws/api/coordinator.go index 2e5340fe..73ed8dc8 100644 --- a/pkg/cws/api/coordinator.go +++ b/pkg/cws/api/coordinator.go @@ -20,19 +20,31 @@ const ( GameLoad = "load" GamePlayerSelect = "player_index" GameMultitap = "multitap" + GameRecording = "recording" ) type GameStartRequest struct { - GameName string `json:"game_name"` + GameName string `json:"game_name"` + Record bool `json:"record,omitempty"` + RecordUser string `json:"record_user,omitempty"` } func (packet *GameStartRequest) From(data string) error { return from(packet, data) } +type GameRecordingRequest struct { + Active bool `json:"active"` + User string `json:"user"` +} + +func (packet *GameRecordingRequest) From(data string) error { return from(packet, data) } + type GameStartCall struct { - Name string `json:"name"` - Base string `json:"base"` - Path string `json:"path"` - Type string `json:"type"` + Name string `json:"name"` + Base string `json:"base"` + Path string `json:"path"` + Type string `json:"type"` + Record bool `json:"record,omitempty"` + RecordUser string `json:"record_user,omitempty"` } func (packet *GameStartCall) From(data string) error { return from(packet, data) } diff --git a/pkg/cws/cws.go b/pkg/cws/cws.go index 8bad0e21..41d311da 100644 --- a/pkg/cws/cws.go +++ b/pkg/cws/cws.go @@ -3,7 +3,6 @@ package cws import ( "encoding/json" "log" - "runtime/debug" "sync" "time" @@ -105,7 +104,6 @@ func (c *Client) Receive(id string, f PacketHandler) { defer func() { if err := recover(); err != nil { log.Println("Recovered from err ", err) - log.Println(debug.Stack()) } }() diff --git a/pkg/emulator/emulator.go b/pkg/emulator/emulator.go index 9a8d57f1..34b1d5d3 100644 --- a/pkg/emulator/emulator.go +++ b/pkg/emulator/emulator.go @@ -10,8 +10,6 @@ type CloudEmulator interface { Start() // SetViewport sets viewport size SetViewport(width int, height int) - // GetViewport debug encoder image - GetViewport() interface{} // SaveGame save game state SaveGame() error // LoadGame load game state diff --git a/pkg/emulator/image/color.go b/pkg/emulator/image/color.go index 8ed950f5..085e4c09 100644 --- a/pkg/emulator/image/color.go +++ b/pkg/emulator/image/color.go @@ -31,6 +31,6 @@ func Rgba8888(data []byte, index int) color.RGBA { R: data[index+2], G: data[index+1], B: data[index], - A: data[index+3], + A: 255, } } diff --git a/pkg/emulator/image/draw.go b/pkg/emulator/image/draw.go index 08aa21e0..b2a1ba87 100644 --- a/pkg/emulator/image/draw.go +++ b/pkg/emulator/image/draw.go @@ -16,9 +16,10 @@ var canvas = imageCache{ 0, } -func DrawRgbaImage(pixFormat Format, rotationFn Rotate, scaleType int, flipV bool, w, h, packedW, bpp int, data []byte, dest *image.RGBA) { +func DrawRgbaImage(pixFormat Format, rotationFn Rotate, scaleType int, flipV bool, w, h, packedW, bpp int, + data []byte, dw, dh int) *image.RGBA { if pixFormat == nil { - dest = nil + return nil } // !to implement own image interfaces img.Pix = bytes[] @@ -29,7 +30,9 @@ func DrawRgbaImage(pixFormat Format, rotationFn Rotate, scaleType int, flipV boo src := getCanvas(ww, hh) drawImage(pixFormat, w, h, packedW, bpp, flipV, rotationFn, data, src) - Resize(scaleType, src, dest) + out := image.NewRGBA(image.Rect(0,0, dw, dh)) + Resize(scaleType, src, out) + return out } func drawImage(toRGBA Format, w, h, packedW, bpp int, flipV bool, rotationFn Rotate, data []byte, image *image.RGBA) { diff --git a/pkg/emulator/libretro/nanoarch/naemulator.go b/pkg/emulator/libretro/nanoarch/naemulator.go index c2a42d02..c1d0ea98 100644 --- a/pkg/emulator/libretro/nanoarch/naemulator.go +++ b/pkg/emulator/libretro/nanoarch/naemulator.go @@ -65,6 +65,9 @@ type naEmulator struct { isSavingLoading bool storage Storage + // out frame size + vw, vh int + players Players done chan struct{} @@ -83,7 +86,6 @@ type GameFrame struct { } var NAEmulator *naEmulator -var outputImg *image.RGBA // NAEmulator implements CloudEmulator interface based on NanoArch(golang RetroArch) func NewNAEmulator(roomID string, inputChannel <-chan InputEvent, storage Storage, conf config.LibretroCoreConfig) (*naEmulator, chan GameFrame, chan []int16) { @@ -173,10 +175,7 @@ func (na *naEmulator) LoadMeta(path string) emulator.Metadata { return na.meta } -func (na *naEmulator) SetViewport(width int, height int) { - // outputImg is tmp img used for decoding and reuse in encoding flow - outputImg = image.NewRGBA(image.Rect(0, 0, width, height)) -} +func (na *naEmulator) SetViewport(width int, height int) { na.vw, na.vh = width, height } func (na *naEmulator) Start() { err := na.LoadGame() @@ -184,25 +183,28 @@ func (na *naEmulator) Start() { log.Printf("error: couldn't load a save, %v", err) } + framerate := 1 / na.meta.Fps + log.Printf("framerate: %vms", framerate) ticker := time.NewTicker(time.Second / time.Duration(na.meta.Fps)) + defer ticker.Stop() - frameTime = time.Now().UnixNano() + lastFrameTime = time.Now() + + for { + na.Lock() + nanoarchRun() + na.Unlock() - for range ticker.C { select { - // Slow response here + case <-ticker.C: + continue case <-na.done: nanoarchShutdown() close(na.imageChannel) close(na.audioChannel) log.Println("Closed Director") return - default: } - - na.Lock() - nanoarchRun() - na.Unlock() } } @@ -234,10 +236,6 @@ func (na *naEmulator) GetHashPath() string { return na.storage.GetSavePath() } func (na *naEmulator) GetSRAMPath() string { return na.storage.GetSRAMPath() } -func (*naEmulator) GetViewport() interface{} { - return outputImg -} - func (na *naEmulator) Close() { close(na.done) } diff --git a/pkg/emulator/libretro/nanoarch/nanoarch.go b/pkg/emulator/libretro/nanoarch/nanoarch.go index 02ef1fb7..14afeef5 100644 --- a/pkg/emulator/libretro/nanoarch/nanoarch.go +++ b/pkg/emulator/libretro/nanoarch/nanoarch.go @@ -3,7 +3,6 @@ package nanoarch import ( "bufio" "log" - "math/rand" "os" "os/user" "runtime" @@ -58,8 +57,8 @@ void bridge_execute(void *f); */ import "C" -var mu sync.Mutex -var frameTime int64 +var mu, fmu sync.Mutex +var lastFrameTime time.Time var video struct { pitch uint32 @@ -98,8 +97,6 @@ var systemDirectory = C.CString("./pkg/emulator/libretro/system") var saveDirectory = C.CString(".") var currentUser *C.char -var seed = rand.New(rand.NewSource(time.Now().UnixNano())).Uint32() - var bindKeysMap = map[int]int{ C.RETRO_DEVICE_ID_JOYPAD_A: 0, C.RETRO_DEVICE_ID_JOYPAD_B: 1, @@ -130,16 +127,18 @@ type CloudEmulator interface { //export coreVideoRefresh func coreVideoRefresh(data unsafe.Pointer, width C.unsigned, height C.unsigned, pitch C.size_t) { + t := time.Now() + fmu.Lock() + dt := t.Sub(lastFrameTime) + lastFrameTime = t + fmu.Unlock() + // some cores can return nothing // !to add duplicate if can dup if data == nil { return } - t := time.Now().UnixNano() - // if Libretro renders frame with OpenGL context - isOpenGLRender := data == C.RETRO_HW_FRAME_BUFFER_VALID - // calculate real frame width in pixels from packed data (realWidth >= width) packedWidth := int(uint32(pitch) / video.bpp) if packedWidth < 1 { @@ -148,6 +147,8 @@ func coreVideoRefresh(data unsafe.Pointer, width C.unsigned, height C.unsigned, // calculate space for the video frame bytes := int(height) * packedWidth * int(video.bpp) + // if Libretro renders frame with OpenGL context + isOpenGLRender := data == C.RETRO_HW_FRAME_BUFFER_VALID var data_ []byte if isOpenGLRender { data_ = graphics.ReadFramebuffer(bytes, int(width), int(height)) @@ -156,23 +157,21 @@ func coreVideoRefresh(data unsafe.Pointer, width C.unsigned, height C.unsigned, } // the image is being resized and de-rotated - image.DrawRgbaImage( + img := image.DrawRgbaImage( pixelFormatConverterFn, rotationFn, image.ScaleNearestNeighbour, isOpenGLRender, int(width), int(height), packedWidth, int(video.bpp), data_, - outputImg, + NAEmulator.vw, + NAEmulator.vh, ) - delta := t - frameTime - frameTime = t - // the image is pushed into a channel // where it will be distributed with fan-out select { - case NAEmulator.imageChannel <- GameFrame{Data: outputImg, Duration: time.Duration(delta)}: + case NAEmulator.imageChannel <- GameFrame{Data: img, Duration: dt}: default: } } diff --git a/pkg/recorder/draw.go b/pkg/recorder/draw.go new file mode 100644 index 00000000..e3f6d4f8 --- /dev/null +++ b/pkg/recorder/draw.go @@ -0,0 +1,39 @@ +package recorder + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "time" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +func AddLabel(img *image.RGBA, x, y int, label string) { + draw.Draw(img, image.Rect(x, y, x+len(label)*7+3, y+12), &image.Uniform{C: color.RGBA{}}, image.Point{}, draw.Src) + (&font.Drawer{ + Dst: img, + Src: image.NewUniform(color.RGBA{R: 255, G: 255, B: 255, A: 255}), + Face: basicfont.Face7x13, + Dot: fixed.Point26_6{X: fixed.Int26_6((x + 2) * 64), Y: fixed.Int26_6((y + 10) * 64)}, + }).DrawString(label) +} + +func clone(src image.Image) *image.RGBA { + b := src.Bounds() + dst := image.NewRGBA(b) + draw.Draw(dst, b, src, b.Min, draw.Src) + return dst +} + +func TimeFormat(d time.Duration) string { + mms := int(d.Milliseconds()) + ms := mms % 1000 + s := (mms / 1000) % 60 + m := (mms / (1000 * 60)) % 60 + h := (mms / (1000 * 60 * 60)) % 24 + return fmt.Sprintf("%02d:%02d:%02d.%03d", h, m, s, ms) +} diff --git a/pkg/recorder/ffmpegstream.go b/pkg/recorder/ffmpegstream.go new file mode 100644 index 00000000..7f04719e --- /dev/null +++ b/pkg/recorder/ffmpegstream.go @@ -0,0 +1,121 @@ +package recorder + +import ( + "bytes" + "fmt" + "image" + "image/png" + "log" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/hashicorp/go-multierror" +) + +type ffmpegStream struct { + VideoStream + + demux *file + + buf chan Video + dir string + pnge *png.Encoder + sequence uint32 + fps float64 + wg sync.WaitGroup +} + +const ( + demuxFile = "input.txt" + videoFile = "f%v.png" +) + +type pool struct{ sync.Pool } + +func pngBuf() *pool { return &pool{sync.Pool{New: func() interface{} { return &png.EncoderBuffer{} }}} } +func (p *pool) Get() *png.EncoderBuffer { return p.Pool.Get().(*png.EncoderBuffer) } +func (p *pool) Put(b *png.EncoderBuffer) { p.Pool.Put(b) } + +func NewFfmpegStream(dir string, opts Options) (*ffmpegStream, error) { + demux, err := newFile(dir, demuxFile) + if err != nil { + return nil, err + } + + _, err = demux.WriteString( + fmt.Sprintf("ffconcat version 1.0\n"+ + "# v: 1\n# date: %v\n# game: %v\n# fps: %v\n# freq (hz): %v\n\n", + time.Now().Format("20060102"), opts.Game, opts.Fps, opts.Frequency)) + + return &ffmpegStream{ + buf: make(chan Video, 1), + dir: dir, + demux: demux, + fps: opts.Fps, + pnge: &png.Encoder{ + CompressionLevel: png.CompressionLevel(opts.ImageCompressionLevel), + BufferPool: pngBuf(), + }, + }, nil +} + +func (f *ffmpegStream) Start() { + for frame := range f.buf { + if err := f.Save(frame.Image, frame.Duration); err != nil { + log.Printf("image write err: %v", err) + } + } +} + +func (f *ffmpegStream) Stop() error { + var result *multierror.Error + close(f.buf) + f.resetSeq() + result = multierror.Append(result, f.demux.Flush()) + result = multierror.Append(result, f.demux.Close()) + f.wg.Wait() + return result.ErrorOrNil() +} + +func (f *ffmpegStream) Save(img image.Image, dur time.Duration) error { + fileName := fmt.Sprintf(videoFile, f.nextSeq()) + f.wg.Add(1) + go f.saveImage(fileName, img) + // ffmpeg concat demuxer, see: https://ffmpeg.org/ffmpeg-formats.html#concat + inf := fmt.Sprintf("file %v\nduration %v\n#delta %v\n", fileName, 1/f.fps, dur.Seconds()) + if _, err := f.demux.WriteString(inf); err != nil { + return err + } + return nil +} + +func (f *ffmpegStream) saveImage(fileName string, img image.Image) { + defer f.wg.Done() + + var buf bytes.Buffer + x, y := (img).Bounds().Dx(), (img).Bounds().Dy() + buf.Grow(x * y * 4) + + if err := f.pnge.Encode(&buf, img); err != nil { + log.Printf("p err: %v", err) + } else { + file, err := os.Create(filepath.Join(f.dir, fileName)) + if err != nil { + log.Printf("c err: %v", err) + } + if _, err = file.Write(buf.Bytes()); err != nil { + log.Printf("f err: %v", err) + } + if err = file.Close(); err != nil { + log.Printf("fc err: %v", err) + } + } +} + +func (f *ffmpegStream) nextSeq() uint32 { return atomic.AddUint32(&f.sequence, 1) } +func (f *ffmpegStream) resetSeq() { atomic.StoreUint32(&f.sequence, 0) } + +func (f *ffmpegStream) Write(data Video) { f.buf <- data } diff --git a/pkg/recorder/file.go b/pkg/recorder/file.go new file mode 100644 index 00000000..2c983583 --- /dev/null +++ b/pkg/recorder/file.go @@ -0,0 +1,74 @@ +package recorder + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "sync" +) + +var defaultBufferSize = 4096 + +type file struct { + io.Closer + sync.Mutex + + f *os.File + w *bufio.Writer +} + +func newFile(dir string, name string) (*file, error) { + f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + return &file{f: f, w: bufio.NewWriterSize(f, defaultBufferSize)}, nil +} + +func (f *file) Flush() error { + f.Lock() + defer f.Unlock() + return f.w.Flush() +} + +func (f *file) Close() error { return f.f.Close() } + +func (f *file) Size() (int64, error) { + f.Lock() + defer f.Unlock() + inf, err := f.f.Stat() + if err != nil { + return -1, err + } + return inf.Size(), nil +} + +func (f *file) Write(data []byte) error { + f.Lock() + n, err := f.w.Write(data) + f.Unlock() + if err != nil { + if n < len(data) { + return fmt.Errorf("write size mismatch [%v!=%v], %v", n, len(data), err) + } + return err + } + return nil +} + +// WriteAtStart writes data into beginning of the file. +// Make sure that underling file doesn't use the O_APPEND directive. +func (f *file) WriteAtStart(data []byte) error { + if _, err := f.f.Seek(0, 0); err != nil { + return err + } + return f.Write(data) +} + +func (f *file) WriteString(s string) (int, error) { + f.Lock() + defer f.Unlock() + return f.w.WriteString(s) +} diff --git a/pkg/recorder/options.go b/pkg/recorder/options.go new file mode 100644 index 00000000..e68773ac --- /dev/null +++ b/pkg/recorder/options.go @@ -0,0 +1,17 @@ +package recorder + +type Options struct { + Dir string + Fps float64 + Frequency int + Game string + ImageCompressionLevel int + Name string + Zip bool +} + +type Option func(*Options) + +type Meta struct { + UserName string +} diff --git a/pkg/recorder/recorder.go b/pkg/recorder/recorder.go new file mode 100644 index 00000000..465c4c7d --- /dev/null +++ b/pkg/recorder/recorder.go @@ -0,0 +1,198 @@ +package recorder + +import ( + "image" + "log" + "math/rand" + "os" + "path/filepath" + "regexp" + "strconv" + "sync" + "time" + + "github.com/hashicorp/go-multierror" +) + +type Recording struct { + sync.Mutex + + enabled bool + + audio AudioStream + video VideoStream + + dir string + saveDir string + meta Meta + opts Options +} + +// naming regexp +var ( + reDate = regexp.MustCompile(`%date:(.*?)%`) + reUser = regexp.MustCompile(`%user%`) + reGame = regexp.MustCompile(`%game%`) + reRand = regexp.MustCompile(`%rand:(\d+)%`) +) + +// Stream represent an output stream of the recording. +type Stream interface { + Start() + Stop() error +} + +type AudioStream interface { + Stream + Write(data Audio) +} +type VideoStream interface { + Stream + Write(data Video) +} + +type ( + Audio struct { + Samples *[]int16 + } + Video struct { + Image image.Image + Duration time.Duration + } +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// NewRecording creates new recorder of the emulator. +// +// FFMPEG: +// +// Example of conversion: +// ffmpeg -r 60 -f concat -i ./recording/psxtest/input.txt \ +// -ac 2 -channel_layout stereo -i ./recording/psxtest/audio.wav \ +// -b:a 192K -crf 23 -pix_fmt yuv420p out.mp4 +func NewRecording(meta Meta, opts Options) *Recording { + savePath, err := filepath.Abs(opts.Dir) + if err != nil { + log.Fatal(err) + } + if _, err := os.Stat(savePath); os.IsNotExist(err) { + if err = os.Mkdir(savePath, os.ModeDir); err != nil { + log.Fatal(err) + } + } + return &Recording{dir: savePath, meta: meta, opts: opts} +} + +func (r *Recording) Start() { + r.Lock() + defer r.Unlock() + r.enabled = true + + r.saveDir = parseName(r.opts.Name, r.opts.Game, r.meta.UserName) + path := filepath.Join(r.dir, r.saveDir) + + log.Printf("[recording] path will be [%v]", path) + + if _, err := os.Stat(path); os.IsNotExist(err) { + if err = os.Mkdir(path, os.ModeDir); err != nil { + log.Fatal(err) + } + } + + audio, err := NewWavStream(path, r.opts) + if err != nil { + log.Fatal(err) + } + r.audio = audio + video, err := NewFfmpegStream(path, r.opts) + if err != nil { + log.Fatal(err) + } + r.video = video + + go r.audio.Start() + go r.video.Start() +} + +func (r *Recording) Stop() error { + var result *multierror.Error + r.Lock() + defer r.Unlock() + r.enabled = false + result = multierror.Append(result, r.audio.Stop()) + result = multierror.Append(result, r.video.Stop()) + if result.ErrorOrNil() == nil && r.opts.Zip && r.saveDir != "" { + src := filepath.Join(r.dir, r.saveDir) + dst := filepath.Join(src, "..", r.saveDir) + go func() { + if err := compress(src, dst); err != nil { + log.Printf("error during result compress, %v", result) + return + } + if err := os.RemoveAll(src); err != nil { + log.Printf("error during result compress, %v", result) + } + }() + } + return result.ErrorOrNil() +} + +func (r *Recording) Set(enable bool, user string) { + r.Lock() + if !r.enabled && enable { + r.Unlock() + r.Start() + r.Lock() + } else { + if r.enabled && !enable { + r.Unlock() + if err := r.Stop(); err != nil { + log.Printf("failed to stop recording, %v", err) + } + r.Lock() + } + } + r.enabled = enable + r.meta.UserName = user + r.Unlock() +} + +func (r *Recording) Enabled() bool { + r.Lock() + defer r.Unlock() + return r.enabled +} + +func (r *Recording) WriteVideo(frame Video) { r.video.Write(frame) } +func (r *Recording) WriteAudio(audio Audio) { r.audio.Write(audio) } + +func parseName(name, game, user string) (out string) { + if d := reDate.FindStringSubmatch(name); d != nil { + out = reDate.ReplaceAllString(name, time.Now().Format(d[1])) + } else { + out = name + } + if rnd := reRand.FindStringSubmatch(out); rnd != nil { + out = reRand.ReplaceAllString(out, random(rnd[1])) + } + out = reUser.ReplaceAllString(out, user) + out = reGame.ReplaceAllString(out, game) + return +} + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func random(num string) string { + n, err := strconv.Atoi(num) + if err != nil { + return "" + } + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] + } + return string(b) +} diff --git a/pkg/recorder/recorder_test.go b/pkg/recorder/recorder_test.go new file mode 100644 index 00000000..26ac88dc --- /dev/null +++ b/pkg/recorder/recorder_test.go @@ -0,0 +1,156 @@ +package recorder + +import ( + "fmt" + "image" + "image/color" + "io/ioutil" + "log" + "math/rand" + "os" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestName(t *testing.T) { + dir, err := ioutil.TempDir("", "rec_test_") + if err != nil { + log.Fatal(err) + } + defer func() { + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + }() + + recorder := NewRecording( + Meta{UserName: "test"}, + Options{ + Dir: dir, + Fps: 60, + Frequency: 10, + Game: fmt.Sprintf("test_game_%v", rand.Int()), + ImageCompressionLevel: 0, + Name: "test", + Zip: true, + }) + recorder.Set(true, "test_user") + + iterations := 222 + + var imgWg, audioWg sync.WaitGroup + imgWg.Add(iterations) + audioWg.Add(iterations) + img := generateImage(100, 100) + + for i := 0; i < 222; i++ { + go func() { + recorder.WriteVideo(Video{Image: img, Duration: 16 * time.Millisecond}) + imgWg.Done() + }() + go func() { + recorder.WriteAudio(Audio{&[]int16{0, 0, 0, 0, 0, 1, 11, 11, 11, 1}}) + audioWg.Done() + }() + } + + imgWg.Wait() + audioWg.Wait() + if err := recorder.Stop(); err != nil { + t.Fatal(err) + } +} + +func BenchmarkNewRecording100x100(b *testing.B) { + benchmarkRecorder(100, 100, 0, b) +} + +func BenchmarkNewRecording320x240_compressed(b *testing.B) { + benchmarkRecorder(320, 240, 0, b) +} +func BenchmarkNewRecording320x240_nocompression(b *testing.B) { + benchmarkRecorder(320, 240, -1, b) +} + +func benchmarkRecorder(w, h int, comp int, b *testing.B) { + b.StopTimer() + + dir, err := ioutil.TempDir("", "rec_bench_") + if err != nil { + b.Fatal(err) + } + defer func() { + if err := os.RemoveAll(dir); err != nil { + b.Fatal(err) + } + }() + + image1 := generateImage(w, h) + image2 := generateImage(w, h) + + var bytes int64 = 0 + + var ticks sync.WaitGroup + ticks.Add(b.N * 2) + + b.StartTimer() + + recorder := NewRecording( + Meta{UserName: "test"}, + Options{ + Dir: dir, + Fps: 60, + Frequency: 10, + Game: fmt.Sprintf("test_game_%v", rand.Int()), + ImageCompressionLevel: comp, + Name: "", + Zip: false, + }) + recorder.Set(true, "test_user") + samples := []int16{0, 0, 0, 0, 0, 1, 11, 11, 11, 1} + + for i := 0; i < b.N; i++ { + im := image1 + if i%2 == 0 { + im = image2 + } + go func() { + recorder.WriteVideo(Video{Image: im, Duration: 16 * time.Millisecond}) + atomic.AddInt64(&bytes, int64(len(im.(*image.RGBA).Pix))) + ticks.Done() + }() + go func() { + recorder.WriteAudio(Audio{&samples}) + atomic.AddInt64(&bytes, int64(len(samples)*2)) + ticks.Done() + }() + } + ticks.Wait() + b.SetBytes(bytes / int64(b.N)) + if err := recorder.Stop(); err != nil { + b.Fatal(err) + } +} + +func generateImage(w, h int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + img.Set(x, y, randomColor()) + } + } + return img +} + +var rnd = rand.New(rand.NewSource(time.Now().Unix())) + +func randomColor() color.RGBA { + return color.RGBA{ + R: uint8(rnd.Intn(256)), + G: uint8(rnd.Intn(256)), + B: uint8(rnd.Intn(256)), + A: 255, + } +} diff --git a/pkg/recorder/wavstream.go b/pkg/recorder/wavstream.go new file mode 100644 index 00000000..59c764d2 --- /dev/null +++ b/pkg/recorder/wavstream.go @@ -0,0 +1,115 @@ +package recorder + +import ( + "encoding/binary" + "log" + + "github.com/hashicorp/go-multierror" +) + +type wavStream struct { + AudioStream + + buf chan Audio + frequency int + wav *file +} + +const ( + audioFile = "audio.wav" + audioFileRIFFSize = 44 +) + +func NewWavStream(dir string, opts Options) (*wavStream, error) { + wav, err := newFile(dir, audioFile) + if err != nil { + return nil, err + } + // add pad for RIFF + if err = wav.Write(make([]byte, audioFileRIFFSize)); err != nil { + return nil, err + } + return &wavStream{ + frequency: opts.Frequency, + wav: wav, + buf: make(chan Audio, 1), + }, nil +} + +func (w *wavStream) Start() { + for audio := range w.buf { + if err := w.Save(*audio.Samples); err != nil { + log.Printf("wav write err: %v", err) + } + } +} + +func (w *wavStream) Stop() error { + var result *multierror.Error + close(w.buf) + result = multierror.Append(result, w.wav.Flush()) + size, er := w.wav.Size() + if er != nil { + result = multierror.Append(result, er) + } + if size > 0 { + // write an actual RIFF header + result = multierror.Append(result, w.wav.WriteAtStart(rIFFWavHeader(uint32(size), w.frequency))) + result = multierror.Append(result, w.wav.Flush()) + } + result = multierror.Append(result, w.wav.Close()) + return result.ErrorOrNil() +} + +func (w *wavStream) Save(pcm []int16) error { + bs := make([]byte, len(pcm)*2) + // int & 0xFF + (int >> 8) & 0xFF + for i, ln := 0, len(pcm); i < ln; i++ { + binary.LittleEndian.PutUint16(bs[i*2:i*2+2], uint16(pcm[i])) + } + return w.wav.Write(bs) +} + +// rIFFWavHeader creates RIFF WAV header. +// See: http://soundfile.sapp.org/doc/WaveFormat +func rIFFWavHeader(fSize uint32, fq int) []byte { + const ( + bits byte = 16 + ch byte = 2 + chunk = 36 + ) + aSize := fSize - audioFileRIFFSize + bitrate := uint32(fq*int(ch*bits)) >> 3 + size := aSize + chunk + header := [audioFileRIFFSize]byte{ + // ChunkID + 'R', 'I', 'F', 'F', + // ChunkSize + byte(size & 0xff), byte((size >> 8) & 0xff), byte((size >> 16) & 0xff), byte((size >> 24) & 0xff), + // Format + 'W', 'A', 'V', 'E', + // Subchunk1ID + 'f', 'm', 't', ' ', + // Subchunk1Size + bits, 0, 0, 0, + // AudioFormat + 1, 0, + // NumChannels + ch, 0, + // SampleRate + byte(fq & 0xff), byte((fq >> 8) & 0xff), byte((fq >> 16) & 0xff), byte((fq >> 24) & 0xff), + // ByteRate == SampleRate * NumChannels * BitsPerSample/8 + byte(bitrate & 0xff), byte((bitrate >> 8) & 0xff), byte((bitrate >> 16) & 0xff), byte((bitrate >> 24) & 0xff), + // BlockAlign == NumChannels * BitsPerSample/8 + ch * bits >> 3, 0, + // BitsPerSample + 16, 0, + // Subchunk2ID + 'd', 'a', 't', 'a', + // Subchunk2Size == NumSamples * NumChannels * BitsPerSample/8 + byte(aSize & 0xff), byte((aSize >> 8) & 0xff), byte((aSize >> 16) & 0xff), byte((aSize >> 24) & 0xff), + } + return header[:] +} + +func (w *wavStream) Write(data Audio) { w.buf <- data } diff --git a/pkg/recorder/zipfile.go b/pkg/recorder/zipfile.go new file mode 100644 index 00000000..8ce442ae --- /dev/null +++ b/pkg/recorder/zipfile.go @@ -0,0 +1,62 @@ +package recorder + +import ( + "archive/zip" + "io" + "os" + "path/filepath" +) + +func compress(source, dest string) (err error) { + f, err := os.Create(dest + ".zip") + if err != nil { + return err + } + defer func() { err = f.Close() }() + + // !to handle errors properly + writer := zip.NewWriter(f) + defer func() { + err = writer.Flush() + err = writer.Close() + }() + + return filepath.Walk(source, func(path string, info os.FileInfo, err error) (er error) { + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + header.Method = zip.Deflate + + header.Name, err = filepath.Rel(filepath.Dir(source), path) + if err != nil { + return err + } + if info.IsDir() { + header.Name += "/" + } + + headerWriter, err := writer.CreateHeader(header) + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { er = f.Close() }() + + _, err = io.Copy(headerWriter, f) + return err + }) +} diff --git a/pkg/worker/handlers.go b/pkg/worker/handlers.go index 1b33c95f..7c7b8680 100644 --- a/pkg/worker/handlers.go +++ b/pkg/worker/handlers.go @@ -185,11 +185,11 @@ func (h *Handler) detachRoom(roomID string) { // createNewRoom creates a new room // Return nil in case of room is existed -func (h *Handler) createNewRoom(game games.GameMetadata, roomID string) *room.Room { +func (h *Handler) createNewRoom(game games.GameMetadata, recUser string, rec bool, roomID string) *room.Room { // If the roomID doesn't have any running sessions (room was closed) // we spawn a new room if !h.isRoomBusy(roomID) { - newRoom := room.NewRoom(roomID, game, h.onlineStorage, h.cfg) + newRoom := room.NewRoom(roomID, game, recUser, rec, h.onlineStorage, h.cfg) // TODO: Might have race condition (and it has (:) h.rooms[newRoom.ID] = newRoom return newRoom diff --git a/pkg/worker/internalhandlers.go b/pkg/worker/internalhandlers.go index 6211064c..9e5a795a 100644 --- a/pkg/worker/internalhandlers.go +++ b/pkg/worker/internalhandlers.go @@ -121,7 +121,15 @@ func (h *Handler) handleGameStart() cws.PacketHandler { return cws.EmptyPacket } game := games.GameMetadata{Name: rom.Name, Type: rom.Type, Base: rom.Base, Path: rom.Path} - room := h.startGameHandler(game, resp.RoomID, resp.PlayerIndex, session.peerconnection) + + // recording + if h.cfg.Recording.Enabled { + log.Printf("RECORD: %v %v", rom.Record, rom.RecordUser) + } else { + log.Printf("RECORD OFF") + } + + room := h.startGameHandler(game, rom.RecordUser, rom.Record, resp.RoomID, resp.PlayerIndex, session.peerconnection) session.RoomID = room.ID // TODO: can data race (and it does) h.rooms[room.ID] = room @@ -234,8 +242,42 @@ func (h *Handler) handleGameMultitap() cws.PacketHandler { } } +func (h *Handler) handleGameRecording() cws.PacketHandler { + return func(resp cws.WSPacket) (req cws.WSPacket) { + log.Printf("Received recording request from coordinator: %v", resp) + + req.ID = api.GameRecording + req.Data = "ok" + + if !h.cfg.Recording.Enabled { + req.Data = "error" + return req + } + + if resp.RoomID != "" { + r := h.getRoom(resp.RoomID) + if r == nil { + req.Data = "error" + return req + } + + request := api.GameRecordingRequest{} + if err := request.From(resp.Data); err != nil { + req.Data = "error" + return req + } + + r.ToggleRecording(request.Active, request.User) + } else { + req.Data = "error" + } + + return req + } +} + // startGameHandler starts a game if roomID is given, if not create new room -func (h *Handler) startGameHandler(game games.GameMetadata, existedRoomID string, playerIndex int, peerconnection *webrtc.WebRTC) *room.Room { +func (h *Handler) startGameHandler(game games.GameMetadata, recUser string, rec bool, existedRoomID string, playerIndex int, peerconnection *webrtc.WebRTC) *room.Room { log.Printf("Loading game: %v\n", game.Name) // If we are connecting to coordinator, request corresponding serverID based on roomID // TODO: check if existedRoomID is in the current server @@ -244,7 +286,7 @@ func (h *Handler) startGameHandler(game games.GameMetadata, existedRoomID string if room == nil { log.Println("Got Room from local ", room, " ID: ", existedRoomID) // Create new room and update player index - room = h.createNewRoom(game, existedRoomID) + room = h.createNewRoom(game, recUser, rec, existedRoomID) room.UpdatePlayerIndex(peerconnection, playerIndex) // Wait for done signal from room diff --git a/pkg/worker/room/media.go b/pkg/worker/room/media.go index daa07f03..654e7c0b 100644 --- a/pkg/worker/room/media.go +++ b/pkg/worker/room/media.go @@ -10,6 +10,7 @@ import ( "github.com/giongto35/cloud-game/v2/pkg/encoder/h264" "github.com/giongto35/cloud-game/v2/pkg/encoder/opus" "github.com/giongto35/cloud-game/v2/pkg/encoder/vpx" + "github.com/giongto35/cloud-game/v2/pkg/recorder" "github.com/giongto35/cloud-game/v2/pkg/webrtc" ) @@ -37,6 +38,8 @@ import ( // }() //} +func (r *Room) isRecording() bool { return r.rec != nil && r.rec.Enabled() } + func (r *Room) startAudio(sampleRate int, audio encoderConfig.Audio) { sound, err := opus.NewEncoder( sampleRate, @@ -53,6 +56,9 @@ func (r *Room) startAudio(sampleRate int, audio encoderConfig.Audio) { log.Printf("OPUS: %v", sound.GetInfo()) for samples := range r.audioChannel { + if r.isRecording() { + r.rec.WriteAudio(recorder.Audio{Samples: &samples}) + } sound.BufferWrite(samples) } @@ -124,6 +130,9 @@ func (r *Room) startVideo(width, height int, video encoderConfig.Video) { for frame := range r.imageChannel { if len(einput) < cap(einput) { + if r.isRecording() { + go r.rec.WriteVideo(recorder.Video{Image: frame.Data, Duration: frame.Duration}) + } einput <- encoder.InFrame{Image: frame.Data, Duration: frame.Duration} } } diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go index f24407bf..c48e64db 100644 --- a/pkg/worker/room/room.go +++ b/pkg/worker/room/room.go @@ -19,6 +19,7 @@ import ( "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/nanoarch" "github.com/giongto35/cloud-game/v2/pkg/encoder" "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/recorder" "github.com/giongto35/cloud-game/v2/pkg/session" "github.com/giongto35/cloud-game/v2/pkg/storage" "github.com/giongto35/cloud-game/v2/pkg/webrtc" @@ -54,6 +55,8 @@ type Room struct { // Cloud storage to store room state online onlineStorage storage.CloudStorage + rec *recorder.Recording + vPipe *encoder.VideoPipe } @@ -120,7 +123,7 @@ func NewVideoImporter(roomID string) chan nanoarch.GameFrame { } // NewRoom creates a new room -func NewRoom(roomID string, game games.GameMetadata, onlineStorage storage.CloudStorage, cfg worker.Config) *Room { +func NewRoom(roomID string, game games.GameMetadata, recUser string, rec bool, onlineStorage storage.CloudStorage, cfg worker.Config) *Room { if roomID == "" { roomID = session.GenerateRoomID(game.Name) } @@ -205,6 +208,21 @@ func NewRoom(roomID string, game games.GameMetadata, onlineStorage storage.Cloud encoderW, encoderH = nheight, nwidth } + if cfg.Recording.Enabled { + room.rec = recorder.NewRecording( + recorder.Meta{UserName: recUser}, + recorder.Options{ + Dir: cfg.Recording.Folder, + Fps: gameMeta.Fps, + Frequency: gameMeta.AudioSampleRate, + Game: game.Name, + ImageCompressionLevel: cfg.Recording.CompressLevel, + Name: cfg.Recording.Name, + Zip: cfg.Recording.Zip, + }) + room.ToggleRecording(rec, recUser) + } + room.director.SetViewport(encoderW, encoderH) // Spawn video and audio encoding for webRTC @@ -355,6 +373,11 @@ func (r *Room) Close() { // Just dont close it, let it be gc //close(r.imageChannel) //close(r.audioChannel) + if r.rec != nil { + if err := r.rec.Stop(); err != nil { + log.Printf("record close err, %v", err) + } + } } func (r *Room) isRoomExisted() bool { @@ -413,3 +436,10 @@ func (r *Room) IsRunningSessions() bool { return false } + +func (r *Room) ToggleRecording(active bool, user string) { + if r.rec == nil { + return + } + r.rec.Set(active, user) +} diff --git a/pkg/worker/room/room_test.go b/pkg/worker/room/room_test.go index 3264e2c6..a004b840 100644 --- a/pkg/worker/room/room_test.go +++ b/pkg/worker/room/room_test.go @@ -91,7 +91,7 @@ func TestRoom(t *testing.T) { vCodec: test.vCodec, }) t.Logf("The game [%v] has been loaded", test.game.Name) - waitNFrames(test.frames, room.vPipe.Output) + waitNOutFrames(test.frames, room.vPipe.Output) room.Close() } // hack: wait room destruction @@ -123,7 +123,7 @@ func TestRoomWithGL(t *testing.T) { vCodec: test.vCodec, }) t.Logf("The game [%v] has been loaded", test.game.Name) - waitNFrames(test.frames, room.vPipe.Output) + waitNOutFrames(test.frames, room.vPipe.Output) room.Close() } // hack: wait room destruction @@ -162,12 +162,11 @@ func TestAllEmulatorRooms(t *testing.T) { autoGlContext: autoGlContext, }) t.Logf("The game [%v] has been loaded", test.game.Name) - waitNFrames(test.frames, room.vPipe.Output) + frame := waitNFrames(test.frames, room.vPipe.Input) if renderFrames { - img := room.director.GetViewport().(*image.RGBA) - tag := fmt.Sprintf("%v-%v-0x%08x", runtime.GOOS, test.game.Type, crc32.Checksum(img.Pix, crc32q)) - dumpCanvas(img, tag, fmt.Sprintf("%v [%v]", tag, test.frames), outputPath) + tag := fmt.Sprintf("%v-%v-0x%08x", runtime.GOOS, test.game.Type, crc32.Checksum(frame.Image.Pix, crc32q)) + dumpCanvas(frame.Image, tag, fmt.Sprintf("%v [%v]", tag, test.frames), outputPath) } room.Close() @@ -176,15 +175,6 @@ func TestAllEmulatorRooms(t *testing.T) { } } -// enforce image.RGBA to remove alpha channel when encoding PNGs -type opaqueRGBA struct { - *image.RGBA -} - -func (*opaqueRGBA) Opaque() bool { - return true -} - func dumpCanvas(f *image.RGBA, name string, caption string, path string) { frame := *f @@ -213,7 +203,7 @@ func dumpCanvas(f *image.RGBA, name string, caption string, path string) { } if f, err := os.Create(filepath.Join(outPath, name+".png")); err == nil { - if err = png.Encode(f, &opaqueRGBA{&frame}); err != nil { + if err = png.Encode(f, &frame); err != nil { log.Printf("Couldn't encode the image, %v", err) } _ = f.Close() @@ -239,7 +229,7 @@ func getRoomMock(cfg roomMockConfig) roomMock { conf.Encoder.Video.Codec = string(cfg.vCodec) cloudStore, _ := storage.NewNoopCloudStorage() - room := NewRoom(cfg.roomName, cfg.game, cloudStore, conf) + room := NewRoom(cfg.roomName, cfg.game, "", false, cloudStore, conf) // loop-wait the room initialization var init sync.WaitGroup @@ -285,10 +275,30 @@ func getRootPath() string { return p + string(filepath.Separator) } -func waitNFrames(n int, ch chan encoder.OutFrame) { +func waitNFrames(n int, ch chan encoder.InFrame) encoder.InFrame { var frames sync.WaitGroup frames.Add(n) + var last encoder.InFrame + done := false + go func() { + for f := range ch { + last = f + if done { + break + } + frames.Done() + } + }() + + frames.Wait() + done = true + return last +} + +func waitNOutFrames(n int, ch chan encoder.OutFrame) { + var frames sync.WaitGroup + frames.Add(n) done := false go func() { for range ch { @@ -298,7 +308,6 @@ func waitNFrames(n int, ch chan encoder.OutFrame) { frames.Done() } }() - frames.Wait() done = true } @@ -317,7 +326,7 @@ func benchmarkRoom(rom games.GameMetadata, codec codec.VideoCodec, frames int, s game: rom, vCodec: codec, }) - waitNFrames(frames, room.vPipe.Output) + waitNOutFrames(frames, room.vPipe.Output) room.Close() } } diff --git a/pkg/worker/routes.go b/pkg/worker/routes.go index 07c157a6..2c0565ac 100644 --- a/pkg/worker/routes.go +++ b/pkg/worker/routes.go @@ -19,4 +19,5 @@ func (h *Handler) routes() { h.oClient.Receive(api.GameLoad, h.handleGameLoad()) h.oClient.Receive(api.GamePlayerSelect, h.handleGamePlayerSelect()) h.oClient.Receive(api.GameMultitap, h.handleGameMultitap()) + h.oClient.Receive(api.GameRecording, h.handleGameRecording()) } diff --git a/web/css/main.css b/web/css/main.css index 759289ea..b920f257 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -13,7 +13,7 @@ body { overflow: hidden; display: block; width: 556px; - height: 278px; + height: 286px; /*-webkit-box-shadow: inset 0px 0px 2px 2px rgba(219, 222, 222, 1);*/ /*-moz-box-shadow: inset 0px 0px 2px 2px rgba(219, 222, 222, 1);*/ @@ -88,7 +88,7 @@ body { #guide-txt { color: #bababa; font-size: 8px; - top: 263px; + top: 269px; left: 30px; width: 1000px; position: absolute; @@ -180,7 +180,7 @@ body { width: 256px; height: 240px; position: absolute; - top: 20px; + top: 23px; left: 150px; overflow: hidden; background-color: #333; @@ -267,6 +267,61 @@ body { background-image: url('/static/img/ui/bt QUIT.png'); } +#btn-rec { + width: 36px; + top: 2px; + left: 373px; + height: 9px; + + background-image: url('/static/img/ui/bt REC.png'); +} + +.record {} + +.record-user { + position: absolute; + padding-left: 6px; + font-size: 12px; + width: 109px; + top: 4px; + left: 262px; + color: #888888; + -moz-border-radius: 6px; + -webkit-border-radius: 6px; + border-radius: 6px; + outline: none; + border-top-style: hidden; + border-right-style: hidden; + border-left-style: hidden; + border-bottom-style: hidden; + background-image: url(/static/img/ui/FrameTEXT.png); + background-size: cover; +} + +.record:before { + position: absolute; + content: ''; + background-color: #FF0000; + border-radius: 50%; + width: 5px; + height: 5px; + pointer-events: none; + left: 4px; + top: 7px; +} + + +.record.blink:before { + animation: blinker 1s linear infinite; +} + +@keyframes blinker { + 50% { + opacity: 0; + } +} + + #btn-select { width: 46px; top: 100px; diff --git a/web/img/ui/bt REC.png b/web/img/ui/bt REC.png new file mode 100644 index 00000000..8d66855c Binary files /dev/null and b/web/img/ui/bt REC.png differ diff --git a/web/index.html b/web/index.html index 5df32206..cd668d80 100644 --- a/web/index.html +++ b/web/index.html @@ -87,6 +87,11 @@
+ {{if .Recording.Enabled}} + + + {{end}}