diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..170579c --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +/vendor + +# macOS +.DS_Store + +# Output +*.mp4 \ No newline at end of file diff --git a/OWNERS b/OWNERS new file mode 100644 index 0000000..596976a --- /dev/null +++ b/OWNERS @@ -0,0 +1,6 @@ +approvers: +- bmanzari +- mhanss +reviewers: +- bmanzari +- mhanss \ No newline at end of file diff --git a/oink/vnc-recorder/Dockerfile b/oink/vnc-recorder/Dockerfile new file mode 100644 index 0000000..751054c --- /dev/null +++ b/oink/vnc-recorder/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:alpine as build-env +LABEL maintainer="bmanzari@redhat.com" + +ENV GO111MODULE=on +RUN apk --no-cache add git + +COPY . /app +WORKDIR /app + +RUN ls -lahR && go mod download && go build -o /vnc-recorder + +FROM linuxserver/ffmpeg:version-5.1.2-cli +COPY --from=build-env /vnc-recorder / +ENTRYPOINT ["sh"] diff --git a/oink/vnc-recorder/LICENSE b/oink/vnc-recorder/LICENSE new file mode 100644 index 0000000..881fa8a --- /dev/null +++ b/oink/vnc-recorder/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Daniel Widerin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/oink/vnc-recorder/README.md b/oink/vnc-recorder/README.md new file mode 100644 index 0000000..2fc65e4 --- /dev/null +++ b/oink/vnc-recorder/README.md @@ -0,0 +1,60 @@ +# 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 widerin/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: + 0.3.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] + --crf value Constant Rate Factor (CRF) to record with (default: 35) [$VR_CRF] + --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/oink/vnc-recorder/encoder.go b/oink/vnc-recorder/encoder.go new file mode 100644 index 0000000..16ea3e3 --- /dev/null +++ b/oink/vnc-recorder/encoder.go @@ -0,0 +1,181 @@ +package main + +/** +* XXX: Ugly workaround for https://github.com/amitbet/vnc2video/issues/10. I've copied the file and build a +* X264ImageCustomEncoder. Once this is merged, we can drop the encoder.go file again. +*/ + +import ( + "errors" + "fmt" + vnc "github.com/amitbet/vnc2video" + "github.com/amitbet/vnc2video/encoders" + "github.com/sirupsen/logrus" + "image" + "image/color" + "io" + "os" + "os/exec" + "strconv" +) + +func encodePPMforRGBA(w io.Writer, img *image.RGBA) error { + maxvalue := 255 + size := img.Bounds() + // write ppm header + _, err := fmt.Fprintf(w, "P6\n%d %d\n%d\n", size.Dx(), size.Dy(), maxvalue) + if err != nil { + return err + } + + if convImage == nil { + convImage = make([]uint8, size.Dy()*size.Dx()*3) + } + + rowCount := 0 + for i := 0; i < len(img.Pix); i++ { + if (i % 4) != 3 { + convImage[rowCount] = img.Pix[i] + rowCount++ + } + } + + if _, err := w.Write(convImage); err != nil { + return err + } + + return nil +} + +func encodePPMGeneric(w io.Writer, img image.Image) error { + maxvalue := 255 + size := img.Bounds() + // write ppm header + _, err := fmt.Fprintf(w, "P6\n%d %d\n%d\n", size.Dx(), size.Dy(), maxvalue) + if err != nil { + return err + } + + // write the bitmap + colModel := color.RGBAModel + row := make([]uint8, size.Dx()*3) + for y := size.Min.Y; y < size.Max.Y; y++ { + i := 0 + for x := size.Min.X; x < size.Max.X; x++ { + color := colModel.Convert(img.At(x, y)).(color.RGBA) + row[i] = color.R + row[i+1] = color.G + row[i+2] = color.B + i += 3 + } + if _, err := w.Write(row); err != nil { + return err + } + } + return nil +} + +var convImage []uint8 + +func encodePPM(w io.Writer, img image.Image) error { + if img == nil { + return errors.New("nil image") + } + img1, isRGBImage := img.(*vnc.RGBImage) + img2, isRGBA := img.(*image.RGBA) + if isRGBImage { + return encodePPMforRGBImage(w, img1) + } else if isRGBA { + return encodePPMforRGBA(w, img2) + } + return encodePPMGeneric(w, img) +} +func encodePPMforRGBImage(w io.Writer, img *vnc.RGBImage) error { + maxvalue := 255 + size := img.Bounds() + // write ppm header + _, err := fmt.Fprintf(w, "P6\n%d %d\n%d\n", size.Dx(), size.Dy(), maxvalue) + if err != nil { + return err + } + + if _, err := w.Write(img.Pix); err != nil { + return err + } + return nil +} + +type X264ImageCustomEncoder struct { + encoders.X264ImageEncoder + FFMpegBinPath string + cmd *exec.Cmd + input io.WriteCloser + closed bool + Framerate int + ConstantRateFactor int +} + +func (enc *X264ImageCustomEncoder) Init(videoFileName string) { + if enc.Framerate == 0 { + enc.Framerate = 12 + } + cmd := exec.Command(enc.FFMpegBinPath, + "-f", "image2pipe", + "-vcodec", "ppm", + "-r", strconv.Itoa(enc.Framerate), + "-an", // no audio + "-y", + "-i", "-", + "-vcodec", "libx264", + "-preset", "fast", + "-g", "250", + "-crf", strconv.Itoa(enc.ConstantRateFactor), + "-pix_fmt", "yuv420p10", + "-s", "1280x720", + videoFileName, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + encInput, err := cmd.StdinPipe() + enc.input = encInput + if err != nil { + logrus.WithError(err).Error("can't get ffmpeg input pipe.") + } + enc.cmd = cmd +} +func (enc *X264ImageCustomEncoder) Run(videoFileName string) error { + if _, err := os.Stat(enc.FFMpegBinPath); os.IsNotExist(err) { + return err + } + + enc.Init(videoFileName) + logrus.Infof("launching binary: %v", enc.cmd) + err := enc.cmd.Run() + if err != nil { + logrus.WithError(err).Errorf("error while launching ffmpeg: %v", enc.cmd.Args) + return err + } + return nil +} +func (enc *X264ImageCustomEncoder) Encode(img image.Image) { + if enc.input == nil || enc.closed { + return + } + + err := encodePPM(enc.input, img) + if err != nil { + logrus.WithError(err).Error("error while encoding image.") + } +} + +func (enc *X264ImageCustomEncoder) Close() { + if enc.closed { + return + } + enc.closed = true + err := enc.input.Close() + if err != nil { + logrus.WithError(err).Error("could not close input.") + } + +} diff --git a/oink/vnc-recorder/go.mod b/oink/vnc-recorder/go.mod new file mode 100644 index 0000000..2ec1227 --- /dev/null +++ b/oink/vnc-recorder/go.mod @@ -0,0 +1,12 @@ +module github.com/saily/vnc-recorder + +go 1.13 + +require ( + github.com/amitbet/vnc2video v0.0.0-20190616012314-9d50b9dab1d9 + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/icza/mjpeg v0.0.0-20201020132628-7c1e1838a393 // indirect + github.com/sirupsen/logrus v1.7.0 + github.com/urfave/cli/v2 v2.2.0 + golang.org/x/sys v0.0.0-20201024132449-ef9fd89ba245 // indirect +) diff --git a/oink/vnc-recorder/go.sum b/oink/vnc-recorder/go.sum new file mode 100644 index 0000000..2d6dc86 --- /dev/null +++ b/oink/vnc-recorder/go.sum @@ -0,0 +1,30 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/amitbet/vnc2video v0.0.0-20190616012314-9d50b9dab1d9 h1:vYU4bpShNx2qwirTqqnGZ2wYicExu4q2lQNP4u7xafM= +github.com/amitbet/vnc2video v0.0.0-20190616012314-9d50b9dab1d9/go.mod h1:zc4FYPGD82PqOVHtzkQiz7zHHxumVoVQ4Ij0RzvOIJQ= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/icza/mjpeg v0.0.0-20170217094447-85dfbe473743 h1:u5kZEGcjrCRAS99gyW/wptM3KjGYkVv80WKexNvxBuA= +github.com/icza/mjpeg v0.0.0-20170217094447-85dfbe473743/go.mod h1:Eja3x31oRrEOzl6ihhsxY23gXaTYWLP3Gwj5nMAJ7m0= +github.com/icza/mjpeg v0.0.0-20201020132628-7c1e1838a393 h1:x6a1h0jKsDMgUqyy0RO2dXOciHY+QWqcZ2Tvb5LStxA= +github.com/icza/mjpeg v0.0.0-20201020132628-7c1e1838a393/go.mod h1:Eja3x31oRrEOzl6ihhsxY23gXaTYWLP3Gwj5nMAJ7m0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= +github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201024132449-ef9fd89ba245 h1:GGQcbpn3KsnwsPvzzr1mDsTriyvGNKi9eo2lG3N8YdM= +golang.org/x/sys v0.0.0-20201024132449-ef9fd89ba245/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/oink/vnc-recorder/main.go b/oink/vnc-recorder/main.go new file mode 100644 index 0000000..9b895cc --- /dev/null +++ b/oink/vnc-recorder/main.go @@ -0,0 +1,235 @@ +package main + +import ( + "context" + "fmt" + vnc "github.com/amitbet/vnc2video" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" + "net" + + "os" + "os/exec" + "os/signal" + "path" + "syscall" + "time" +) + +func main() { + app := &cli.App{ + Name: path.Base(os.Args[0]), + Usage: "Connect to a vnc server and record the screen to a video.", + Version: "0.3.0", + Authors: []*cli.Author{ + &cli.Author{ + Name: "Daniel Widerin", + Email: "daniel@widerin.net", + }, + }, + Action: recorder, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "ffmpeg", + Value: "ffmpeg", + Usage: "Which ffmpeg executable to use", + EnvVars: []string{"VR_FFMPEG_BIN"}, + }, + &cli.StringFlag{ + Name: "host", + Value: "localhost", + Usage: "VNC host", + EnvVars: []string{"VR_VNC_HOST"}, + }, + &cli.IntFlag{ + Name: "port", + Value: 5900, + Usage: "VNC port", + EnvVars: []string{"VR_VNC_PORT"}, + }, + &cli.StringFlag{ + Name: "password", + Value: "secret", + Usage: "Password to connect to the VNC host", + EnvVars: []string{"VR_VNC_PASSWORD"}, + }, + &cli.IntFlag{ + Name: "framerate", + Value: 30, + Usage: "Framerate to record", + EnvVars: []string{"VR_FRAMERATE"}, + }, + &cli.IntFlag{ + Name: "crf", + Value: 35, + Usage: "Constant Rate Factor (CRF) to record with", + EnvVars: []string{"VR_CRF"}, + }, + &cli.StringFlag{ + Name: "outfile", + Value: "output.mp4", + Usage: "Output file to record to.", + EnvVars: []string{"VR_OUTFILE"}, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + logrus.WithError(err).Fatal("recording failed.") + } +} + +func recorder(c *cli.Context) error { + address := fmt.Sprintf("%s:%d", c.String("host"), c.Int("port")) + dialer, err := net.DialTimeout("tcp", address, 5*time.Second) + if err != nil { + logrus.WithError(err).Error("connection to VNC host failed.") + return err + } + defer dialer.Close() + + logrus.WithField("address", address).Info("connection established.") + + // Negotiate connection with the server. + cchServer := make(chan vnc.ServerMessage) + cchClient := make(chan vnc.ClientMessage) + errorCh := make(chan error) + + var secHandlers []vnc.SecurityHandler + if c.String("password") == "" { + secHandlers = []vnc.SecurityHandler{ + &vnc.ClientAuthNone{}, + } + } else { + secHandlers = []vnc.SecurityHandler{ + &vnc.ClientAuthVNC{Password: []byte(c.String("password"))}, + } + } + + ccflags := &vnc.ClientConfig{ + SecurityHandlers: secHandlers, + 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, + } + + vncConnection, err := vnc.Connect(context.Background(), dialer, ccflags) + defer vncConnection.Close() + if err != nil { + logrus.WithError(err).Error("connection negotiation to VNC host failed.") + return err + } + screenImage := vncConnection.Canvas + + ffmpegPath, err := exec.LookPath(c.String("ffmpeg")) + if err != nil { + logrus.WithError(err).Error("ffmpeg binary not found.") + return err + } + logrus.WithField("ffmpeg", ffmpegPath).Info("ffmpeg binary for recording found") + + vcodec := &X264ImageCustomEncoder{ + FFMpegBinPath: ffmpegPath, + Framerate: c.Int("framerate"), + ConstantRateFactor: c.Int("crf"), + } + + //goland:noinspection GoUnhandledErrorResult + go vcodec.Run(c.String("outfile")) + + for _, enc := range ccflags.Encodings { + myRenderer, ok := enc.(vnc.Renderer) + + if ok { + myRenderer.SetTargetImage(screenImage) + } + } + + vncConnection.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) + } + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, + os.Interrupt, + syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGQUIT, + syscall.SIGKILL, + ) + + frameBufferReq := 0 + timeStart := time.Now() + + for { + select { + case err := <-errorCh: + panic(err) + case msg := <-cchClient: + logrus.WithFields(logrus.Fields{ + "messageType": msg.Type(), + "message": msg, + }).Debug("client message received.") + + case msg := <-cchServer: + if msg.Type() == vnc.FramebufferUpdateMsgType { + secsPassed := time.Now().Sub(timeStart).Seconds() + frameBufferReq++ + reqPerSec := float64(frameBufferReq) / secsPassed + logrus.WithFields(logrus.Fields{ + "reqs": frameBufferReq, + "seconds": secsPassed, + "Req Per second": reqPerSec, + }).Debug("framebuffer update") + + reqMsg := vnc.FramebufferUpdateRequest{Inc: 1, X: 0, Y: 0, Width: vncConnection.Width(), Height: vncConnection.Height()} + reqMsg.Write(vncConnection) + } + case signal := <-sigCh: + if signal != nil { + logrus.WithField("signal", signal).Info("signal received.") + vcodec.Close() + // give some time to write the file + time.Sleep(time.Second * 1) + os.Exit(0) + } + } + } + return nil +}