407 lines
10 KiB
Go
407 lines
10 KiB
Go
package vidio
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
type Video struct {
|
|
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.
|
|
stream int // Stream Index.
|
|
duration float64 // Duration of video in seconds.
|
|
fps float64 // Frames per second.
|
|
codec string // Codec used for video encoding.
|
|
hasstreams bool // Flag storing whether file has additional data streams.
|
|
framebuffer []byte // Raw frame data.
|
|
metadata map[string]string // Video metadata.
|
|
pipe io.ReadCloser // Stdout pipe for ffmpeg process.
|
|
cmd *exec.Cmd // ffmpeg command.
|
|
}
|
|
|
|
func (video *Video) FileName() string {
|
|
return video.filename
|
|
}
|
|
|
|
func (video *Video) Width() int {
|
|
return video.width
|
|
}
|
|
|
|
func (video *Video) Height() int {
|
|
return video.height
|
|
}
|
|
|
|
// Channels of video frames.
|
|
func (video *Video) Depth() int {
|
|
return video.depth
|
|
}
|
|
|
|
// Bitrate of video in bits/s.
|
|
func (video *Video) Bitrate() int {
|
|
return video.bitrate
|
|
}
|
|
|
|
// Total number of frames in video.
|
|
func (video *Video) Frames() int {
|
|
return video.frames
|
|
}
|
|
|
|
// Returns the zero-indexed video stream index.
|
|
func (video *Video) Stream() int {
|
|
return video.stream
|
|
}
|
|
|
|
// Video duration in seconds.
|
|
func (video *Video) Duration() float64 {
|
|
return video.duration
|
|
}
|
|
|
|
// Frames per second of video.
|
|
func (video *Video) FPS() float64 {
|
|
return video.fps
|
|
}
|
|
|
|
func (video *Video) Codec() string {
|
|
return video.codec
|
|
}
|
|
|
|
// Returns true if file has any audio, subtitle, data or attachment streams.
|
|
func (video *Video) HasStreams() bool {
|
|
return video.hasstreams
|
|
}
|
|
|
|
func (video *Video) FrameBuffer() []byte {
|
|
return video.framebuffer
|
|
}
|
|
|
|
// Raw Metadata from ffprobe output for the video file.
|
|
func (video *Video) MetaData() map[string]string {
|
|
return video.metadata
|
|
}
|
|
|
|
func (video *Video) SetFrameBuffer(buffer []byte) error {
|
|
size := video.width * video.height * video.depth
|
|
if len(buffer) < size {
|
|
return fmt.Errorf("buffer size %d is smaller than frame size %d", len(buffer), size)
|
|
}
|
|
video.framebuffer = buffer
|
|
return nil
|
|
}
|
|
|
|
func NewVideo(filename string) (*Video, error) {
|
|
streams, err := NewVideoStreams(filename)
|
|
if streams == nil {
|
|
return nil, err
|
|
}
|
|
|
|
return streams[0], err
|
|
}
|
|
|
|
// Read all video streams from the given file.
|
|
func NewVideoStreams(filename string) ([]*Video, error) {
|
|
if !exists(filename) {
|
|
return nil, fmt.Errorf("video file %s does not exist", filename)
|
|
}
|
|
// Check if ffmpeg and ffprobe are installed on the users machine.
|
|
if err := installed("ffmpeg"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := installed("ffprobe"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
videoData, err := ffprobe(filename, "v")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(videoData) == 0 {
|
|
return nil, fmt.Errorf("no video data found in %s", filename)
|
|
}
|
|
|
|
// Loop over all stream types. a: Audio, s: Subtitle, d: Data, t: Attachments
|
|
hasstream := false
|
|
for _, c := range "asdt" {
|
|
data, err := ffprobe(filename, string(c))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > 0 {
|
|
hasstream = true
|
|
break
|
|
}
|
|
}
|
|
|
|
streams := make([]*Video, len(videoData))
|
|
for i, data := range videoData {
|
|
video := &Video{
|
|
filename: filename,
|
|
depth: 4,
|
|
stream: i,
|
|
hasstreams: hasstream,
|
|
metadata: data,
|
|
}
|
|
|
|
video.addVideoData(data)
|
|
|
|
streams[i] = video
|
|
}
|
|
|
|
return streams, nil
|
|
}
|
|
|
|
// Adds Video data to the video struct from the ffprobe output.
|
|
func (video *Video) addVideoData(data map[string]string) {
|
|
if width, ok := data["width"]; ok {
|
|
video.width = int(parse(width))
|
|
}
|
|
if height, ok := data["height"]; ok {
|
|
video.height = int(parse(height))
|
|
}
|
|
if duration, ok := data["duration"]; ok {
|
|
video.duration = float64(parse(duration))
|
|
}
|
|
if frames, ok := data["nb_frames"]; ok {
|
|
video.frames = int(parse(frames))
|
|
}
|
|
if fps, ok := data["r_frame_rate"]; ok {
|
|
split := strings.Split(fps, "/")
|
|
if len(split) == 2 && split[0] != "" && split[1] != "" {
|
|
video.fps = parse(split[0]) / parse(split[1])
|
|
}
|
|
}
|
|
if bitrate, ok := data["bit_rate"]; ok {
|
|
video.bitrate = int(parse(bitrate))
|
|
}
|
|
if codec, ok := data["codec_name"]; ok {
|
|
video.codec = codec
|
|
}
|
|
}
|
|
|
|
// Once the user calls Read() for the first time on a Video struct,
|
|
// the ffmpeg command which is used to read the video is started.
|
|
func (video *Video) init() error {
|
|
// If user exits with Ctrl+C, stop ffmpeg process.
|
|
video.cleanup()
|
|
// ffmpeg command to pipe video data to stdout in 8-bit RGBA format.
|
|
cmd := exec.Command(
|
|
"ffmpeg",
|
|
"-i", video.filename,
|
|
"-f", "image2pipe",
|
|
"-loglevel", "quiet",
|
|
"-pix_fmt", "rgba",
|
|
"-vcodec", "rawvideo",
|
|
"-map", fmt.Sprintf("0:v:%d", video.stream),
|
|
"-",
|
|
)
|
|
|
|
video.cmd = cmd
|
|
pipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
video.pipe = pipe
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if video.framebuffer == nil {
|
|
video.framebuffer = make([]byte, video.width*video.height*video.depth)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Reads the next frame from the video and stores in the framebuffer.
|
|
// If the last frame has been read, returns false, otherwise true.
|
|
func (video *Video) Read() bool {
|
|
// If cmd is nil, video reading has not been initialized.
|
|
if video.cmd == nil {
|
|
if err := video.init(); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if _, err := io.ReadFull(video.pipe, video.framebuffer); err != nil {
|
|
video.Close()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Reads the N-th frame from the video and stores it in the framebuffer. If the index is out of range or
|
|
// the operation failes, the function will return an error. The frames are indexed from 0.
|
|
func (video *Video) ReadFrame(n int) error {
|
|
if n >= video.frames {
|
|
return fmt.Errorf("vidio: provided frame index %d is not in frame count range", n)
|
|
}
|
|
|
|
if video.framebuffer == nil {
|
|
video.framebuffer = make([]byte, video.width*video.height*video.depth)
|
|
}
|
|
|
|
selectExpression, err := buildSelectExpression(n)
|
|
if err != nil {
|
|
return fmt.Errorf("vidio: failed to parse the specified frame index: %w", err)
|
|
}
|
|
|
|
cmd := exec.Command(
|
|
"ffmpeg",
|
|
"-i", video.filename,
|
|
"-f", "image2pipe",
|
|
"-loglevel", "quiet",
|
|
"-pix_fmt", "rgba",
|
|
"-vcodec", "rawvideo",
|
|
"-map", fmt.Sprintf("0:v:%d", video.stream),
|
|
"-vf", selectExpression,
|
|
"-vsync", "0",
|
|
"-",
|
|
)
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("vidio: failed to access the ffmpeg stdout pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("vidio: failed to start the ffmpeg cmd: %w", err)
|
|
}
|
|
|
|
interruptChan := make(chan os.Signal, 1)
|
|
signal.Notify(interruptChan, os.Interrupt, syscall.SIGTERM)
|
|
go func() {
|
|
<-interruptChan
|
|
if stdoutPipe != nil {
|
|
stdoutPipe.Close()
|
|
}
|
|
if cmd != nil {
|
|
cmd.Process.Kill()
|
|
}
|
|
os.Exit(1)
|
|
}()
|
|
|
|
if _, err := io.ReadFull(stdoutPipe, video.framebuffer); err != nil {
|
|
return fmt.Errorf("vidio: failed to read the ffmpeg cmd result to the image buffer: %w", err)
|
|
}
|
|
|
|
if err := stdoutPipe.Close(); err != nil {
|
|
return fmt.Errorf("vidio: failed to close the ffmpeg stdout pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Wait(); err != nil {
|
|
return fmt.Errorf("vidio: failed to free resources after the ffmpeg cmd: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Read the N-amount of frames with the given indexes and return them as a slice of RGBA image pointers. If one of
|
|
// the indexes is out of range, the function will return an error. The frames are indexes from 0.
|
|
func (video *Video) ReadFrames(n ...int) ([]*image.RGBA, error) {
|
|
if len(n) == 0 {
|
|
return nil, fmt.Errorf("vidio: no frames indexes specified")
|
|
}
|
|
|
|
for _, nValue := range n {
|
|
if nValue >= video.frames {
|
|
return nil, fmt.Errorf("vidio: provided frame index %d is not in frame count range", nValue)
|
|
}
|
|
}
|
|
|
|
selectExpression, err := buildSelectExpression(n...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("vidio: failed to parse the specified frame index: %w", err)
|
|
}
|
|
|
|
cmd := exec.Command(
|
|
"ffmpeg",
|
|
"-i", video.filename,
|
|
"-f", "image2pipe",
|
|
"-loglevel", "quiet",
|
|
"-pix_fmt", "rgba",
|
|
"-vcodec", "rawvideo",
|
|
"-map", fmt.Sprintf("0:v:%d", video.stream),
|
|
"-vf", selectExpression,
|
|
"-vsync", "0",
|
|
"-",
|
|
)
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("vidio: failed to access the ffmpeg stdout pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("vidio: failed to start the ffmpeg cmd: %w", err)
|
|
}
|
|
|
|
interruptChan := make(chan os.Signal, 1)
|
|
signal.Notify(interruptChan, os.Interrupt, syscall.SIGTERM)
|
|
go func() {
|
|
<-interruptChan
|
|
if stdoutPipe != nil {
|
|
stdoutPipe.Close()
|
|
}
|
|
if cmd != nil {
|
|
cmd.Process.Kill()
|
|
}
|
|
os.Exit(1)
|
|
}()
|
|
|
|
frames := make([]*image.RGBA, len(n))
|
|
for frameIndex := range frames {
|
|
frames[frameIndex] = image.NewRGBA(image.Rect(0, 0, video.width, video.height))
|
|
|
|
if _, err := io.ReadFull(stdoutPipe, frames[frameIndex].Pix); err != nil {
|
|
return nil, fmt.Errorf("vidio: failed to read the ffmpeg cmd result to the image buffer: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := stdoutPipe.Close(); err != nil {
|
|
return nil, fmt.Errorf("vidio: failed to close the ffmpeg stdout pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Wait(); err != nil {
|
|
return nil, fmt.Errorf("vidio: failed to free resources after the ffmpeg cmd: %w", err)
|
|
}
|
|
|
|
return frames, nil
|
|
}
|
|
|
|
// Closes the pipe and stops the ffmpeg process.
|
|
func (video *Video) Close() {
|
|
if video.pipe != nil {
|
|
video.pipe.Close()
|
|
}
|
|
if video.cmd != nil {
|
|
video.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 (video *Video) cleanup() {
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
go func() {
|
|
<-c
|
|
if video.pipe != nil {
|
|
video.pipe.Close()
|
|
}
|
|
if video.cmd != nil {
|
|
video.cmd.Process.Kill()
|
|
}
|
|
os.Exit(1)
|
|
}()
|
|
}
|