diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..63bb607 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +* +!*.go +!*.toml +!*.lock diff --git a/.gitignore b/.gitignore index f1c181e..8bdc449 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +/vendor diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5967070 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:alpine as build-env +LABEL maintainer="daniel@widerin.net" + +ENV GOBIN /go/bin + +RUN mkdir /go/src/app && \ + apk --no-cache add git curl && \ + curl -sSL https://raw.githubusercontent.com/golang/dep/master/install.sh | sh + +ADD . /go/src/app +WORKDIR /go/src/app + +RUN dep ensure && \ + go build -o /vnc-recorder . + +FROM jrottenberg/ffmpeg:4.0-alpine +COPY --from=build-env /vnc-recorder / +ENTRYPOINT ["/vnc-recorder"] +CMD [""] diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..5a40149 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,66 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "reusable-encoders" + digest = "1:c9c2a078726605536f2c71e7a282e9f267dac31ac27379d63fd91bc6215fba84" + name = "github.com/amitbet/vnc2video" + packages = [ + ".", + "encoders", + ] + pruneopts = "UT" + revision = "22e16ea7e65b4c8682938bed11aab7576c22533f" + source = "https://github.com/saily/vnc2video.git" + +[[projects]] + branch = "master" + digest = "1:7644c593fbd72dfeda547b10e0aba09fbce3c53edd45cb9e4b287184ca82d8a4" + name = "github.com/icza/mjpeg" + packages = ["."] + pruneopts = "UT" + revision = "85dfbe473743c5a48253effd11423ae2d82ac15b" + +[[projects]] + digest = "1:31e761d97c76151dde79e9d28964a812c46efc5baee4085b86f68f0c654450de" + name = "github.com/konsorten/go-windows-terminal-sequences" + packages = ["."] + pruneopts = "UT" + revision = "f55edac94c9bbba5d6182a4be46d86a2c9b5b50e" + version = "v1.0.2" + +[[projects]] + digest = "1:04457f9f6f3ffc5fea48e71d62f2ca256637dee0a04d710288e27e05c8b41976" + name = "github.com/sirupsen/logrus" + packages = ["."] + pruneopts = "UT" + revision = "839c75faf7f98a33d445d181f3018b5c3409a45e" + version = "v1.4.2" + +[[projects]] + digest = "1:b24d38b282bacf9791408a080f606370efa3d364e4b5fd9ba0f7b87786d3b679" + name = "github.com/urfave/cli" + packages = ["."] + pruneopts = "UT" + revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1" + version = "v1.20.0" + +[[projects]] + branch = "master" + digest = "1:8f5108406bc43c7669b0d67d282e40d05c9f268615fcaf8c1f0f76965aa3f09f" + name = "golang.org/x/sys" + packages = ["unix"] + pruneopts = "UT" + revision = "4c4f7f33c9ed00de01c4c741d2177abfcfe19307" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = [ + "github.com/amitbet/vnc2video", + "github.com/amitbet/vnc2video/encoders", + "github.com/sirupsen/logrus", + "github.com/urfave/cli", + ] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..5b859e9 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,43 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.4.2" + +[[constraint]] + name = "github.com/urfave/cli" + version = "1.20.0" + +[[constraint]] + name = "github.com/amitbet/vnc2video" + source = "https://github.com/saily/vnc2video.git" + branch = "reusable-encoders" + +[prune] + go-tests = true + unused-packages = true diff --git a/README.md b/README.md index a43af58..f241fa8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,59 @@ -# vnc-recorder -Record VNC screens to mp4 video using ffmpeg +# VNC recorder + +> this is wip, don't use in production! + +Record [VNC] screens to mp4 video using [ffmpeg]. Thanks to +[amitbet for providing his vnc2video](https://github.com/amitbet/vnc2video) +library which made this wrapper possible. + +## Use + + docker run -it saily/vnc-recorder --help + + + NAME: + vnc-recorder - Connect to a vnc server and record the screen to a video. + + USAGE: + vnc-recorder [global options] command [command options] [arguments...] + + VERSION: + 1.0 + + AUTHOR: + Daniel Widerin + + COMMANDS: + help, h Shows a list of commands or help for one command + + GLOBAL OPTIONS: + --ffmpeg value Which ffmpeg executable to use (default: "ffmpeg") [$VR_FFMPEG_BIN] + --host value VNC host (default: "localhost") [$VR_VNC_HOST] + --port value VNC port (default: 5900) [$VR_VNC_PORT] + --password value Password to connect to the VNC host (default: "secret") [$VR_VNC_PASSWORD] + --framerate value Framerate to record (default: 30) [$VR_FRAMERATE] + --outfile value Output file to record to. (default: "output.mp4") [$VR_OUTFILE] + --help, -h show help + --version, -v print the version + +**Note:** If you run vnc-recorder from your command line and don't use [docker] +you might want to customize the `--ffmpeg` flag to point to an existing +[ffmpeg] installation. + + +## Build + + docker build -t yourbuild . + docker run -it yourbuild --help + + +## TODO + +- [ ] Add tests! +- [ ] Add more encoder options +- [ ] Get some patches merged for our dependencies + + +[ffmpeg]: https://ffmpeg.org +[docker]: https://www.docker.com +[vnc]: https://en.wikipedia.org/wiki/Virtual_Network_Computing diff --git a/main.go b/main.go new file mode 100644 index 0000000..23603e6 --- /dev/null +++ b/main.go @@ -0,0 +1,228 @@ +package main + +import ( + "context" + "fmt" + log "github.com/sirupsen/logrus" + "github.com/urfave/cli" + "net" + "os" + "os/exec" + "os/signal" + "path" + "strconv" + "syscall" + "time" + vnc "github.com/amitbet/vnc2video" + "github.com/amitbet/vnc2video/encoders" +) + +func init() { + log.SetOutput(os.Stdout) + log.SetLevel(log.InfoLevel) +} + +func main() { + app := cli.NewApp() + app.Name = path.Base(os.Args[0]) + app.Usage = "Connect to a vnc server and record the screen to a video." + app.Version = "1.0" + app.Authors = []cli.Author{ + { + Name: "Daniel Widerin", + Email: "daniel@widerin.net", + }, + } + app.Action = recorder + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "ffmpeg", + Value: "ffmpeg", + Usage: "Which ffmpeg executable to use", + EnvVar: "VR_FFMPEG_BIN", + }, + cli.StringFlag{ + Name: "host", + Value: "localhost", + Usage: "VNC host", + EnvVar: "VR_VNC_HOST", + }, + cli.IntFlag{ + Name: "port", + Value: 5900, + Usage: "VNC port", + EnvVar: "VR_VNC_PORT", + }, + cli.StringFlag{ + Name: "password", + Value: "secret", + Usage: "Password to connect to the VNC host", + EnvVar: "VR_VNC_PASSWORD", + }, + cli.IntFlag{ + Name: "framerate", + Value: 30, + Usage: "Framerate to record", + EnvVar: "VR_FRAMERATE", + }, + cli.StringFlag{ + Name: "outfile", + Value: "output.mp4", + Usage: "Output file to record to.", + EnvVar: "VR_OUTFILE", + }, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +func recorder(c *cli.Context) error { + address := fmt.Sprintf("%s:%d", c.String("host"), c.Int("port")) + nc, err := net.DialTimeout("tcp", address, 5*time.Second) + if err != nil { + log.Fatalf("Error connecting to VNC host. %v", err) + return err + } + defer nc.Close() + + log.Infof("Connected to %s", address) + + // Negotiate connection with the server. + cchServer := make(chan vnc.ServerMessage) + cchClient := make(chan vnc.ClientMessage) + errorCh := make(chan error) + + ccfg := &vnc.ClientConfig{ + SecurityHandlers: []vnc.SecurityHandler{ + // &vnc.ClientAuthATEN{Username: []byte(os.Args[2]), Password: []byte(os.Args[3])} + &vnc.ClientAuthVNC{Password: []byte(c.String("password"))}, + &vnc.ClientAuthNone{}, + }, + DrawCursor: true, + PixelFormat: vnc.PixelFormat32bit, + ClientMessageCh: cchClient, + ServerMessageCh: cchServer, + Messages: vnc.DefaultServerMessages, + Encodings: []vnc.Encoding{ + &vnc.RawEncoding{}, + &vnc.TightEncoding{}, + &vnc.HextileEncoding{}, + &vnc.ZRLEEncoding{}, + &vnc.CopyRectEncoding{}, + &vnc.CursorPseudoEncoding{}, + &vnc.CursorPosPseudoEncoding{}, + &vnc.ZLibEncoding{}, + &vnc.RREEncoding{}, + }, + ErrorCh: errorCh, + } + + cc, err := vnc.Connect(context.Background(), nc, ccfg) + defer cc.Close() + + screenImage := cc.Canvas + if err != nil { + log.Fatalf("Error negotiating connection to VNC host. %v", err) + return err + } + + ffmpeg_path, err := exec.LookPath(c.String("ffmpeg")) + if err != nil { + panic(err) + } + log.Infof("Using %s for encoding", ffmpeg_path) + vcodec := &encoders.Encoder{ + BinPath: ffmpeg_path, + Framerate: c.Int("framerate"), + Cmd: exec.Command(ffmpeg_path, + "-f", "image2pipe", + "-vcodec", "ppm", + "-r", strconv.Itoa(c.Int("framerate")), + "-an", // no audio + "-y", + "-i", "-", + "-vcodec", "libx264", //"libvpx",//"libvpx-vp9"//"libx264" + "-preset", "fast", + "-crf", "24", + c.String("outfile"), + ), + } + + go vcodec.Run() + + for _, enc := range ccfg.Encodings { + myRenderer, ok := enc.(vnc.Renderer) + + if ok { + myRenderer.SetTargetImage(screenImage) + } + } + + cc.SetEncodings([]vnc.EncodingType{ + vnc.EncCursorPseudo, + vnc.EncPointerPosPseudo, + vnc.EncCopyRect, + vnc.EncTight, + vnc.EncZRLE, + vnc.EncHextile, + vnc.EncZlib, + vnc.EncRRE, + }) + + go func() { + for { + timeStart := time.Now() + + vcodec.Encode(screenImage.Image) + + timeTarget := timeStart.Add((1000 / time.Duration(vcodec.Framerate)) * time.Millisecond) + timeLeft := timeTarget.Sub(time.Now()) + if timeLeft > 0 { + time.Sleep(timeLeft) + } + } + }() + + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, + syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGQUIT, ) + frameBufferReq := 0 + timeStart := time.Now() + + for { + select { + case err := <-errorCh: + panic(err) + case msg := <-cchClient: + log.Debugf("Received client message type:%v msg:%v\n", msg.Type(), msg) + case msg := <-cchServer: + if msg.Type() == vnc.FramebufferUpdateMsgType { + secsPassed := time.Now().Sub(timeStart).Seconds() + frameBufferReq++ + reqPerSec := float64(frameBufferReq) / secsPassed + //counter++ + //jpeg.Encode(out, screenImage, nil) + ///vcodec.Encode(screenImage) + log.Debugf("reqs=%d, seconds=%f, Req Per second= %f", frameBufferReq, secsPassed, reqPerSec) + + reqMsg := vnc.FramebufferUpdateRequest{Inc: 1, X: 0, Y: 0, Width: cc.Width(), Height: cc.Height()} + //cc.ResetAllEncodings() + reqMsg.Write(cc) + } + case signal := <-sigc: + if signal != nil { + log.Info(signal, " received, exit.") + vcodec.Close() + // give some time to write the file + time.Sleep(time.Second * 5) + return nil + } + } + } + return nil +}