diff --git a/.gitignore b/.gitignore index 7be8748..afa9647 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -main.go *.gif *.mp4 \ No newline at end of file diff --git a/README.md b/README.md index 6ed369a..4f0cad3 100644 --- a/README.md +++ b/README.md @@ -2,134 +2,194 @@ A simple Video I/O library written in Go. This library relies on [FFmpeg](https://www.ffmpeg.org/), and [FFProbe](https://www.ffmpeg.org/) which must be downloaded before usage. +All frames are encoded and decoded in 8-bit RGB format. + +``` +go get github.com/AlexEidt/Vidio +``` + ## `Video` The `Video` struct stores data about a video file you give it. The code below shows an example of sequentially reading the frames of the given video. ```go -video := NewVideo("input.mp4") -for video.NextFrame() { - // "frame" stores the video frame as a flattened RGB image. +video := vidio.NewVideo("input.mp4") +for video.Read() { + // "frame" stores the video frame as a flattened RGB image frame := video.framebuffer // stored as: RGBRGBRGBRGB... } ``` ```go type Video struct { - filename string - width int - height int - depth int - bitrate int - frames int - duration float64 - fps float64 - codec string - pix_fmt string - framebuffer []byte - pipe *io.ReadCloser - cmd *exec.Cmd + filename string // Video Filename + width int // Width of Frames + height int // Height of Frames + depth int // Depth of Frames + bitrate int // Bitrate for video encoding + frames int // Total number of frames + duration float64 // Duration in seconds + fps float64 // Frames per second + codec string // Codec used to encode video + pix_fmt string // Pixel format video is stored in + framebuffer []byte // Raw frame data + pipe *io.ReadCloser // Stdout pipe for ffmpeg process + cmd *exec.Cmd // ffmpeg command } ``` ## `Camera` -The `Camera` can read from any cameras on the device running Vidio. +The `Camera` can read from any cameras on the device running Vidio. It takes in the stream index. On most machines the webcam device has index 0. ```go type Camera struct { - name string - width int - height int - depth int - fps float64 - codec string - framebuffer []byte - pipe *io.ReadCloser - cmd *exec.Cmd + name string // Camera device name + width int // Camera frame width + height int // Camera frame height + depth int // Camera frame depth + fps float64 // Camera frames per second + codec string // Camera codec + framebuffer []byte // Raw frame data + pipe *io.ReadCloser // Stdout pipe for ffmpeg process streaming webcam + cmd *exec.Cmd // ffmpeg command } ``` ```go -camera := NewCamera(0) // Get Webcam +camera := vidio.NewCamera(0) // Get Webcam defer camera.Close() -// Stream the webcam. -for camera.NextFrame() { - // "frame" stores the video frame as a flattened RGB image. +// Stream the webcam +for camera.Read() { + // "frame" stores the video frame as a flattened RGB image frame := camera.framebuffer // stored as: RGBRGBRGBRGB... } ``` ## `VideoWriter` -The `VideoWriter` is used to write frames to a video file. You first need to create a `Video` struct with all the desired properties of the new video you want to create such as width, height and framerate. +The `VideoWriter` is used to write frames to a video file. The only required parameters are the output file name, the width and height of the frames being written, and an `Options` struct. This contains all the desired properties of the new video you want to create such as width, height and framerate. ```go type Options struct { - width int // Width of Output Frames - height int // Height of Output Frames - bitrate int // Bitrate - loop int // For GIFs only. -1=no loop, 0=loop forever, >0=loop n times - delay int // Delay for Final Frame of GIFs - macro int // macro size for determining how to resize frames for codecs - fps float64 // Frames per second - codec string // Codec for video - in_pix_fmt string // Pixel Format of incoming bytes - out_pix_fmt string // Pixel Format for video being written + bitrate int // Bitrate + loop int // For GIFs only. -1=no loop, 0=loop forever, >0=loop n times + delay int // Delay for Final Frame of GIFs. Default -1 (Use same delay as previous frame) + macro int // macro size for determining how to resize frames for codecs. Default 16 + fps float64 // Frames per second. Default 25 + quality float64 // If bitrate not given, use quality instead. Must be between 0 and 1. 0:best, 1:worst + codec string // Codec for video. Default libx264 } ``` ```go type VideoWriter struct { - filename string - width int - height int - bitrate int - loop int - delay int - macro int - fps float64 - codec string - in_pix_fmt string - out_pix_fmt string - pipe *io.WriteCloser - cmd *exec.Cmd + filename string // Output video filename + width int // Frame width + height int // Frame height + bitrate int // Output video bitrate for encoding + loop int // Number of times for GIF to loop + delay int // Delay of final frame of GIF + macro int // macro size for determining how to resize frames for codecs + fps float64 // Frames per second for output video + quality float64 // Used if bitrate not given + codec string // Codec to encode video with + pipe *io.WriteCloser // Stdout pipe of ffmpeg process + cmd *exec.Cmd // ffmpeg command } ``` ```go w, h, c := 1920, 1080, 3 -options = Options{width: w, height: w, bitrate: 100000} +options = vidio.Options{} // Will fill in defaults if empty -writer := NewVideoWriter("output.mp4", &options) -defer writer.Close() // Make sure to close writer. +writer := vidio.NewVideoWriter("output.mp4", w, h, &options) +defer writer.Close() -frame = make([]byte, w*h*c) // Create Frame as RGB Image and modify. -writer.Write(frame) // Write Frame to video. +frame := make([]byte, w*h*c) // Create Frame as RGB Image and modify +writer.Write(frame) // Write Frame to video +``` + +## Images + +Vidio provides some convenience functions for reading and writing to images using an array of bytes. Currently, only `png` and `jpeg` formats are supported. + +```go +// Read png image +w, h, img := vidio.Read("input.png") + +// w - width of image +// h - height of image +// img - byte array in RGB format. RGBRGBRGBRGB... + +vidio.Write("output.jpg", w, h, img) ``` ## Examples -Copy `input` to `output`. +Copy `input.mp4` to `output.mp4`. ```go -video := NewVideo(input) -options := Options{ - width: video.width, - height: video.height, +video := vidio.NewVideo("input.mp4") +options := vidio.Options{ fps: video.fps, bitrate: video.bitrate } -writer := NewVideoWriter(output, &options) +writer := vidio.NewVideoWriter("output.mp4", video.width, video.height, &options) defer writer.Close() -for video.NextFrame() { +for video.Read() { writer.Write(video.framebuffer) } ``` +Grayscale 1000 frames of webcam stream and store in `output.mp4`. + +```go +webcam := vidio.NewCamera(0) +defer webcam.Close() + +options := vidio.Options{fps: webcam.fps} + +writer := vidio.NewVideoWriter("output.mp4", webcam.width, webcam.height, &options) +defer writer.Close() + +count := 0 +for webcam.Read() { + for i := 0; i < len(webcam.framebuffer); i += 3 { + rgb := webcam.framebuffer[i : i+3] + r, g, b := int(rgb[0]), int(rgb[1]), int(rgb[2]) + gray := uint8((3*r + 4*g + b) / 8) + writer.framebuffer[i] = gray + writer.framebuffer[i+1] = gray + writer.framebuffer[i+2] = gray + } + writer.Write(webcam.framebuffer) + if count > 1000 { + break + } + count++ +} +``` + +Create a gif from a series of `png` files enumerated from 1 to 10 that loops continously with a final frame delay of 1000 centiseconds. + +```go +w, h, _ := vidio.Read("1.png") // Get frame dimensions from first image + +options := vidio.Options{fps: 1, loop: -1, delay: 1000} + +gif := vidio.NewVideoWriter("output.gif", w, h, &options) +defer gif.Close() + +for i := 1; i <= 10; i++ { + _, _, img := vidio.Read(strconv.Itoa(i)+".png") + gif.Write(img) +} +``` + # Acknowledgements * Special thanks to [Zulko](http://zulko.github.io/) and his [blog post](http://zulko.github.io/blog/2013/09/27/read-and-write-video-frames-in-python-using-ffmpeg/) about using FFmpeg to process video. diff --git a/camera.go b/camera.go index 502b9fd..ac04cad 100644 --- a/camera.go +++ b/camera.go @@ -1,4 +1,4 @@ -package main +package vidio import ( "io" @@ -55,7 +55,7 @@ func getDevicesWindows() []string { func getCameraData(device string, camera *Camera) { // Run command to get camera data. - // On windows the webcam will turn on and off again. + // Webcam will turn on and then off in quick succession. cmd := exec.Command( "ffmpeg", "-hide_banner", @@ -88,11 +88,10 @@ func getCameraData(device string, camera *Camera) { } func NewCamera(stream int) *Camera { + // Check if ffmpeg is installed on the users machine. checkExists("ffmpeg") var device string - // If OS is windows, we need to parse the listed devices to find which corresponds to the - // given "stream" index. switch runtime.GOOS { case "linux": device = "/dev/video" + strconv.Itoa(stream) @@ -101,6 +100,8 @@ func NewCamera(stream int) *Camera { device = strconv.Itoa(stream) break case "windows": + // If OS is windows, we need to parse the listed devices to find which corresponds to the + // given "stream" index. devices := getDevicesWindows() if stream >= len(devices) { panic("Could not find devices with index: " + strconv.Itoa(stream)) @@ -123,10 +124,10 @@ func initCamera(camera *Camera) { cmd := exec.Command( "ffmpeg", "-hide_banner", + "-loglevel", "quiet", "-f", webcam(), "-i", camera.name, "-f", "image2pipe", - "-loglevel", "quiet", "-pix_fmt", "rgb24", "-vcodec", "rawvideo", "-", ) @@ -144,7 +145,7 @@ func initCamera(camera *Camera) { camera.framebuffer = make([]byte, camera.width*camera.height*camera.depth) } -func (camera *Camera) NextFrame() bool { +func (camera *Camera) Read() bool { // If cmd is nil, video reading has not been initialized. if camera.cmd == nil { initCamera(camera) diff --git a/go.mod b/go.mod index 0703a6c..9da1150 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/AlexEidt/Video-IO +module github.com/AlexEidt/Vidio -go 1.16 \ No newline at end of file +go 1.16 diff --git a/go.sum b/go.sum index 5c3f45f..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +0,0 @@ -github.com/hybridgroup/mjpeg v0.0.0-20140228234708-4680f319790e/go.mod h1:eagM805MRKrioHYuU7iKLUyFPVKqVV6um5DAvCkUtXs= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -gocv.io/x/gocv v0.28.0 h1:hweRS9Js60YEZPZzjhU5I+0E2ngazquLlO78zwnrFvY= -gocv.io/x/gocv v0.28.0/go.mod h1:oc6FvfYqfBp99p+yOEzs9tbYF9gOrAQSeL/dyIPefJU= diff --git a/imageio.go b/imageio.go new file mode 100644 index 0000000..cc2d45c --- /dev/null +++ b/imageio.go @@ -0,0 +1,69 @@ +package vidio + +import ( + "image" + "os" + "strings" + + "image/color" + "image/jpeg" + _ "image/jpeg" + "image/png" + _ "image/png" +) + +// Reads an image from a file. Currently only supports png and jpeg. +func Read(filename string) (int, int, []byte) { + f, err := os.Open(filename) + if err != nil { + panic(err) + } + defer f.Close() + image, _, err := image.Decode(f) + if err != nil { + panic(err) + } + bounds := image.Bounds().Max + data := make([]byte, bounds.Y*bounds.X*3) + index := 0 + for h := 0; h < bounds.Y; h++ { + for w := 0; w < bounds.X; w++ { + r, g, b, _ := image.At(w, h).RGBA() + r, g, b = r/256, g/256, b/256 + data[index] = byte(r) + index++ + data[index] = byte(g) + index++ + data[index] = byte(b) + index++ + } + } + return bounds.X, bounds.Y, data +} + +// Writes an image to a file. Currently only supports png and jpeg. +func Write(filename string, width, height int, data []byte) { + f, err := os.Create(filename) + if err != nil { + panic(err) + } + defer f.Close() + image := image.NewRGBA(image.Rect(0, 0, width, height)) + index := 0 + for h := 0; h < height; h++ { + for w := 0; w < width; w++ { + r, g, b := data[index], data[index+1], data[index+2] + image.Set(w, h, color.RGBA{r, g, b, 255}) + index += 3 + } + } + if strings.HasSuffix(filename, ".png") { + if err := png.Encode(f, image); err != nil { + panic(err) + } + } else if strings.HasSuffix(filename, ".jpg") || strings.HasSuffix(filename, ".jpeg") { + if err := jpeg.Encode(f, image, nil); err != nil { + panic(err) + } + } +} diff --git a/utils.go b/utils.go index fada2df..a4b7440 100644 --- a/utils.go +++ b/utils.go @@ -1,4 +1,4 @@ -package main +package vidio import ( "errors" diff --git a/videoio.go b/videoio.go index bc05d6e..5dcb74b 100644 --- a/videoio.go +++ b/videoio.go @@ -1,4 +1,4 @@ -package main +package vidio import ( "io" @@ -28,6 +28,7 @@ func NewVideo(filename string) *Video { if !exists(filename) { panic("File: " + filename + " does not exist") } + // Check if ffmpeg and ffprobe are installed on the users machine. checkExists("ffmpeg") checkExists("ffprobe") // Extract video information with ffprobe. @@ -71,8 +72,6 @@ func initVideoStream(video *Video) { // If user exits with Ctrl+C, stop ffmpeg process. video.cleanup() - // map = {1: "gray", 2: "gray8a", 3: "rgb24", 4: "rgba"} - cmd := exec.Command( "ffmpeg", "-i", video.filename, @@ -94,7 +93,7 @@ func initVideoStream(video *Video) { video.framebuffer = make([]byte, video.width*video.height*video.depth) } -func (video *Video) NextFrame() bool { +func (video *Video) Read() bool { // If cmd is nil, video reading has not been initialized. if video.cmd == nil { initVideoStream(video) diff --git a/videowriter.go b/videowriter.go index d44accc..f28e1d2 100644 --- a/videowriter.go +++ b/videowriter.go @@ -1,4 +1,4 @@ -package main +package vidio import ( "fmt" @@ -11,43 +11,35 @@ import ( ) type VideoWriter struct { - filename string - width int - height int - bitrate int - loop int // For GIFs. -1=no loop, 0=loop forever, >0=loop n times - delay int // Delay for Final Frame of GIFs. - macro int - fps float64 - codec string - in_pix_fmt string - out_pix_fmt string - pipe *io.WriteCloser - cmd *exec.Cmd + filename string + width int + height int + bitrate int + loop int // For GIFs. -1=no loop, 0=loop forever, >0=loop n times + delay int // Delay for Final Frame of GIFs. + macro int + fps float64 + quality float64 + codec string + pipe *io.WriteCloser + cmd *exec.Cmd } type Options struct { - width int - height int - bitrate int - loop int - delay int - macro int - fps float64 - codec string - in_pix_fmt string - out_pix_fmt string + bitrate int + loop int + delay int + macro int + fps float64 + quality float64 + codec string } -func NewVideoWriter(filename string, options *Options) *VideoWriter { +func NewVideoWriter(filename string, width, height int, options *Options) *VideoWriter { writer := VideoWriter{filename: filename} - if options.width == 0 || options.height == 0 { - panic("width and height must be greater than 0.") - } else { - writer.width = options.width - writer.height = options.height - } + writer.width = width + writer.height = height // Default Parameter options logic from: // https://github.com/imageio/imageio-ffmpeg/blob/master/imageio_ffmpeg/_io.py#L268 @@ -72,6 +64,12 @@ func NewVideoWriter(filename string, options *Options) *VideoWriter { writer.fps = options.fps } + if options.quality == 0 { + writer.quality = 0.5 + } else { + writer.quality = options.quality + } + if options.codec == "" { if strings.HasSuffix(strings.ToLower(filename), ".wmv") { writer.codec = "msmpeg4" @@ -84,18 +82,6 @@ func NewVideoWriter(filename string, options *Options) *VideoWriter { writer.codec = options.codec } - if options.in_pix_fmt == "" { - writer.in_pix_fmt = "rgb24" - } else { - writer.in_pix_fmt = options.in_pix_fmt - } - - if options.out_pix_fmt == "" { - writer.out_pix_fmt = "yuv420p" - } else { - writer.out_pix_fmt = options.out_pix_fmt - } - return &writer } @@ -109,13 +95,27 @@ func initVideoWriter(writer *VideoWriter) { "-f", "rawvideo", "-vcodec", "rawvideo", "-s", fmt.Sprintf("%dx%d", writer.width, writer.height), // frame w x h - "-pix_fmt", writer.in_pix_fmt, + "-pix_fmt", "rgb24", "-r", fmt.Sprintf("%.02f", writer.fps), // frames per second "-i", "-", // The input comes from stdin "-an", // Tells ffmpeg not to expect any audio "-vcodec", writer.codec, - "-pix_fmt", writer.out_pix_fmt, - "-b:v", fmt.Sprintf("%d", writer.bitrate), // bitrate + "-pix_fmt", "yuv420p", // Output is 8-bit RGB, no alpha + } + + // Code from the imageio-ffmpeg project. + // https://github.com/imageio/imageio-ffmpeg/blob/master/imageio_ffmpeg/_io.py#L399 + // If bitrate not given, use a default. + if writer.bitrate == 0 { + if writer.codec == "libx264" { + // Quality between 0 an 51. 51 is worst. + command = append(command, "-crf", fmt.Sprintf("%d", int(writer.quality*51))) + } else { + // Quality between 1 and 31. 31 is worst. + command = append(command, "-qscale:v", fmt.Sprintf("%d", int(writer.quality*30)+1)) + } + } else { + command = append(command, "-b:v", fmt.Sprintf("%d", writer.bitrate)) } if strings.HasSuffix(strings.ToLower(writer.filename), ".gif") {