Using FFprobe for video metadata

This commit is contained in:
Alex Eidt 2021-12-25 23:34:04 -08:00
parent 3faf4aa9c8
commit ba32c5c493
5 changed files with 337 additions and 230 deletions

3
.gitignore vendored
View file

@ -0,0 +1,3 @@
main.go
*.gif
*.mp4

View file

@ -1,12 +1,6 @@
# Video-IO
A simple Video I/O library written in Go. This library relies on [FFMPEG](https://www.ffmpeg.org/), which must be downloaded before usage.
One of the key features of this library is it's simplicity: The FFMPEG commands used to read and write video are readily available in `videoio.go` for you to modify as you need. All functions placed in one file for portability.
## Documentation
Video-IO features `Video` and `VideoWriter` structs which can read and write videos.
A simple Video I/O library written in Go. This library relies on [FFmpeg](https://www.ffmpeg.org/), which must be downloaded before usage.
## `Video`
@ -19,15 +13,23 @@ for video.NextFrame() {
}
```
Notice that once the `video` is initialized, you will have access to certain metadata of the video such as the
* width (pixels)
* height (pixels)
* bitrate (kB/s)
* duration (seconds)
* frames per second
* video codec
* pixel format
```
type Video struct {
filename string
width int
height int
channels int
bitrate int
frames int
duration float64
fps float64
codec string
pix_fmt string
framebuffer []byte
pipe *io.ReadCloser
cmd *exec.Cmd
}
```
Once the frame is read by calling the `NextFrame()` function, the resulting frame is stored in the `framebuffer` as shown above. The frame buffer is an array of bytes representing the most recently read frame as an RGB image. The framebuffer is flattened and contains image data in the form: `RGBRGBRGBRGB...`.
@ -85,4 +87,5 @@ for video.NextFrame() {
# 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.
* 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.
* The [ImageIO-FFMPEG](https://github.com/imageio/imageio-ffmpeg/) project on GitHub.

82
utils.go Normal file
View file

@ -0,0 +1,82 @@
package main
import (
"errors"
"os"
"os/exec"
"strconv"
"strings"
)
// Returns true if file exists, false otherwise.
// https://stackoverflow.com/questions/12518876/how-to-check-if-a-file-exists-in-go
func Exists(filename string) bool {
_, err := os.Stat(filename)
if err == nil {
return true
}
if errors.Is(err, os.ErrNotExist) {
return false
}
return false
}
// Checks if the given program is installed.
func CheckExists(program string) {
cmd := exec.Command(program, "-version")
if err := cmd.Start(); err != nil {
panic(program + " is not installed.")
}
if err := cmd.Wait(); err != nil {
panic(program + " is not installed.")
}
}
// Parse ffprobe output to fill in video data.
func parseFFprobe(input []byte, video *Video) {
data := make(map[string]string)
for _, line := range strings.Split(string(input), "|") {
if strings.Contains(line, "=") {
keyValue := strings.Split(line, "=")
if _, ok := data[keyValue[0]]; !ok {
data[keyValue[0]] = keyValue[1]
}
}
}
video.width = int(parse(data["width"]))
video.height = int(parse(data["height"]))
video.duration = float64(parse(data["duration"]))
video.frames = int(parse(data["nb_frames"]))
split := strings.Split(data["r_frame_rate"], "/")
if len(split) == 2 && split[0] != "" && split[1] != "" {
video.fps = parse(split[0]) / parse(split[1])
}
video.bitrate = int(parse(data["bit_rate"]))
video.codec = data["codec_name"]
video.pix_fmt = data["pix_fmt"]
}
func parse(data string) float64 {
n, err := strconv.ParseFloat(data, 64)
if err != nil {
return 0
}
return n
}
// func main() {
// os := runtime.GOOS
// switch os {
// case "windows":
// fmt.Println("Windows")
// case "darwin":
// fmt.Println("MAC operating system")
// case "linux":
// fmt.Println("Linux")
// default:
// fmt.Printf("%s.\n", os)
// }
// }

View file

@ -1,28 +1,20 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"regexp"
"strconv"
"strings"
"syscall"
)
// #############################################################################
// Video
// #############################################################################
type Video struct {
filename string
width int
height int
channels int
bitrate int
frames int
duration float64
fps float64
codec string
@ -36,17 +28,25 @@ func NewVideo(filename string) *Video {
if !Exists(filename) {
panic("File: " + filename + " does not exist")
}
// Execute ffmpeg -i command to get video information.
cmd := exec.Command("ffmpeg", "-i", filename, "-")
// ffmpeg output piped to Stderr.
pipe, err := cmd.StderrPipe()
CheckExists("ffmpeg")
CheckExists("ffprobe")
// Extract video information with ffprobe.
cmd := exec.Command(
"ffprobe",
"-show_streams",
"-select_streams", "v",
"-print_format", "compact",
"-loglevel", "quiet",
filename,
)
pipe, err := cmd.StdoutPipe()
if err != nil {
panic(err)
}
if err := cmd.Start(); err != nil {
panic(err)
}
buffer := make([]byte, 2<<12)
buffer := make([]byte, 2<<10)
total := 0
for {
n, err := pipe.Read(buffer[total:])
@ -55,9 +55,11 @@ func NewVideo(filename string) *Video {
break
}
}
cmd.Wait()
if err := cmd.Wait(); err != nil {
panic(err)
}
video := &Video{filename: filename, channels: 3}
parseFFMPEGHeader(video, string(buffer))
parseFFprobe(buffer[:total], video)
return video
}
@ -67,9 +69,9 @@ func (video *Video) initVideoStream() {
cmd := exec.Command(
"ffmpeg",
"-loglevel", "quiet",
"-i", video.filename,
"-f", "image2pipe",
"-loglevel", "quiet",
"-pix_fmt", "rgb24",
"-vcodec", "rawvideo", "-",
)
@ -94,10 +96,7 @@ func (video *Video) NextFrame() bool {
for total < video.width*video.height*video.channels {
n, err := (*video.pipe).Read(video.framebuffer[total:])
if err == io.EOF {
(*video.pipe).Close()
if err := video.cmd.Wait(); err != nil {
panic(err)
}
video.Close()
return false
}
total += n
@ -105,6 +104,15 @@ func (video *Video) NextFrame() bool {
return true
}
func (video *Video) Close() {
if video.pipe != nil {
(*video.pipe).Close()
}
if err := video.cmd.Wait(); err != nil {
panic(err)
}
}
// Stops the "cmd" process running when the user presses Ctrl+C.
// https://stackoverflow.com/questions/11268943/is-it-possible-to-capture-a-ctrlc-signal-and-run-a-cleanup-function-in-a-defe
func (video *Video) cleanup() {
@ -121,195 +129,3 @@ func (video *Video) cleanup() {
os.Exit(1)
}()
}
// #############################################################################
// VideoWriter
// #############################################################################
type VideoWriter struct {
filename string
width int
height int
bitrate int
fps float64
codec string
pix_fmt string
pipe *io.WriteCloser
cmd *exec.Cmd
}
func NewVideoWriter(filename string, video *Video) *VideoWriter {
if video.width == 0 || video.height == 0 {
panic("Video width and height must be set.")
}
if video.fps == 0 {
video.fps = 25 // Default to 25 FPS.
}
return &VideoWriter{
filename: filename,
width: video.width,
height: video.height,
bitrate: video.bitrate,
fps: video.fps,
codec: "mpeg4",
pix_fmt: "rgb24",
}
}
func (writer *VideoWriter) initVideoWriter() {
// If user exits with Ctrl+C, stop ffmpeg process.
writer.cleanup()
cmd := exec.Command(
"ffmpeg",
"-y", // overwrite output file if it exists
"-f", "rawvideo",
"-vcodec", "rawvideo",
"-s", fmt.Sprintf("%dx%d", writer.width, writer.height), // frame w x h
"-pix_fmt", writer.pix_fmt,
"-r", fmt.Sprintf("%f", writer.fps), // frames per second
"-i", "-", // The imput comes from stdin
"-an", // Tells ffmpeg not to expect any audio
"-vcodec", writer.codec,
"-b:v", fmt.Sprintf("%dk", writer.bitrate), // bitrate
writer.filename,
)
writer.cmd = cmd
pipe, err := cmd.StdinPipe()
if err != nil {
panic(err)
}
writer.pipe = &pipe
if err := cmd.Start(); err != nil {
panic(err)
}
}
func (writer *VideoWriter) Write(frame []byte) {
// If cmd is nil, video writing has not been set up.
if writer.cmd == nil {
writer.initVideoWriter()
}
total := 0
for total < len(frame) {
n, err := (*writer.pipe).Write(frame[total:])
if err != nil {
defer fmt.Println("Likely cause is invalid parameters to ffmpeg.")
panic(err)
}
total += n
}
}
func (writer *VideoWriter) Close() {
if writer.pipe != nil {
(*writer.pipe).Close()
}
if writer.cmd != nil {
writer.cmd.Wait()
}
}
// Stops the "cmd" process running when the user presses Ctrl+C.
// https://stackoverflow.com/questions/11268943/is-it-possible-to-capture-a-ctrlc-signal-and-run-a-cleanup-function-in-a-defe
func (writer *VideoWriter) cleanup() {
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
if writer.pipe != nil {
(*writer.pipe).Close()
}
if writer.cmd != nil {
writer.cmd.Process.Kill()
}
os.Exit(1)
}()
}
// #############################################################################
// Utils
// #############################################################################
// Parses the duration of the video from the ffmpeg header.
func parseDurationBitrate(video *Video, data []string) {
videoData := ""
for _, line := range data {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Duration: ") {
videoData = line
break
}
}
if videoData == "" {
panic("Could not find duration in ffmpeg header.")
}
// Duration
duration := strings.Split(strings.SplitN(strings.SplitN(videoData, ",", 2)[0], "Duration:", 2)[1], ":")
seconds, _ := strconv.ParseFloat(duration[len(duration)-1], 64)
minutes, _ := strconv.ParseFloat(duration[len(duration)-2], 64)
hours, _ := strconv.ParseFloat(duration[len(duration)-3], 64)
video.duration = seconds + minutes*60 + hours*3600
// Bitrate
bitrate := strings.SplitN(strings.TrimSpace(strings.SplitN(videoData, "bitrate:", 2)[1]), " ", 2)[0]
video.bitrate, _ = strconv.Atoi(bitrate)
}
func parseVideoData(video *Video, data []string) {
videoData := ""
// Get string containing video data.
for _, line := range data {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Stream") && strings.Contains(line, "Video:") {
videoData = strings.TrimSpace(strings.SplitN(line, "Video:", 2)[1])
break
}
}
if videoData == "" {
panic("No video data found in ffmpeg header.")
}
// Video Codec
video.codec = strings.TrimSpace(strings.SplitN(videoData, " ", 2)[0])
// FPS
fpsstr := strings.SplitN(videoData, "fps", 2)[0]
fps, _ := strconv.Atoi(strings.TrimSpace(fpsstr[strings.LastIndex(fpsstr, ",")+1:]))
video.fps = float64(fps)
// Pixel Format
video.pix_fmt = strings.TrimSpace(strings.Split(videoData, ",")[1])
// Width and Height
r, _ := regexp.Compile("\\d+x\\d+")
wh := r.FindAllString(videoData, -1)
dims := strings.SplitN(wh[len(wh)-1], "x", 2)
width, _ := strconv.Atoi(dims[0])
height, _ := strconv.Atoi(dims[1])
video.width = width
video.height = height
}
// Parses the ffmpeg header.
// Code inspired by the imageio-ffmpeg project.
// GitHub: https://github.com/imageio/imageio-ffmpeg/blob/master/imageio_ffmpeg/_parsing.py#L113
func parseFFMPEGHeader(video *Video, header string) {
data := strings.Split(strings.ReplaceAll(header, "\r\n", "\n"), "\n")
parseDurationBitrate(video, data)
parseVideoData(video, data)
}
// #############################################################################
// Utils
// #############################################################################
// Returns true if file exists, false otherwise.
// https://stackoverflow.com/questions/12518876/how-to-check-if-a-file-exists-in-go
func Exists(filename string) bool {
_, err := os.Stat(filename)
if err == nil {
return true
}
if errors.Is(err, os.ErrNotExist) {
return false
}
return false
}

203
videowriter.go Normal file
View file

@ -0,0 +1,203 @@
package main
import (
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
)
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
}
type Options struct {
width int
height int
bitrate int
loop int
macro int
delay int
fps float64
codec string
in_pix_fmt string
out_pix_fmt string
}
func NewVideoWriter(filename string, 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
}
// Default Parameter options logic from:
// https://github.com/imageio/imageio-ffmpeg/blob/master/imageio_ffmpeg/_io.py#L268
// GIF settings
writer.loop = options.loop
if options.delay == 0 {
writer.delay = -1
} else {
writer.delay = options.delay
}
if options.macro == 0 {
writer.macro = 16
} else {
writer.macro = options.macro
}
if options.fps == 0 {
writer.fps = 25
} else {
writer.fps = options.fps
}
if options.codec == "" {
if strings.HasSuffix(strings.ToLower(filename), ".wmv") {
writer.codec = "msmpeg4"
} else if strings.HasSuffix(strings.ToLower(filename), ".gif") {
writer.codec = "gif"
} else {
writer.codec = "libx264"
}
} else {
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
}
func (writer *VideoWriter) initVideoWriter() {
// If user exits with Ctrl+C, stop ffmpeg process.
writer.cleanup()
command := []string{
"-y", // overwrite output file if it exists
"-loglevel", "quiet",
"-f", "rawvideo",
"-vcodec", "rawvideo",
"-s", fmt.Sprintf("%dx%d", writer.width, writer.height), // frame w x h
"-pix_fmt", writer.in_pix_fmt,
"-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
}
if strings.HasSuffix(strings.ToLower(writer.filename), ".gif") {
command = append(
command,
"-loop", fmt.Sprintf("%d", writer.loop),
"-final_delay", fmt.Sprintf("%d", writer.delay),
)
}
// Code from the imageio-ffmpeg project:
// https://github.com/imageio/imageio-ffmpeg/blob/master/imageio_ffmpeg/_io.py#L415
// Resizes the video frames to a size that works with most codecs.
if writer.macro > 1 {
if writer.width%writer.macro > 0 || writer.height%writer.macro > 0 {
width := writer.width
height := writer.height
if writer.width%writer.macro > 0 {
width += writer.macro - (writer.width % writer.macro)
}
if writer.height%writer.macro > 0 {
height += writer.macro - (writer.height % writer.macro)
}
command = append(
command,
"-vf", fmt.Sprintf("scale=%d:%d", width, height),
)
}
}
command = append(command, writer.filename)
cmd := exec.Command("ffmpeg", command...)
writer.cmd = cmd
pipe, err := cmd.StdinPipe()
if err != nil {
panic(err)
}
writer.pipe = &pipe
if err := cmd.Start(); err != nil {
panic(err)
}
}
func (writer *VideoWriter) Write(frame []byte) {
// If cmd is nil, video writing has not been set up.
if writer.cmd == nil {
writer.initVideoWriter()
}
total := 0
for total < len(frame) {
n, err := (*writer.pipe).Write(frame[total:])
if err != nil {
fmt.Println("Likely cause is invalid parameters to ffmpeg.")
panic(err)
}
total += n
}
}
func (writer *VideoWriter) Close() {
if writer.pipe != nil {
(*writer.pipe).Close()
}
if writer.cmd != nil {
writer.cmd.Wait()
}
}
// Stops the "cmd" process running when the user presses Ctrl+C.
// https://stackoverflow.com/questions/11268943/is-it-possible-to-capture-a-ctrlc-signal-and-run-a-cleanup-function-in-a-defe
func (writer *VideoWriter) cleanup() {
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
if writer.pipe != nil {
(*writer.pipe).Close()
}
if writer.cmd != nil {
writer.cmd.Process.Kill()
}
os.Exit(1)
}()
}