334 lines
8.5 KiB
Go
334 lines
8.5 KiB
Go
package vidio
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
type VideoWriter struct {
|
|
filename string // Output filename.
|
|
audio string // Audio filename.
|
|
width int // Frame width.
|
|
height int // Frame height.
|
|
bitrate int // Output video bitrate.
|
|
loop int // Number of times for GIF to loop.
|
|
delay int // Delay of final frame of GIF. Default -1 (same delay as previous frame).
|
|
macro int // Macroblock size for determining how to resize frames for codecs.
|
|
fps float64 // Frames per second for output video. Default 25.
|
|
quality float64 // Used if bitrate not given. Default 0.5.
|
|
codec string // Codec to encode video with. Default libx264.
|
|
format string // Output format. Default rgb24.
|
|
audioCodec string // Codec to encode audio with. Default aac.
|
|
pipe *io.WriteCloser // Stdout pipe of ffmpeg process.
|
|
cmd *exec.Cmd // ffmpeg command.
|
|
}
|
|
|
|
// Optional parameters for VideoWriter.
|
|
type Options struct {
|
|
Bitrate int // Bitrate.
|
|
Loop int // For GIFs only. -1=no loop, 0=infinite loop, >0=number of loops.
|
|
Delay int // Delay for final frame of GIFs.
|
|
Macro int // Macroblock size for determining how to resize frames for codecs.
|
|
FPS float64 // Frames per second for output video.
|
|
Quality float64 // If bitrate not given, use quality instead. Must be between 0 and 1. 0:best, 1:worst.
|
|
Codec string // Codec for video.
|
|
Format string // Pixel Format for video. Default "rgb24".
|
|
Audio string // File path for audio. If no audio, audio="".
|
|
AudioCodec string // Codec for audio.
|
|
}
|
|
|
|
func (writer *VideoWriter) FileName() string {
|
|
return writer.filename
|
|
}
|
|
|
|
func (writer *VideoWriter) Audio() string {
|
|
return writer.audio
|
|
}
|
|
|
|
func (writer *VideoWriter) Width() int {
|
|
return writer.width
|
|
}
|
|
|
|
func (writer *VideoWriter) Height() int {
|
|
return writer.height
|
|
}
|
|
|
|
func (writer *VideoWriter) Bitrate() int {
|
|
return writer.bitrate
|
|
}
|
|
|
|
func (writer *VideoWriter) Loop() int {
|
|
return writer.loop
|
|
}
|
|
|
|
func (writer *VideoWriter) Delay() int {
|
|
return writer.delay
|
|
}
|
|
|
|
func (writer *VideoWriter) Macro() int {
|
|
return writer.macro
|
|
}
|
|
|
|
func (writer *VideoWriter) FPS() float64 {
|
|
return writer.fps
|
|
}
|
|
|
|
func (writer *VideoWriter) Quality() float64 {
|
|
return writer.quality
|
|
}
|
|
|
|
func (writer *VideoWriter) Codec() string {
|
|
return writer.codec
|
|
}
|
|
|
|
func (writer *VideoWriter) Format() string {
|
|
return writer.format
|
|
}
|
|
|
|
func (writer *VideoWriter) AudioCodec() string {
|
|
return writer.audioCodec
|
|
}
|
|
|
|
// Creates a new VideoWriter struct with default values from the Options struct.
|
|
func NewVideoWriter(filename string, width, height int, options *Options) (*VideoWriter, error) {
|
|
// Check if ffmpeg is installed on the users machine.
|
|
if err := checkExists("ffmpeg"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
writer := &VideoWriter{filename: filename}
|
|
|
|
if options == nil {
|
|
options = &Options{}
|
|
}
|
|
|
|
writer.width = width
|
|
writer.height = height
|
|
writer.bitrate = options.Bitrate
|
|
|
|
// Default Parameter options logic from:
|
|
// https://github.com/imageio/imageio-ffmpeg/blob/master/imageio_ffmpeg/_io.py#L268.
|
|
|
|
// GIF settings
|
|
writer.loop = options.Loop // Default to infinite loop.
|
|
if options.Delay == 0 {
|
|
writer.delay = -1 // Default to frame delay of previous frame.
|
|
} 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.Quality == 0 {
|
|
writer.quality = 0.5
|
|
} else {
|
|
writer.quality = math.Max(0, math.Min(options.Quality, 1))
|
|
}
|
|
|
|
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.Format == "" {
|
|
writer.format = "rgb24"
|
|
} else {
|
|
writer.format = options.Format
|
|
}
|
|
|
|
if options.Audio != "" {
|
|
if !exists(options.Audio) {
|
|
return nil, fmt.Errorf("audio file %s does not exist", options.Audio)
|
|
}
|
|
|
|
audioData, err := ffprobe(options.Audio, "a")
|
|
if err != nil {
|
|
return nil, err
|
|
} else if len(audioData) == 0 {
|
|
return nil, fmt.Errorf("given audio file %s has no audio", options.Audio)
|
|
}
|
|
|
|
writer.audio = options.Audio
|
|
|
|
if options.AudioCodec == "" {
|
|
writer.audioCodec = "aac"
|
|
} else {
|
|
writer.audioCodec = options.AudioCodec
|
|
}
|
|
}
|
|
|
|
return writer, nil
|
|
}
|
|
|
|
// Once the user calls Write() for the first time on a VideoWriter struct,
|
|
// the ffmpeg command which is used to write to the video file is started.
|
|
func (writer *VideoWriter) init() error {
|
|
// If user exits with Ctrl+C, stop ffmpeg process.
|
|
writer.cleanup()
|
|
// ffmpeg command to write to video file. Takes in bytes from Stdin and encodes them.
|
|
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.format,
|
|
"-r", fmt.Sprintf("%.02f", writer.fps), // frames per second.
|
|
"-i", "-", // The input comes from stdin.
|
|
}
|
|
|
|
gif := strings.HasSuffix(strings.ToLower(writer.filename), ".gif")
|
|
|
|
if writer.audio == "" || gif {
|
|
command = append(command, "-an") // No audio.
|
|
} else {
|
|
command = append(
|
|
command,
|
|
"-i", writer.audio,
|
|
)
|
|
}
|
|
|
|
command = append(
|
|
command,
|
|
"-vcodec", writer.codec,
|
|
"-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))
|
|
}
|
|
|
|
// For GIFs, add looping and delay parameters.
|
|
if 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),
|
|
)
|
|
}
|
|
}
|
|
|
|
// If audio was included, then specify video and audio channels.
|
|
if writer.audio != "" && !gif {
|
|
command = append(
|
|
command,
|
|
"-acodec", writer.audioCodec,
|
|
"-map", "0:v:0",
|
|
"-map", "1:a:0",
|
|
)
|
|
}
|
|
|
|
command = append(command, writer.filename)
|
|
cmd := exec.Command("ffmpeg", command...)
|
|
writer.cmd = cmd
|
|
|
|
pipe, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writer.pipe = &pipe
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Writes the given frame to the video file.
|
|
func (writer *VideoWriter) Write(frame []byte) error {
|
|
// If cmd is nil, video writing has not been set up.
|
|
if writer.cmd == nil {
|
|
if err := writer.init(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
total := 0
|
|
for total < len(frame) {
|
|
n, err := (*writer.pipe).Write(frame[total:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
total += n
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Closes the pipe and stops the ffmpeg process.
|
|
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, 1)
|
|
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)
|
|
}()
|
|
}
|