mirror of
https://github.com/giongto35/cloud-game.git
synced 2026-08-02 14:52:26 +00:00
Game recording support (#356)
This feature adds the ability to record game sessions as raw a/v media files.
This commit is contained in:
parent
17bec2e987
commit
1271aa8438
40 changed files with 1351 additions and 173 deletions
|
|
@ -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)
|
||||
|
|
|
|||
28
go.mod
28
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
|
||||
)
|
||||
|
|
|
|||
69
go.sum
69
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=
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type Config struct {
|
|||
}
|
||||
Emulator emulator.Emulator
|
||||
Environment shared.Environment
|
||||
Recording shared.Recording
|
||||
Webrtc webrtcConfig.Webrtc
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
pkg/recorder/draw.go
Normal file
39
pkg/recorder/draw.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
121
pkg/recorder/ffmpegstream.go
Normal file
121
pkg/recorder/ffmpegstream.go
Normal file
|
|
@ -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 }
|
||||
74
pkg/recorder/file.go
Normal file
74
pkg/recorder/file.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
17
pkg/recorder/options.go
Normal file
17
pkg/recorder/options.go
Normal file
|
|
@ -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
|
||||
}
|
||||
198
pkg/recorder/recorder.go
Normal file
198
pkg/recorder/recorder.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
156
pkg/recorder/recorder_test.go
Normal file
156
pkg/recorder/recorder_test.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
115
pkg/recorder/wavstream.go
Normal file
115
pkg/recorder/wavstream.go
Normal file
|
|
@ -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 }
|
||||
62
pkg/recorder/zipfile.go
Normal file
62
pkg/recorder/zipfile.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
BIN
web/img/ui/bt REC.png
Normal file
BIN
web/img/ui/bt REC.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
|
|
@ -87,6 +87,11 @@
|
|||
<div id="help-overlay-detail"></div>
|
||||
</div>
|
||||
<div id="btn-help" class="btn unselectable" value="help"></div>
|
||||
{{if .Recording.Enabled}}
|
||||
<input id="user-name" type="text" data-lpignore="true" spellcheck="false" placeholder="user"
|
||||
class="record-user unselectable">
|
||||
<div id="btn-rec" class="btn unselectable" value="rec"></div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div id="app-settings" class="modal-window">
|
||||
|
|
@ -130,6 +135,7 @@
|
|||
<script src="/static/js/network/ajax.js?v=3"></script>
|
||||
<script src="/static/js/network/socket.js?v=4"></script>
|
||||
<script src="/static/js/network/rtcp.js?v=3"></script>
|
||||
<script src="/static/js/recording.js?v=1"></script>
|
||||
<script src="/static/js/stats/stats.js?v=2"></script>
|
||||
<script src="/static/js/controller.js?v=6"></script>
|
||||
<script src="/static/js/input/keyboard.js?v=5"></script>
|
||||
|
|
@ -138,13 +144,17 @@
|
|||
|
||||
<script src="/static/js/init.js?v=5"></script>
|
||||
|
||||
{{if .Inject}}
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{.Gtag}}"></script>
|
||||
{{if .Analytics.Inject}}
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{.Analytics.Gtag}}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
|
||||
gtag('js', new Date());
|
||||
gtag('config', '{{.Gtag}}');
|
||||
gtag('config', '{{.Analytics.Gtag}}');
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,13 @@
|
|||
// currently it's a game with the index 1
|
||||
// on the server this game is ignored and the actual game will be extracted from the share link
|
||||
// so there's no point in doing this and this' really confusing
|
||||
socket.startGame(gameList.getCurrentGame(), env.isMobileDevice(), room.getId(), +playerIndex.value - 1);
|
||||
socket.startGame(
|
||||
gameList.getCurrentGame(),
|
||||
env.isMobileDevice(),
|
||||
room.getId(),
|
||||
recording.isActive(),
|
||||
recording.getUser(),
|
||||
+playerIndex.value - 1);
|
||||
|
||||
// clear menu screen
|
||||
input.poll().disable();
|
||||
|
|
@ -232,11 +238,29 @@
|
|||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
var toggle = document.getElementById('dpad-toggle');
|
||||
let toggle = document.getElementById('dpad-toggle');
|
||||
toggle.checked = !toggle.checked;
|
||||
event.pub(DPAD_TOGGLE, {checked: toggle.checked});
|
||||
};
|
||||
|
||||
const handleRecording = (data) => {
|
||||
const {recording, userName} = data;
|
||||
socket.toggleRecording(recording, userName);
|
||||
}
|
||||
|
||||
const handleRecordingStatus = (data) => {
|
||||
if (data === 'ok') {
|
||||
message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`, true)
|
||||
if (recording.isActive()) {
|
||||
recording.setIndicator(true)
|
||||
}
|
||||
} else {
|
||||
message.show(`Recording failed ):`)
|
||||
recording.setIndicator(false)
|
||||
}
|
||||
console.log("recording is ", recording.isActive())
|
||||
}
|
||||
|
||||
const app = {
|
||||
state: {
|
||||
eden: {
|
||||
|
|
@ -407,7 +431,7 @@
|
|||
event.sub(GAME_SAVED, () => message.show('Saved'));
|
||||
event.sub(GAME_LOADED, () => message.show('Loaded'));
|
||||
event.sub(GAME_PLAYER_IDX_CHANGE, data => {
|
||||
updatePlayerIndex(data.index);
|
||||
updatePlayerIndex(data.index);
|
||||
});
|
||||
event.sub(GAME_PLAYER_IDX, idx => {
|
||||
if (!isNaN(+idx)) message.show(+idx + 1);
|
||||
|
|
@ -442,6 +466,9 @@
|
|||
});
|
||||
event.sub(AXIS_CHANGED, onAxisChanged);
|
||||
event.sub(CONTROLLER_UPDATED, data => rtcp.input(data));
|
||||
// recording
|
||||
event.sub(RECORDING_TOGGLED, handleRecording);
|
||||
event.sub(RECORDING_STATUS_CHANGED, handleRecordingStatus);
|
||||
|
||||
// initial app state
|
||||
setState(app.state.eden);
|
||||
|
|
|
|||
|
|
@ -96,3 +96,6 @@ const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled';
|
|||
|
||||
const SETTINGS_CHANGED = 'settingsChanged';
|
||||
const SETTINGS_CLOSED = 'settingsClosed';
|
||||
|
||||
const RECORDING_TOGGLED = 'recordingToggle'
|
||||
const RECORDING_STATUS_CHANGED = 'recordingStatusChanged'
|
||||
|
|
|
|||
|
|
@ -38,7 +38,13 @@ const message = (() => {
|
|||
_popup();
|
||||
}
|
||||
|
||||
const show = utils.throttle(_proceed, 1000);
|
||||
const show = (text, force = false) => {
|
||||
if (!force) {
|
||||
utils.throttle(() => _proceed(text), 1000);
|
||||
} else {
|
||||
_proceed(text)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
show: show
|
||||
|
|
|
|||
|
|
@ -52,52 +52,52 @@ const keyboard = (() => {
|
|||
let dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
|
||||
|
||||
function onDpadToggle(checked) {
|
||||
if (dpadMode === checked) {
|
||||
return //error?
|
||||
}
|
||||
if (dpadMode) {
|
||||
dpadMode = false;
|
||||
// reset dpad keys pressed before moving to analog stick mode
|
||||
for (const key in dpadState) {
|
||||
if (dpadState[key] === true) {
|
||||
dpadState[key] = false;
|
||||
event.pub(KEY_RELEASED, {key: key});
|
||||
if (dpadMode === checked) {
|
||||
return //error?
|
||||
}
|
||||
if (dpadMode) {
|
||||
dpadMode = false;
|
||||
// reset dpad keys pressed before moving to analog stick mode
|
||||
for (const key in dpadState) {
|
||||
if (dpadState[key] === true) {
|
||||
dpadState[key] = false;
|
||||
event.pub(KEY_RELEASED, {key: key});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dpadMode = true;
|
||||
// reset analog stick axes before moving to dpad mode
|
||||
value = (dpadState[KEY.RIGHT] === true ? 1 : 0) - (dpadState[KEY.LEFT] === true ? 1 : 0)
|
||||
if (value !== 0) {
|
||||
event.pub(AXIS_CHANGED, {id: 0, value: 0});
|
||||
}
|
||||
value = (dpadState[KEY.DOWN] === true ? 1 : 0) - (dpadState[KEY.UP] === true ? 1 : 0)
|
||||
if (value !== 0) {
|
||||
event.pub(AXIS_CHANGED, {id: 1, value: 0});
|
||||
}
|
||||
dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
|
||||
}
|
||||
} else {
|
||||
dpadMode = true;
|
||||
// reset analog stick axes before moving to dpad mode
|
||||
value = (dpadState[KEY.RIGHT] === true ? 1 : 0) - (dpadState[KEY.LEFT] === true ? 1 : 0)
|
||||
if (value !== 0) {
|
||||
event.pub(AXIS_CHANGED, {id: 0, value: 0});
|
||||
}
|
||||
value = (dpadState[KEY.DOWN] === true ? 1 : 0) - (dpadState[KEY.UP] === true ? 1 : 0)
|
||||
if (value !== 0) {
|
||||
event.pub(AXIS_CHANGED, {id: 1, value: 0});
|
||||
}
|
||||
dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
|
||||
}
|
||||
}
|
||||
|
||||
const onKey = (code, callback, state) => {
|
||||
if (code in keyMap) {
|
||||
key = keyMap[code]
|
||||
if (key in dpadState) {
|
||||
dpadState[key] = state
|
||||
if (dpadMode) {
|
||||
callback(key);
|
||||
key = keyMap[code]
|
||||
if (key in dpadState) {
|
||||
dpadState[key] = state
|
||||
if (dpadMode) {
|
||||
callback(key);
|
||||
} else {
|
||||
if (key === KEY.LEFT || key === KEY.RIGHT) {
|
||||
value = (dpadState[KEY.RIGHT] === true ? 1 : 0) - (dpadState[KEY.LEFT] === true ? 1 : 0)
|
||||
event.pub(AXIS_CHANGED, {id: 0, value: value});
|
||||
} else {
|
||||
value = (dpadState[KEY.DOWN] === true ? 1 : 0) - (dpadState[KEY.UP] === true ? 1 : 0)
|
||||
event.pub(AXIS_CHANGED, {id: 1, value: value});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (key === KEY.LEFT || key == KEY.RIGHT) {
|
||||
value = (dpadState[KEY.RIGHT] === true ? 1 : 0) - (dpadState[KEY.LEFT] === true ? 1 : 0)
|
||||
event.pub(AXIS_CHANGED, {id: 0, value: value});
|
||||
} else {
|
||||
value = (dpadState[KEY.DOWN] === true ? 1 : 0) - (dpadState[KEY.UP] === true ? 1 : 0)
|
||||
event.pub(AXIS_CHANGED, {id: 1, value: value});
|
||||
}
|
||||
callback(key);
|
||||
}
|
||||
} else {
|
||||
callback(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -109,13 +109,21 @@ const keyboard = (() => {
|
|||
const body = document.body;
|
||||
// !to use prevent default as everyone
|
||||
body.addEventListener('keyup', e => {
|
||||
e.stopPropagation();
|
||||
if (isKeysFilteredMode) {
|
||||
onKey(e.code, key => event.pub(KEY_RELEASED, {key: key}));
|
||||
} else {
|
||||
event.pub(KEYBOARD_KEY_PRESSED, {key: e.code});
|
||||
}
|
||||
}, false);
|
||||
body.addEventListener('keydown', e => onKey(e.code, key => event.pub(KEY_PRESSED, {key: key}), true));
|
||||
body.addEventListener('keydown', e => {
|
||||
e.stopPropagation();
|
||||
if (isKeysFilteredMode) {
|
||||
onKey(e.code, key => event.pub(KEY_PRESSED, {key: key}), true)
|
||||
} else {
|
||||
event.pub(KEYBOARD_KEY_PRESSED, {key: e.code});
|
||||
}
|
||||
});
|
||||
|
||||
log.info('[input] keyboard has been initialized');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@ const KEY = (() => {
|
|||
L3: 'l3',
|
||||
R3: 'r3',
|
||||
MULTITAP: 'multitap',
|
||||
REC: 'rec',
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ const socket = (() => {
|
|||
curPacketId = data.packet_id;
|
||||
const addresses = data.data.split(',');
|
||||
event.pub(LATENCY_CHECK_REQUESTED, {packetId: curPacketId, addresses: addresses});
|
||||
break;
|
||||
case 'recording':
|
||||
event.pub(RECORDING_STATUS_CHANGED, data.data);
|
||||
break;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -120,16 +124,21 @@ const socket = (() => {
|
|||
const saveGame = () => send({"id": "save", "data": ""});
|
||||
const loadGame = () => send({"id": "load", "data": ""});
|
||||
const updatePlayerIndex = (idx) => send({"id": "player_index", "data": idx.toString()});
|
||||
const startGame = (gameName, isMobile, roomId, playerIndex) => send({
|
||||
const startGame = (gameName, isMobile, roomId, record, recordUser, playerIndex) => send({
|
||||
"id": "start",
|
||||
"data": JSON.stringify({
|
||||
"game_name": gameName,
|
||||
"record": record,
|
||||
"record_user": recordUser,
|
||||
}),
|
||||
"room_id": roomId != null ? roomId : '',
|
||||
"player_index": playerIndex
|
||||
});
|
||||
const quitGame = (roomId) => send({"id": "quit", "data": "", "room_id": roomId});
|
||||
const toggleMultitap = () => send({"id": "multitap", "data": ""});
|
||||
const toggleRecording = (active = false, userName = '') => send({
|
||||
"id": "recording", "data": JSON.stringify({"active": active, "user": userName,})
|
||||
})
|
||||
|
||||
return {
|
||||
init: init,
|
||||
|
|
@ -142,5 +151,6 @@ const socket = (() => {
|
|||
startGame: startGame,
|
||||
quitGame: quitGame,
|
||||
toggleMultitap: toggleMultitap,
|
||||
toggleRecording: toggleRecording,
|
||||
}
|
||||
})(event, log);
|
||||
|
|
|
|||
77
web/js/recording.js
Normal file
77
web/js/recording.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
const RECORDING_ON = 1;
|
||||
const RECORDING_OFF = 0;
|
||||
const RECORDING_REC = 2;
|
||||
|
||||
/**
|
||||
* Recording module.
|
||||
* @version 1
|
||||
*/
|
||||
const recording = (() => {
|
||||
const userName = document.getElementById('user-name'),
|
||||
recButton = document.getElementById('btn-rec');
|
||||
|
||||
if (!userName || !recButton) {
|
||||
return {}
|
||||
}
|
||||
|
||||
let state = {
|
||||
userName: '',
|
||||
state: RECORDING_OFF,
|
||||
};
|
||||
|
||||
const restoreLastState = () => {
|
||||
const lastState = localStorage.getItem('recording');
|
||||
if (lastState) {
|
||||
const _last = JSON.parse(lastState);
|
||||
if (_last) {
|
||||
state = _last;
|
||||
}
|
||||
}
|
||||
userName.value = state.userName
|
||||
}
|
||||
|
||||
const setRec = (val) => {
|
||||
recButton.classList.toggle('record', val);
|
||||
}
|
||||
const setIndicator = (val) => {
|
||||
recButton.classList.toggle('blink', val);
|
||||
};
|
||||
|
||||
// persistence
|
||||
const saveLastState = () => {
|
||||
const _state = Object.keys(state)
|
||||
.filter(key => !key.startsWith('_'))
|
||||
.reduce((obj, key) => ({...obj, [key]: state[key]}), {});
|
||||
localStorage.setItem('recording', JSON.stringify(_state));
|
||||
}
|
||||
const saveUserName = utils.throttle(() => {
|
||||
state.userName = userName.value;
|
||||
saveLastState();
|
||||
}, 500)
|
||||
|
||||
restoreLastState();
|
||||
setIndicator(false);
|
||||
setRec(state.state === RECORDING_ON)
|
||||
|
||||
// text
|
||||
userName.addEventListener('focus', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE))
|
||||
userName.addEventListener('blur', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}))
|
||||
userName.addEventListener('keyup', ev => {
|
||||
ev.stopPropagation();
|
||||
saveUserName()
|
||||
})
|
||||
|
||||
// button
|
||||
recButton.addEventListener('click', () => {
|
||||
state.state = (state.state + 1) % 2
|
||||
const active = state.state === RECORDING_ON
|
||||
setRec(active)
|
||||
saveLastState()
|
||||
event.pub(RECORDING_TOGGLED, {userName: state.userName, recording: active})
|
||||
})
|
||||
return {
|
||||
isActive: () => state.state > 0,
|
||||
getUser: () => state.userName,
|
||||
setIndicator: setIndicator,
|
||||
}
|
||||
})(document, event, localStorage, utils);
|
||||
Loading…
Add table
Add a link
Reference in a new issue