diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index d21d79a..bfe1b40 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -1,25 +1,34 @@ name: Build & Test on: push: - branches: [master] + branches: + - v2 pull_request: - branches: [master] + branches: + - v2 jobs: - build: + build_and_test: + name: Build & Test runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 + - name: Install FFmpeg + run: |- + sudo add-apt-repository ppa:ubuntuhandbook1/ffmpeg6 -y + sudo apt-get update -y + sudo apt-get install ffmpeg -y - name: Setup Go uses: actions/setup-go@v4 with: - go-version: "1.21.x" + go-version: '1.21.x' - name: Install dependencies - run: go get . - - name: Install FFmpeg - run: sudo apt-get update && sudo apt-get install ffmpeg + run: go mod download - name: Build - run: go build -v ./... - - name: Test with the Go CLI - run: go test --failfast -v ./... + run: go build -ldflags="-s -w" -o ./bin/ ./... + - name: Test with coverage + run: go test --failfast -v ./... -coverprofile=coverage.out -covermode=atomic -coverpkg=./... + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 2864c6f..890c05d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ *.dll *.so *.dylib +*.key +*.keyinfo +*.pem +*.pub # Test binary, build with `go test -c` *.test @@ -16,10 +20,6 @@ # main test main-test.go -# binary -goffmpeg -coverage.out - # IDE .DS_Store .vscode @@ -27,7 +27,21 @@ coverage.out # Go vendor - -# Test results -*/test_results/* -!*.gitkeep +bin +build +*.log +*.out +*.test +*.prof +*.cover + +# OS +*.DS_Store +*.swp +*.lock +*.tmp +*.bak + +# others +**/results +**/*.json diff --git a/LICENSE b/LICENSE index f21a1ef..3968ff4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 FlooStack +Copyright (c) 2023 xfrr Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index fbdb003..e6abc5c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,19 @@ +.PHONY: all +all: test +.PHONY: test build test: - go test -v ./... -coverprofile=coverage.out -covermode=atomic -coverpkg=./... + go test --failfast -v ./... -coverprofile=coverage.out -covermode=atomic -coverpkg=./... +.PHONY: build +build: + go build -ldflags="-s -w" -o ./bin/ ./... + +.PHONY: coverage-html coverage-html: - go tool cover -html=coverage.out \ No newline at end of file + go tool cover -html=coverage.out + +.PHONY: coverage-total +coverage-total: + go tool cover -func=coverage.out | grep total | awk '{print $$3}' \ No newline at end of file diff --git a/README.md b/README.md index e6b01f6..7eb62b8 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ # Goffmpeg [![Build & Test](https://github.com/xfrr/goffmpeg/actions/workflows/build_and_test.yml/badge.svg)](https://github.com/xfrr/goffmpeg/actions/workflows/build_and_test.yml) -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/93e018e5008b4439acbb30d715b22e7f)](https://www.codacy.com/app/francisco.romero/goffmpeg?utm_source=github.com&utm_medium=referral&utm_content=xfrr/goffmpeg&utm_campaign=Badge_Grade) +[![V2 Release](https://github.com/xfrr/goffmpeg/actions/workflows/v2.yml/badge.svg?branch=v2&event=push)](https://github.com/xfrr/goffmpeg/actions/workflows/v2.yml) +[![Code Quality](https://api.codacy.com/project/badge/Grade/93e018e5008b4439acbb30d715b22e7f)](https://www.codacy.com/app/francisco.romero/goffmpeg?utm_source=github.com&utm_medium=referral&utm_content=xfrr/goffmpeg&utm_campaign=Badge_Grade) +[![Coverage](https://codecov.io/gh/xfrr/goffmpeg/graph/badge.svg?token=LjqrgDKO69)](https://codecov.io/gh/xfrr/goffmpeg) [![Go Report Card](https://goreportcard.com/badge/github.com/xfrr/goffmpeg)](https://goreportcard.com/report/github.com/xfrr/goffmpeg) -[![GoDoc](https://godoc.org/github.com/xfrr/goffmpeg?status.svg)](https://godoc.org/github.com/xfrr/goffmpeg) +[![GoDoc](https://godoc.org/github.com/xfrr/goffmpeg/v2?status.svg)](https://godoc.org/github.com/xfrr/goffmpeg/v2) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) -FFMPEG wrapper written in GO +FFMPEG wrapper written in the Go -## Features +## Supported features -- [x] Transcoding -- [x] Streaming -- [x] Progress -- [x] Filters -- [x] Thumbnails -- [x] Watermark -- [ ] Concatenation -- [ ] Subtitles +- [x] Execute ffmpeg command and get progress events +- [x] Read file and get metadata with ffprobe +- [x] Pipe Options +- [x] Global Options +- [x] Video Options +- [x] Audio Options ## Dependencies -- [FFmpeg](https://www.ffmpeg.org/) +- [FFmpeg](https://www.ffmpeg.org/) - 4.0 or higher - [FFProbe](https://www.ffmpeg.org/ffprobe.html) ## Supported platforms @@ -31,7 +31,7 @@ FFMPEG wrapper written in GO ## Installation Install the package with the following command: ```shell -go get github.com/xfrr/goffmpeg +go get github.com/xfrr/goffmpeg/v2 ``` ## Usage diff --git a/config.go b/config.go deleted file mode 100644 index e0b350b..0000000 --- a/config.go +++ /dev/null @@ -1,67 +0,0 @@ -package goffmpeg - -import ( - "context" - "errors" - "runtime" - "strings" - - "github.com/xfrr/goffmpeg/pkg/cmd" -) - -const ( - ffmpegCommand = "ffmpeg" - ffprobeCommand = "ffprobe" -) - -type Configuration struct { - ffprobeBinPath string - ffmpegBinPath string -} - -func (cfg Configuration) FFmpegBinPath() string { - return cfg.ffmpegBinPath -} - -func (cfg Configuration) FFprobeBinPath() string { - return cfg.ffprobeBinPath -} - -func Configure(ctx context.Context) (Configuration, error) { - ffmpegBin, err := cmd.FindBinPath(ctx, ffmpegCommand) - if err != nil { - return Configuration{}, err - } - - if ffmpegBin == "" { - return Configuration{}, errors.New("ffmpeg not found, please install it before using goffmpeg") - } - - ffprobeBin, err := cmd.FindBinPath(ctx, ffprobeCommand) - if err != nil { - return Configuration{}, err - } - - if ffprobeBin == "" { - return Configuration{}, errors.New("ffprobe not found, please install it before using goffmpeg") - } - - return Configuration{ - ffmpegBinPath: normalizeBinPath(ffmpegBin), - ffprobeBinPath: normalizeBinPath(ffprobeBin), - }, nil -} - -func normalizeBinPath(binPath string) string { - binPath = strings.ReplaceAll(binPath, lineSeparator(), " ") - return strings.TrimSpace(binPath) -} - -func lineSeparator() string { - switch runtime.GOOS { - case "windows": - return "\r\n" - default: - return "\n" - } -} diff --git a/config_test.go b/config_test.go deleted file mode 100644 index 8390576..0000000 --- a/config_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package goffmpeg - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestConfigure(t *testing.T) { - t.Run("Should set the correct command", func(t *testing.T) { - ctx := context.Background() - cfg, err := Configure(ctx) - assert.Nil(t, err) - assert.NotEmpty(t, cfg.FFmpegBinPath()) - assert.NotEmpty(t, cfg.FFprobeBinPath()) - }) -} diff --git a/e2e/fixtures/input.3gp b/e2e/fixtures/input.3gp deleted file mode 100644 index 7b2e319..0000000 Binary files a/e2e/fixtures/input.3gp and /dev/null differ diff --git a/e2e/test_results/.gitkeep b/e2e/test_results/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/e2e/transcoding_test.go b/e2e/transcoding_test.go deleted file mode 100755 index e74421f..0000000 --- a/e2e/transcoding_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package test - -import ( - "os/exec" - "path" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/xfrr/goffmpeg/transcoder" -) - -const ( - fixturePath = "./fixtures" - resultsPath = "./test_results" -) - -var ( - // Input files - input3gp = path.Join(fixturePath, "input.3gp") -) - -func TestInputNotFound(t *testing.T) { - createResultsDir(t) - var outputPath = path.Join(resultsPath, "notfound.mp4") - - trans := new(transcoder.Transcoder) - - err := trans.Initialize("notfound.3gp", outputPath) - assert.NotNil(t, err) -} - -func TestTranscodingProgress(t *testing.T) { - createResultsDir(t) - - outputPath := path.Join(resultsPath, "progress.mp4") - trans := new(transcoder.Transcoder) - - err := trans.Initialize(input3gp, outputPath) - assert.Nil(t, err) - - errCh := trans.Run(true) - - progress := []transcoder.Progress{} - for val := range trans.Output() { - progress = append(progress, val) - } - err = <-errCh - assert.Nil(t, err) - assert.GreaterOrEqual(t, len(progress), 1) - checkFileExists(t, outputPath) -} - -func createResultsDir(t *testing.T) { - err := exec.Command("mkdir", "-p", resultsPath).Run() - assert.Nil(t, err) -} - -func checkFileExists(t *testing.T, filepath string) { - res, err := exec.Command("cat", filepath).Output() - assert.Nil(t, err) - assert.Greater(t, len(res), 0) -} diff --git a/examples/fixtures/input.3gp b/examples/fixtures/input.3gp deleted file mode 100644 index 7b2e319..0000000 Binary files a/examples/fixtures/input.3gp and /dev/null differ diff --git a/examples/hls/main.go b/examples/hls/main.go index b8dc315..f7facba 100644 --- a/examples/hls/main.go +++ b/examples/hls/main.go @@ -1,34 +1,133 @@ package main +// Execute the following script to make it work: +/* +BASE_URL=${1:-'.'} +openssl rand 16 > file.key +echo $BASE_URL/file.key > file.keyinfo +echo file.key >> file.keyinfo +echo $(openssl rand -hex 16) >> file.keyinfo +*/ + import ( + "context" + "flag" "fmt" + "os" + "path/filepath" + "time" + + "github.com/xfrr/goffmpeg/v2/ffmpeg" + "github.com/xfrr/goffmpeg/v2/ffmpeg/progress" + "github.com/xfrr/goffmpeg/v2/ffprobe" +) - "github.com/xfrr/goffmpeg/transcoder" +var ( + keyinfoPath = flag.String("k", "file.keyinfo", "Encryption key path") ) -const ( - inputPath = "../fixtures/input.3gp" - outputPath = "../test_results/hls-output.mp4" - keyinfoPath = "keyinfo" +var ( + defaultInputPath = "../../testdata/input.mp4" + outputPath = flag.String("o", "../results/hls.m3u8", "output path") ) func main() { - trans := new(transcoder.Transcoder) + flag.Parse() + + inputPath := flag.Arg(0) + if inputPath == "" { + inputPath = defaultInputPath + } + + err := createOutputDir(*outputPath) + if err != nil { + panic(err) + } + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + mediafile, err := ffprobe.NewCommand(). + WithInputPath(inputPath). + Run(ctx) + if err != nil { + panic(err) + } + + // the order of the arguments matters + cmd := ffmpeg.NewCommand(). + WithInputPath(inputPath). + WithHLSKeyInfoPath(*keyinfoPath). + WithHLSSegmentTime(4). + WithOutputPath(*outputPath) - err := trans.Initialize(inputPath, outputPath) + progress, err := cmd.Start(ctx) if err != nil { panic(err) } - trans.MediaFile().SetVideoCodec("libx264") - trans.MediaFile().SetHlsSegmentDuration(4) - trans.MediaFile().SetEncryptionKey(keyinfoPath) + go func() { + for msg := range progress { + printProgress(msg, mediafile.GetDuration()) + } + }() - done := trans.Run(true) - progress := trans.Output() - for p := range progress { - fmt.Println(p) + err = cmd.Wait() + if err != nil { + panic(err) + } +} + +func printProgress(p progress.Progress, tdur time.Duration) { + // totalDurMs is the total duration in milliseconds + tdur = time.Duration(tdur.Milliseconds()) * time.Millisecond + + // rdur is the remaining duration in milliseconds + rdur := p.Duration - tdur + + // convert to positive if negative + if rdur < 0 { + rdur = -rdur + } + + // remainingTimeStr is the remaining time in the format 00:00:00.000 + remainingTime := rdur.String() + + // progress is the percentage of the progress + progress := int(float64(p.Duration.Milliseconds()) / float64(tdur.Milliseconds()) * 100) + + fmt.Printf(` +Progress: +- Current Time: %s +- Remaining Time: %s +- Progress: %d%s +- Frames Processed: %d +- Current Bitrate: %f +- Size: %d +- Speed: %f +- Fps: %f +- Dup: %d +- Drop: %d +`, + p.Duration, + remainingTime, + progress, "%", + p.FramesProcessed, + p.Bitrate, + p.Size, + p.Speed, + p.Fps, + p.Dup, + p.Drop, + ) +} + +func createOutputDir(outputPath string) error { + err := os.MkdirAll(filepath.Dir(outputPath), 0755) + if err != nil { + return err } - fmt.Println(<-done) + return nil } diff --git a/examples/pipes/main.go b/examples/pipes/main.go new file mode 100644 index 0000000..24f5f67 --- /dev/null +++ b/examples/pipes/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/xfrr/goffmpeg/v2/ffmpeg" + "github.com/xfrr/goffmpeg/v2/ffmpeg/progress" + "github.com/xfrr/goffmpeg/v2/ffprobe" +) + +var ( + defaultInputPath = "../../testdata/input.mp4" + outputPath = flag.String("o", "../results/pipes.mp4", "output path") +) + +func main() { + flag.Parse() + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + inputPath := flag.Arg(0) + if inputPath == "" { + inputPath = defaultInputPath + } + + inputFile, err := os.Open(inputPath) + if err != nil { + panic(err) + } + + err = createOutputDir(*outputPath) + if err != nil { + panic(err) + } + + outputFile, err := os.Create(*outputPath) + if err != nil { + panic(err) + } + + // the order of the arguments matters + cmd := ffmpeg.NewCommand(). + WithInputPipe(inputFile). + WithOutputFormat("mpeg"). + WithOutputPipe(outputFile) + + go func() { + progress, err := cmd.Start(ctx) + if err != nil { + panic(err) + } + + mediafile, err := ffprobe.NewCommand(). + WithInputPath(inputPath). + Run(ctx) + if err != nil { + panic(err) + } + + go func() { + for msg := range progress { + printProgress(msg, mediafile.GetDuration()) + } + }() + }() + + err = cmd.Wait() + if err != nil { + panic(err) + } +} + +func printProgress(p progress.Progress, tdur time.Duration) { + // totalDurMs is the total duration in milliseconds + tdur = time.Duration(tdur.Milliseconds()) * time.Millisecond + + // rdur is the remaining duration in milliseconds + rdur := p.Duration - tdur + + // convert to positive if negative + if rdur < 0 { + rdur = -rdur + } + + // remainingTimeStr is the remaining time in the format 00:00:00.000 + remainingTime := rdur.String() + + // progress is the percentage of the progress + progress := int(float64(p.Duration.Milliseconds()) / float64(tdur.Milliseconds()) * 100) + + fmt.Printf(` +Progress: +- Current Time: %s +- Remaining Time: %s +- Progress: %d%s +- Frames Processed: %d +- Current Bitrate: %f +- Size: %d +- Speed: %f +- Fps: %f +- Dup: %d +- Drop: %d +`, + p.Duration, + remainingTime, + progress, "%", + p.FramesProcessed, + p.Bitrate, + p.Size, + p.Speed, + p.Fps, + p.Dup, + p.Drop, + ) +} + +func createOutputDir(outputPath string) error { + err := os.MkdirAll(filepath.Dir(outputPath), 0755) + if err != nil { + return err + } + + return nil +} diff --git a/examples/rmtp-stream/main.go b/examples/rmtp-stream/main.go new file mode 100644 index 0000000..1c89cb0 --- /dev/null +++ b/examples/rmtp-stream/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/xfrr/goffmpeg/v2/ffmpeg" + "github.com/xfrr/goffmpeg/v2/ffmpeg/progress" +) + +// NOTE: This example requires a running rtmp server on localhost:1935 +// You can use the following docker image to run a rtmp server: +// docker run -d -p 1935:1935 --name nginx-rtmp tiangolo/nginx-rtmp +// Then you can use VLC to play the stream: rtmp://localhost/live/stream + +var ( + defaultInputPath = "../../testdata/input.mp4" + outputPath = flag.String("o", "rtmp://localhost/live/stream", "output path") +) + +func main() { + flag.Parse() + + inputPath := flag.Arg(0) + if inputPath == "" { + inputPath = defaultInputPath + } + + err := createOutputDir(*outputPath) + if err != nil { + panic(err) + } + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // the order of the arguments matters + cmd := ffmpeg.NewCommand(). + WithInputPath(inputPath). + WithOutputFormat("flv"). + WithOutputPath(*outputPath) + + go func() { + progress, err := cmd.Start(ctx) + if err != nil { + panic(err) + } + + go func() { + for p := range progress { + printProgress(p) + } + }() + }() + + err = cmd.Wait() + if err != nil { + panic(err) + } +} + +func printProgress(p progress.Progress) { + fmt.Printf(` +Progress: +- Time Streamed: %s +- Frames Processed: %d +- Bitrate: %f +- Size: %d +- Speed: %f +- Fps: %f +- Dup: %d +- Drop: %d + +`, + p.Duration, + p.FramesProcessed, + p.Bitrate, + p.Size, + p.Speed, + p.Fps, + p.Dup, + p.Drop, + ) +} + +func createOutputDir(outputPath string) error { + err := os.MkdirAll(filepath.Dir(outputPath), 0755) + if err != nil { + return err + } + + return nil +} diff --git a/examples/rtsp-stream/main.go b/examples/rtsp-stream/main.go new file mode 100644 index 0000000..b07cddc --- /dev/null +++ b/examples/rtsp-stream/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/xfrr/goffmpeg/v2/ffmpeg" + "github.com/xfrr/goffmpeg/v2/ffmpeg/progress" +) + +var ( + // use your own rtsp stream + defaultInputPath = "rtsp://localhost:8554/test" + outputPath = flag.String("o", "../results/rtsp.mp4", "output path") +) + +func main() { + flag.Parse() + + inputPath := flag.Arg(0) + if inputPath == "" { + inputPath = defaultInputPath + } + + err := createOutputDir(*outputPath) + if err != nil { + panic(err) + } + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // the order of the arguments matters + cmd := ffmpeg.NewCommand(). + WithInputPath(inputPath). + WithOutputFormat("mp4"). + WithOutputPath(*outputPath) + + go func() { + progress, err := cmd.Start(ctx) + if err != nil { + panic(err) + } + + go func() { + for p := range progress { + printProgress(p) + } + }() + }() + + err = cmd.Wait() + if err != nil { + panic(err) + } +} + +func printProgress(p progress.Progress) { + fmt.Printf(` +Progress: +- Time Recorded: %s +- Frames Processed: %d +- Bitrate: %f +- Size: %d +- Speed: %f +- Fps: %f +- Dup: %d +- Drop: %d + +`, + p.Duration, + p.FramesProcessed, + p.Bitrate, + p.Size, + p.Speed, + p.Fps, + p.Dup, + p.Drop, + ) +} + +func createOutputDir(outputPath string) error { + err := os.MkdirAll(filepath.Dir(outputPath), 0755) + if err != nil { + return err + } + + return nil +} diff --git a/examples/test_results/.gitkeep b/examples/test_results/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/examples/ultrafast-preset/main.go b/examples/ultrafast-preset/main.go deleted file mode 100644 index beec42b..0000000 --- a/examples/ultrafast-preset/main.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/xfrr/goffmpeg/transcoder" -) - -const ( - inputPath = "../fixtures/input.3gp" - outputPath = "../test_results/ultrafast-output.mp4" -) - -func main() { - trans := new(transcoder.Transcoder) - - err := trans.Initialize(inputPath, outputPath) - if err != nil { - panic(err) - } - - trans.MediaFile().SetPreset("ultrafast") - - done := trans.Run(true) - progress := trans.Output() - for p := range progress { - fmt.Println(p) - } - - fmt.Println(<-done) -} diff --git a/ffmpeg/advanced_options.go b/ffmpeg/advanced_options.go new file mode 100644 index 0000000..4fae804 --- /dev/null +++ b/ffmpeg/advanced_options.go @@ -0,0 +1,317 @@ +package ffmpeg + +// WithMap creates one or more streams in the output file. This option has two forms for specifying the data source(s): the first selects one or more streams from some input file (specified with -i), the second takes an output from some complex filtergraph (specified with -filter_complex or -filter_complex_script). +func (c *Command) WithMap(m string) *Command { + c.args = append(c.args, "-map", m) + return c +} + +// WithIgnoreUnknown ignore input streams with unknown type instead of failing if copying such streams is attempted. +func (c *Command) WithIgnoreUnknown() *Command { + c.args = append(c.args, "-ignore_unknown") + return c +} + +// WithCopyUnknown allow input streams with unknown type to be copied instead of failing if copying such streams is attempted. +func (c *Command) WithCopyUnknown() *Command { + c.args = append(c.args, "-copy_unknown") + return c +} + +// WithMapChannel creates an audio channel from a given input to an output. If output_file_id.stream_specifier is not set, the audio channel will be mapped on all the audio streams. +func (c *Command) WithMapChannel(m string) *Command { + c.args = append(c.args, "-map_channel", m) + return c +} + +// WithMapMetadata sets metadata information of the next output file from infile. Note that those are file indices (zero-based), not filenames. Optional metadata_spec_in/out parameters specify, which metadata to copy. A metadata specifier can have the following forms: +func (c *Command) WithMapMetadata(m string) *Command { + c.args = append(c.args, "-map_metadata", m) + return c +} + +// WithMapChapters copy chapters from input file with index input_file_index to the next output file. If no chapter mapping is specified, then chapters are copied from the first input file with at least one chapter. Use a negative file index to disable any chapter copying. +func (c *Command) WithMapChapters(m string) *Command { + c.args = append(c.args, "-map_chapters", m) + return c +} + +// WithBenchmark show benchmarking information at the end of an encode. Shows real, system and user time used and maximum memory consumption. Maximum memory consumption is not supported on all systems, it will usually display as 0 if not supported. +func (c *Command) WithBenchmark() *Command { + c.args = append(c.args, "-benchmark") + return c +} + +// WithBenchmarkAll show benchmarking information during the encode. Shows real, system and user time used in various steps (audio/video encode/decode). +func (c *Command) WithBenchmarkAll() *Command { + c.args = append(c.args, "-benchmark_all") + return c +} + +// WithTimelimit exit after ffmpeg has been running for duration seconds in CPU user time. +func (c *Command) WithTimelimit(t string) *Command { + c.args = append(c.args, "-timelimit", t) + return c +} + +// WithDump dump each input packet to stderr. +func (c *Command) WithDump() *Command { + c.args = append(c.args, "-dump") + return c +} + +// WithHex when dumping packets, also dump the payload. +func (c *Command) WithHex() *Command { + c.args = append(c.args, "-hex") + return c +} + +// WithVideoSync set video sync method. +func (c *Command) WithVideoSync(v string) *Command { + c.args = append(c.args, "-vsync", v) + return c +} + +// WithFpsMode set video sync method. +func (c *Command) WithFpsMode(v string) *Command { + c.args = append(c.args, "-fps_mode", v) + return c +} + +// WithFrameDropThreshold set frame drop threshold, which specifies how much behind video frames can be before they are dropped. In frame rate units, so 1.0 is one frame. The default is -1.1. One possible usecase is to avoid framedrops in case of noisy timestamps or to increase frame drop precision in case of exact timestamps. +func (c *Command) WithFrameDropThreshold(f string) *Command { + c.args = append(c.args, "-frame_drop_threshold", f) + return c +} + +// WithApad pad the output audio stream(s). This is the same as applying -af apad. Argument is a string of filter parameters composed the same as with the apad filter. -shortest must be set for this output for the option to take effect. +func (c *Command) WithApad(a string) *Command { + c.args = append(c.args, "-apad", a) + return c +} + +// WithCopyts do not process input timestamps, but keep their values without trying to sanitize them. In particular, do not remove the initial start time offset value. +func (c *Command) WithCopyts() *Command { + c.args = append(c.args, "-copyts") + return c +} + +// WithStartAtZero when used with copyts, shift input timestamps so they start at zero. +func (c *Command) WithStartAtZero() *Command { + c.args = append(c.args, "-start_at_zero") + return c +} + +// WithCopytb specify how to set the encoder timebase when stream copying. mode is an integer numeric value, and can assume one of the following values: +func (c *Command) WithCopytb(m string) *Command { + c.args = append(c.args, "-copytb", m) + return c +} + +// WithEncTimeBase set the encoder timebase. timebase can assume one of the following values: +func (c *Command) WithEncTimeBase(e string) *Command { + c.args = append(c.args, "-enc_time_base", e) + return c +} + +// WithBitexact enable bitexact mode for (de)muxer and (de/en)coder +func (c *Command) WithBitexact() *Command { + c.args = append(c.args, "-bitexact") + return c +} + +// WithShortest finish encoding when the shortest output stream ends. +func (c *Command) WithShortest() *Command { + c.args = append(c.args, "-shortest") + return c +} + +// WithShortestBufDuration the -shortest option may require buffering potentially large amounts of data when at least one of the streams is "sparse" (i.e. has large gaps between frames – this is typically the case for subtitles). +func (c *Command) WithShortestBufDuration(s string) *Command { + c.args = append(c.args, "-shortest_buf_duration", s) + return c +} + +// WithDtsDeltaThreshold timestamp discontinuity delta threshold, expressed as a decimal number of seconds. +func (c *Command) WithDtsDeltaThreshold(d string) *Command { + c.args = append(c.args, "-dts_delta_threshold", d) + return c +} + +// WithDtsErrorThreshold timestamp error delta threshold, expressed as a decimal number of seconds. +func (c *Command) WithDtsErrorThreshold(d string) *Command { + c.args = append(c.args, "-dts_error_threshold", d) + return c +} + +// WithMuxdelay set the maximum demux-decode delay. +func (c *Command) WithMuxdelay(m string) *Command { + c.args = append(c.args, "-muxdelay", m) + return c +} + +// WithMuxpreload set the initial demux-decode delay. +func (c *Command) WithMuxpreload(m string) *Command { + c.args = append(c.args, "-muxpreload", m) + return c +} + +// WithStreamID assign a new stream-id value to an output stream. This option should be specified prior to the output filename to which it applies. For the situation where multiple output files exist, a streamid may be reassigned to a different value. +func (c *Command) WithStreamID(s string) *Command { + c.args = append(c.args, "-streamid", s) + return c +} + +// WithBsf set bitstream filters for matching streams. bitstream_filters is a comma-separated list of bitstream filters. Use the -bsfs option to get the list of bitstream filters. +func (c *Command) WithBsf(b string) *Command { + c.args = append(c.args, "-bsf", b) + return c +} + +// WithTag force a tag/fourcc for matching streams. +func (c *Command) WithTag(t string) *Command { + c.args = append(c.args, "-tag", t) + return c +} + +// WithTimecode specify Timecode for writing. SEP is ’:’ for non drop timecode and ’;’ (or ’.’) for drop. +func (c *Command) WithTimecode(t string) *Command { + c.args = append(c.args, "-timecode", t) + return c +} + +// WithFilterComplex define a complex filtergraph, i.e. one with arbitrary number of inputs and/or outputs. For simple graphs – those with one input and one output of the same type – see the -filter options. filtergraph is a description of the filtergraph, as described in the “Filtergraph syntax” section of the ffmpeg-filters manual. +func (c *Command) WithFilterComplex(f string) *Command { + c.args = append(c.args, "-filter_complex", f) + return c +} + +// WithFilterComplexThreads defines how many threads are used to process a filter_complex graph. Similar to filter_threads but used for -filter_complex graphs only. The default is the number of available CPUs. +func (c *Command) WithFilterComplexThreads(f string) *Command { + c.args = append(c.args, "-filter_complex_threads", f) + return c +} + +// WithLavfi define a complex filtergraph, i.e. one with arbitrary number of inputs and/or outputs. Equivalent to -filter_complex. +func (c *Command) WithLavfi(l string) *Command { + c.args = append(c.args, "-lavfi", l) + return c +} + +// WithFilterComplexScript this option is similar to -filter_complex, the only difference is that its argument is the name of the file from which a complex filtergraph description is to be read. +func (c *Command) WithFilterComplexScript(f string) *Command { + c.args = append(c.args, "-filter_complex_script", f) + return c +} + +// WithAccurateSeek this option enables or disables accurate seeking in input files with the -ss option. It is enabled by default, so seeking is accurate when transcoding. Use -noaccurate_seek to disable it, which may be useful e.g. when copying some streams and transcoding the others. +func (c *Command) WithAccurateSeek() *Command { + c.args = append(c.args, "-accurate_seek") + return c +} + +// WithSeekTimestamp this option enables or disables seeking by timestamp in input files with the -ss option. It is disabled by default. If enabled, the argument to the -ss option is considered an actual timestamp, and is not offset by the start time of the file. This matters only for files which do not start from timestamp 0, such as transport streams. +func (c *Command) WithSeekTimestamp() *Command { + c.args = append(c.args, "-seek_timestamp") + return c +} + +// WithThreadQueueSize for input, this option sets the maximum number of queued packets when reading from the file or device. With low latency / high rate live streams, packets may be discarded if they are not read in a timely manner; setting this value can force ffmpeg to use a separate input thread and read packets as soon as they arrive. By default ffmpeg only does this if multiple inputs are specified. +func (c *Command) WithThreadQueueSize(t string) *Command { + c.args = append(c.args, "-thread_queue_size", t) + return c +} + +// WithSdpFile print sdp information for an output stream to file. This allows dumping sdp information when at least one output isn’t an rtp stream. (Requires at least one of the output formats to be rtp). +func (c *Command) WithSdpFile(s string) *Command { + c.args = append(c.args, "-sdp_file", s) + return c +} + +// WithDiscard allows discarding specific streams or frames from streams. Any input stream can be fully discarded, using value all whereas selective discarding of frames from a stream occurs at the demuxer and is not supported by all demuxers. +func (c *Command) WithDiscard(d string) *Command { + c.args = append(c.args, "-discard", d) + return c +} + +// WithAbortOn Stop and abort on various conditions. The following flags are available: +// +// empty_output: No packets were passed to the muxer, the output is empty. +// +// empty_output_stream: No packets were passed to the muxer in some of the output streams. +func (c *Command) WithAbortOn(a string) *Command { + c.args = append(c.args, "-abort_on", a) + return c +} + +// WithMaxErrorRate set fraction of decoding frame failures across all inputs which when crossed ffmpeg will return exit code 69. Crossing this threshold does not terminate processing. Range is a floating-point number between 0 to 1. Default is 2/3. +func (c *Command) WithMaxErrorRate(m string) *Command { + c.args = append(c.args, "-max_error_rate", m) + return c +} + +// WithXerror stop and exit on error +func (c *Command) WithXerror() *Command { + c.args = append(c.args, "-xerror") + return c +} + +// WithMaxMuxingQueueSize when transcoding audio and/or video streams, ffmpeg will not begin writing into the output until it has one packet for each such stream. While waiting for that to happen, packets for other streams are buffered. This option sets the size of this buffer, in packets, for the matching output stream. +func (c *Command) WithMaxMuxingQueueSize(m string) *Command { + c.args = append(c.args, "-max_muxing_queue_size", m) + return c +} + +// WithMuxingQueueDataThreshold this is a minimum threshold until which the muxing queue size is not taken into account. Defaults to 50 megabytes per stream, and is based on the overall size of packets passed to the muxer. +func (c *Command) WithMuxingQueueDataThreshold(m string) *Command { + c.args = append(c.args, "-muxing_queue_data_threshold", m) + return c +} + +// WithAutoConversionFilters enable automatically inserting format conversion filters in all filter graphs, including those defined by -vf, -af, -filter_complex and -lavfi. If filter format negotiation requires a conversion, the initialization of the filters will fail. Conversions can still be performed by inserting the relevant conversion filter (scale, aresample) in the graph. On by default, to explicitly disable it you need to specify -noauto_conversion_filters. +func (c *Command) WithAutoConversionFilters() *Command { + c.args = append(c.args, "-auto_conversion_filters") + return c +} + +// WithBitsPerRawSample declare the number of bits per raw sample in the given output stream to be value. Note that this option sets the information provided to the encoder/muxer, it does not change the stream to conform to this value. Setting values that do not match the stream properties may result in encoding failures or invalid output files. +func (c *Command) WithBitsPerRawSample(b string) *Command { + c.args = append(c.args, "-bits_per_raw_sample", b) + return c +} + +// WithStatsEncPre write per-frame encoding information about the matching streams into the file given by path. +func (c *Command) WithStatsEncPre(s string) *Command { + c.args = append(c.args, "-stats_enc_pre", s) + return c +} + +// WithStatsEncPost write per-frame encoding information about the matching streams into the file given by path. +func (c *Command) WithStatsEncPost(s string) *Command { + c.args = append(c.args, "-stats_enc_post", s) + return c +} + +// WithStatsMuxPre write per-frame encoding information about the matching streams into the file given by path. +func (c *Command) WithStatsMuxPre(s string) *Command { + c.args = append(c.args, "-stats_mux_pre", s) + return c +} + +// WithStatsEncPreFmt specify the format for the lines written with -stats_enc_pre / -stats_enc_post / -stats_mux_pre. +func (c *Command) WithStatsEncPreFmt(s string) *Command { + c.args = append(c.args, "-stats_enc_pre_fmt", s) + return c +} + +// WithStatsEncPostFmt specify the format for the lines written with -stats_enc_pre / -stats_enc_post / -stats_mux_pre. +func (c *Command) WithStatsEncPostFmt(s string) *Command { + c.args = append(c.args, "-stats_enc_post_fmt", s) + return c +} + +// WithStatsMuxPreFmt specify the format for the lines written with -stats_enc_pre / -stats_enc_post / -stats_mux_pre. +func (c *Command) WithStatsMuxPreFmt(s string) *Command { + c.args = append(c.args, "-stats_mux_pre_fmt", s) + return c +} diff --git a/ffmpeg/advanced_options_test.go b/ffmpeg/advanced_options_test.go new file mode 100644 index 0000000..95f8877 --- /dev/null +++ b/ffmpeg/advanced_options_test.go @@ -0,0 +1 @@ +package ffmpeg diff --git a/ffmpeg/audio_options.go b/ffmpeg/audio_options.go new file mode 100644 index 0000000..a8c48b5 --- /dev/null +++ b/ffmpeg/audio_options.go @@ -0,0 +1,93 @@ +package ffmpeg + +import "strconv" + +// WithAudioFrames sets the number of audio frames to record. +func (c *Command) WithAudioFrames(frames int) *Command { + c.args = append(c.args, "-aframes", strconv.Itoa(frames)) + return c +} + +// WithAudioQuality sets the audio quality. +func (c *Command) WithAudioQuality(quality int) *Command { + c.args = append(c.args, "-aq", strconv.Itoa(quality)) + return c +} + +// WithAudioRate sets the audio sampling rate. +func (c *Command) WithAudioRate(rate int) *Command { + c.args = append(c.args, "-ar", strconv.Itoa(rate)) + return c +} + +// WithAudioChannels sets the number of audio channels. +func (c *Command) WithAudioChannels(channels int) *Command { + c.args = append(c.args, "-ac", strconv.Itoa(channels)) + return c +} + +// WithDisableAudio disables audio. +func (c *Command) WithDisableAudio() *Command { + c.args = append(c.args, "-an") + return c +} + +// WithAudioCodec sets the audio codec. +func (c *Command) WithAudioCodec(codec string) *Command { + c.args = append(c.args, "-acodec", codec) + return c +} + +// WithVolume sets the audio volume. +func (c *Command) WithVolume(volume int) *Command { + c.args = append(c.args, "-vol", strconv.Itoa(volume)) + return c +} + +// WithAudioFilters creates a complex filtergraph. +func (c *Command) WithAudioFilters(filters string) *Command { + c.args = append(c.args, "-af", filters) + return c +} + +// WithAudioTag sets the audio tag. +func (c *Command) WithAudioTag(tag string) *Command { + c.args = append(c.args, "-atag", tag) + return c +} + +// WithAudioSampleFormat sets the sample format. +func (c *Command) WithAudioSampleFormat(format string) *Command { + c.args = append(c.args, "-sample_fmt", format) + return c +} + +// WithAudioChannelLayout sets the channel layout. +func (c *Command) WithAudioChannelLayout(layout string) *Command { + c.args = append(c.args, "-channel_layout", layout) + return c +} + +// WithAudioGuessLayoutMax sets the maximum number of channels to try to guess the channel layout. +func (c *Command) WithAudioGuessLayoutMax(max int) *Command { + c.args = append(c.args, "-guess_layout_max", strconv.Itoa(max)) + return c +} + +// WithAudioBitstreamFilters sets the audio bitstream filters. +func (c *Command) WithAudioBitstreamFilters(filters string) *Command { + c.args = append(c.args, "-absf", filters) + return c +} + +// WithAudioPreset sets the audio options to the indicated preset. +func (c *Command) WithAudioPreset(preset string) *Command { + c.args = append(c.args, "-apre", preset) + return c +} + +// WithAudioProfile sets the audio profile. +func (c *Command) WithAudioProfile(profile string) *Command { + c.args = append(c.args, "-profile:a", profile) + return c +} diff --git a/ffmpeg/audio_options_test.go b/ffmpeg/audio_options_test.go new file mode 100644 index 0000000..39e7287 --- /dev/null +++ b/ffmpeg/audio_options_test.go @@ -0,0 +1,137 @@ +package ffmpeg_test + +import ( + "testing" + + "github.com/xfrr/goffmpeg/v2/ffmpeg" +) + +func TestAudioOptions(t *testing.T) { + testCases := []struct { + name string + expected []string + fn func(*ffmpeg.Command) *ffmpeg.Command + }{ + { + name: "WithAudioFrames", + expected: []string{"-aframes", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioFrames(10) + }, + }, + { + name: "WithAudioQuality", + expected: []string{"-aq", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioQuality(10) + }, + }, + { + name: "WithAudioRate", + expected: []string{"-ar", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioRate(10) + }, + }, + { + name: "WithAudioChannels", + expected: []string{"-ac", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioChannels(10) + }, + }, + { + name: "WithDisableAudio", + expected: []string{"-an"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithDisableAudio() + }, + }, + { + name: "WithAudioCodec", + expected: []string{"-acodec", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioCodec("10") + }, + }, + { + name: "WithVolume", + expected: []string{"-vol", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVolume(10) + }, + }, + { + name: "WithAudioFilters", + expected: []string{"-af", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioFilters("10") + }, + }, + { + name: "WithAudioTag", + expected: []string{"-atag", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioTag("10") + }, + }, + { + name: "WithAudioSampleFormat", + expected: []string{"-sample_fmt", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioSampleFormat("10") + }, + }, + { + name: "WithAudioChannelLayout", + expected: []string{"-channel_layout", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioChannelLayout("10") + }, + }, + { + name: "WithAudioGuessLayoutMax", + expected: []string{"-guess_layout_max", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioGuessLayoutMax(10) + }, + }, + { + name: "WithAudioBitstreamFilters", + expected: []string{"-absf", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioBitstreamFilters("10") + }, + }, + { + name: "WithAudioPreset", + expected: []string{"-apreset", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioPreset("10") + }, + }, + { + name: "WithAudioProfile", + expected: []string{"-profile:a", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAudioProfile("10") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := ffmpeg.NewCommand() + cmd = tc.fn(cmd) + if len(cmd.Args()) != len(tc.expected)+len(ffmpeg.DefaultArgs()) { + t.Fatalf("expected %d args, got %d", len(tc.expected), len(cmd.Args())) + } + + for i := len(ffmpeg.DefaultArgs()) - 1; i < len(tc.expected); i++ { + if cmd.Args()[i] != tc.expected[i] { + t.Fatalf("expected %s, got %s", tc.expected[i], cmd.Args()[i]) + } + } + }) + } +} diff --git a/ffmpeg/command.go b/ffmpeg/command.go new file mode 100644 index 0000000..4a233b3 --- /dev/null +++ b/ffmpeg/command.go @@ -0,0 +1,112 @@ +package ffmpeg + +import ( + "context" + "fmt" + "io" + "os/exec" + + "github.com/xfrr/goffmpeg/v2/ffmpeg/progress" +) + +type Command struct { + cmd *exec.Cmd + binPath string + args []string + inputReader io.Reader + outputWriter io.Writer + progressReader progress.Reader + err chan error +} + +func (c Command) Args() []string { + return c.args +} + +// WithBinPath sets the path to the ffmpeg binary. +func (c *Command) WithBinPath(binPath string) *Command { + c.binPath = binPath + return c +} + +func NewCommand() *Command { + return &Command{ + binPath: "ffmpeg", + cmd: &exec.Cmd{}, + args: DefaultArgs(), + inputReader: nil, + outputWriter: nil, + progressReader: progress.NewReader(), + err: make(chan error, 1), + } +} + +func DefaultArgs() []string { + return []string{ + "-nostats", + "-loglevel", "error", + "-y", + } +} + +func (c *Command) Run(ctx context.Context) error { + if len(c.args) == 0 { + return fmt.Errorf("command not initialized") + } + + c.cmd = exec.CommandContext(ctx, c.binPath, c.args...) + if c.inputReader != nil { + c.cmd.Stdin = c.inputReader + } + + if c.outputWriter != nil { + c.cmd.Stdout = c.outputWriter + } + + return c.cmd.Run() +} + +func (c *Command) Start(ctx context.Context) (<-chan progress.Progress, error) { + if len(c.args) == 0 { + return nil, fmt.Errorf("command not initialized") + } + + c.args = append([]string{"-progress", "pipe:2"}, c.args...) + c.cmd = exec.CommandContext(ctx, c.binPath, c.args...) + + if c.inputReader != nil { + c.cmd.Stdin = c.inputReader + } + + if c.outputWriter != nil { + c.cmd.Stdout = c.outputWriter + } + + stderr, err := c.cmd.StderrPipe() + if err != nil { + return nil, err + } + + progressCh := make(chan progress.Progress, 10) + + go func() { + defer close(progressCh) + c.err <- c.progressReader.Read(ctx, stderr, progressCh) + }() + + err = c.cmd.Start() + if err != nil { + return nil, err + } + + return progressCh, nil +} + +func (c *Command) Wait() error { + defer close(c.err) + err := <-c.err + if err != nil { + return err + } + return c.cmd.Wait() +} diff --git a/ffmpeg/command_test.go b/ffmpeg/command_test.go new file mode 100755 index 0000000..e007f75 --- /dev/null +++ b/ffmpeg/command_test.go @@ -0,0 +1,90 @@ +package ffmpeg_test + +import ( + "context" + "os" + "os/exec" + "path" + "testing" + + "github.com/xfrr/goffmpeg/v2/ffmpeg" + "github.com/xfrr/goffmpeg/v2/ffmpeg/progress" +) + +const ( + fixturePath = "../testdata/" + resultsPath = "./results" +) + +var ( + inputPath = path.Join(fixturePath, "input.mp4") +) + +func TestCommandWithProgress(t *testing.T) { + createResultsDir(t) + + outputPath := path.Join(resultsPath, "progress.mpg") + + cmd := ffmpeg.NewCommand(). + WithInputPath(inputPath). + WithOutputPath(outputPath) + + progressCh, err := cmd.Start(context.Background()) + if err != nil { + panic(err) + } + + progress := []progress.Progress{} + for val := range progressCh { + progress = append(progress, val) + } + + err = cmd.Wait() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(progress) == 0 { + t.Fatal("expected progress to be reported") + } + + if !fileExists(outputPath) { + t.Fatal("expected output file to exist") + } +} + +func TestCommandWithoutProgress(t *testing.T) { + createResultsDir(t) + + outputPath := path.Join(resultsPath, "no-progress.mpg") + + cmd := ffmpeg.NewCommand(). + WithInputPath(inputPath). + WithOutputPath(outputPath) + + err := cmd.Run(context.Background()) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !fileExists(outputPath) { + t.Fatal("expected output file to exist") + } + +} + +func createResultsDir(t *testing.T) { + err := exec.Command("mkdir", "-p", resultsPath).Run() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func fileExists(path string) bool { + fileInfo, err := os.Stat(path) + if err != nil { + return false + } + + return !fileInfo.IsDir() +} diff --git a/ffmpeg/error/error.go b/ffmpeg/error/error.go new file mode 100644 index 0000000..f447859 --- /dev/null +++ b/ffmpeg/error/error.go @@ -0,0 +1,8 @@ +package fferror + +import "fmt" + +var ( + ErrUnableToFindOutputFormat = fmt.Errorf("unable to find a suitable output format") + ErrInputFileNotFound = fmt.Errorf("no such file or directory") +) diff --git a/ffmpeg/global_options.go b/ffmpeg/global_options.go new file mode 100644 index 0000000..025382b --- /dev/null +++ b/ffmpeg/global_options.go @@ -0,0 +1,100 @@ +package ffmpeg + +import ( + "fmt" + "strconv" +) + +// WithReport enables the ffmpeg report. +func (c *Command) WithReport() *Command { + c.args = append(c.args, "-report") + return c +} + +// WithMaxAlloc sets the maximum size of a single allocated block. +func (c *Command) WithMaxAlloc(bytes int) *Command { + c.args = append(c.args, "-max_alloc", strconv.Itoa(bytes)) + return c +} + +// WithFilterThreads sets the number of non-complex filter threads. +func (c *Command) WithFilterThreads(threads int) *Command { + c.args = append(c.args, "-filter_threads", strconv.Itoa(threads)) + return c +} + +// WithStdin enables or disables interaction on standard input. +func (c *Command) WithStdin(enabled bool) *Command { + if enabled { + c.args = append(c.args, "-stdin") + } else { + c.args = append(c.args, "-nostdin") + } + return c +} + +// WithTimeLimit sets the max runtime in seconds. +func (c *Command) WithTimeLimit(seconds int) *Command { + c.args = append(c.args, "-timelimit", strconv.Itoa(seconds)) + return c +} + +// WithAudioDriftThreshold sets the audio drift threshold. +func (c *Command) WithAudioDriftThreshold(threshold int) *Command { + c.args = append(c.args, "-adrift_threshold", strconv.Itoa(threshold)) + return c +} + +// WithCopyTS copies timestamps. +func (c *Command) WithCopyTS() *Command { + c.args = append(c.args, "-copyts") + return c +} + +// WithCopyTB copies input stream time base when stream copying. +func (c *Command) WithCopyTB(mode string) *Command { + c.args = append(c.args, "-copytb", mode) + return c +} + +// WithDTSDeltaThreshold sets the timestamp discontinuity delta threshold. +func (c *Command) WithDTSDeltaThreshold(threshold int) *Command { + c.args = append(c.args, "-dts_delta_threshold", strconv.Itoa(threshold)) + return c +} + +// WithDTSErrorThreshold sets the timestamp error delta threshold. +func (c *Command) WithDTSErrorThreshold(threshold int) *Command { + c.args = append(c.args, "-dts_error_threshold", strconv.Itoa(threshold)) + return c +} + +// WithXError exits on error. +func (c *Command) WithXError() *Command { + c.args = append(c.args, "-xerror") + return c +} + +// WithDebugTS prints timestamp debugging info. +func (c *Command) WithDebugTS() *Command { + c.args = append(c.args, "-debug_ts") + return c +} + +// WithVStatsFile dumps video coding statistics to file. +func (c *Command) WithVStatsFile(file string) *Command { + c.args = append(c.args, "-vstats_file", file) + return c +} + +// WithISync assigns an input as a sync source. +func (c *Command) WithISync() *Command { + c.args = append(c.args, "-isync") + return c +} + +// WithMaxBitrate sets the max bitrate in kbit/s. +func (c *Command) WithMaxBitrate(bitrate int) *Command { + c.args = append(c.args, "-maxrate", fmt.Sprintf("%dk", bitrate)) + return c +} diff --git a/ffmpeg/global_options_test.go b/ffmpeg/global_options_test.go new file mode 100644 index 0000000..95f8877 --- /dev/null +++ b/ffmpeg/global_options_test.go @@ -0,0 +1 @@ +package ffmpeg diff --git a/ffmpeg/hls_options.go b/ffmpeg/hls_options.go new file mode 100644 index 0000000..5531aa5 --- /dev/null +++ b/ffmpeg/hls_options.go @@ -0,0 +1,33 @@ +package ffmpeg + +import "strconv" + +func (c *Command) WithHLSKeyInfoPath(path string) *Command { + c.args = append(c.args, "-hls_key_info_file", path) + return c +} + +func (c *Command) WithHLSSegmentTime(seconds int) *Command { + c.args = append(c.args, "-hls_time", strconv.Itoa(seconds)) + return c +} + +func (c *Command) WithHLSSegmentFilename(filename string) *Command { + c.args = append(c.args, "-hls_segment_filename", filename) + return c +} + +func (c *Command) WithHLSSegmentStartNumber(number int) *Command { + c.args = append(c.args, "-start_number", strconv.Itoa(number)) + return c +} + +func (c *Command) WithHLSSegmentListSize(size int) *Command { + c.args = append(c.args, "-hls_list_size", strconv.Itoa(size)) + return c +} + +func (c *Command) WithHLSSegmentListType(typ string) *Command { + c.args = append(c.args, "-hls_playlist_type", typ) + return c +} diff --git a/ffmpeg/hls_options_test.go b/ffmpeg/hls_options_test.go new file mode 100644 index 0000000..95f8877 --- /dev/null +++ b/ffmpeg/hls_options_test.go @@ -0,0 +1 @@ +package ffmpeg diff --git a/ffmpeg/main_options.go b/ffmpeg/main_options.go new file mode 100644 index 0000000..fac58e0 --- /dev/null +++ b/ffmpeg/main_options.go @@ -0,0 +1,235 @@ +package ffmpeg + +import ( + "io" + "strconv" +) + +// WithInputPath sets the input path to the given path. +func (c *Command) WithInputPath(path string) *Command { + c.args = append(c.args, "-i", path) + return c +} + +// WithInputPipe sets the input pipe reader (stdin). +func (c *Command) WithInputPipe(r io.Reader) *Command { + c.args = append(c.args, "-i", "pipe:0") + c.inputReader = r + return c +} + +// WithInputFormat sets the input format to the given format. +func (c *Command) WithOutputPath(path string) *Command { + c.args = append(c.args, path) + return c +} + +// WithOutputPipe sets the output pipe writer to the given writer (stdout) +func (c *Command) WithOutputPipe(w io.Writer) *Command { + c.args = append(c.args, "pipe:1") + c.outputWriter = w + return c +} + +// WithOutputFormat sets the output format to the given format. +func (c *Command) WithOutputFormat(format string) *Command { + c.args = append(c.args, "-f", format) + return c +} + +// WithCodec selects an encoder (when used before an output file) or a decoder (when used before an input file) for one or more streams. +// Codec is the name of a decoder/encoder or a special value copy (output only) to indicate that the stream is not to be re-encoded. +func (c *Command) WithCodec(codec string) *Command { + c.args = append(c.args, "-c", codec) + return c +} + +// WithPreset sets the preset for matching stream(s). +func (c *Command) WithPreset(preset string) *Command { + c.args = append(c.args, "-preset", preset) + return c +} + +// WithDuration when used as an input option (before -i), limit the duration of data read from the input file. +// When used as an output option (before an output url), stop writing the output after its duration reaches duration. +// duration must be a time duration specification, see (ffmpeg-utils)the Time duration section in the ffmpeg-utils(1) manual. +// -to and -t are mutually exclusive and -t has priority. +func (c *Command) WithDuration(duration string) *Command { + c.args = append(c.args, "-t", duration) + return c +} + +// WithStopTime stop writing the output or reading the input at position. position must be a time duration specification. +func (c *Command) WithStopTime(time string) *Command { + c.args = append(c.args, "-to", time) + return c +} + +// WithOutputLimitFileSize sets the file size limit, expressed in bytes. +// No further chunk of bytes is written after the limit is exceeded. +// The size of the output file is slightly more than the requested file size. +func (c *Command) WithLimitFileSize(size int) *Command { + c.args = append(c.args, "-fs", strconv.Itoa(size)) + return c +} + +// WithStartTimeOffset when used as an input option (before -i), seeks in this input file to position. +// Note that in most formats it is not possible to seek exactly, so ffmpeg will seek to the closest seek point before position. +// When transcoding and -accurate_seek is enabled (the default), this extra segment between the seek point and position will be decoded and discarded. +// When doing stream copy or when -noaccurate_seek is used, it will be preserved. +// When used as an output option (before an output url), decodes but discards input until the timestamps reach position. +func (c *Command) WithStartTimeOffset(offset string) *Command { + c.args = append(c.args, "-ss", offset) + return c +} + +// WithTimestamp sets the recording timestamp in the container. +func (c *Command) WithTimestamp(timestamp string) *Command { + c.args = append(c.args, "-timestamp", timestamp) + return c +} + +// WithMetadata sets metadata information to the output file. +func (c *Command) WithMetadata(metadata string) *Command { + c.args = append(c.args, "-metadata", metadata) + return c +} + +// WithTarget sets the target file type. +func (c *Command) WithTarget(typ string) *Command { + c.args = append(c.args, "-target", typ) + return c +} + +// WithAudioPad adds "padding" audio frames. +func (c *Command) WithAudioPad() *Command { + c.args = append(c.args, "-apad") + return c +} + +// WithFrames sets the number of frames to record. +func (c *Command) WithFrames(number int) *Command { + c.args = append(c.args, "-frames", strconv.Itoa(number)) + return c +} + +// WithFilter sets stream filtergraph. +func (c *Command) WithFilter(filter string) *Command { + c.args = append(c.args, "-filter", filter) + return c +} + +// WithFilterScript sets read stream filtergraph description from a file. +func (c *Command) WithFilterScript(filename string) *Command { + c.args = append(c.args, "-filter_script", filename) + return c +} + +// WithReinitFilter reinit filtergraph on input parameter changes. +func (c *Command) WithReinitFilter() *Command { + c.args = append(c.args, "-reinit_filter") + return c +} + +// WithInputTsOffset set the input ts offset. +func (c *Command) WithInputTsOffset(offset string) *Command { + c.args = append(c.args, "-itsoffset", offset) + return c +} + +// WithInputTsScale set the input ts scale. +func (c *Command) WithInputTsScale(scale string) *Command { + c.args = append(c.args, "-itsscale", scale) + return c +} + +// WithDataFrames set the number of data frames to record. +func (c *Command) WithDataFrames(number int) *Command { + c.args = append(c.args, "-dframes", strconv.Itoa(number)) + return c +} + +// WithReadInputAtNativeFrameRate reads input at native frame rate. +func (c *Command) WithReadInputAtNativeFrameRate() *Command { + c.args = append(c.args, "-re") + return c +} + +// WithCopyInitialNonKeyframes copies non-keyframes initially. +func (c *Command) WithCopyInitialNonKeyframes() *Command { + c.args = append(c.args, "-copyinkf") + return c +} + +// WithCopyOrDiscardFramesBeforeStartTime copies or discards frames before start time. +func (c *Command) WithCopyOrDiscardFramesBeforeStartTime() *Command { + c.args = append(c.args, "-copypriorss") + return c +} + +// WithForceCodecTag forces codec tag/fourcc. +func (c *Command) WithForceCodecTag(tag string) *Command { + c.args = append(c.args, "-tag", tag) + return c +} + +// WithFixedQualityScale sets fixed quality scale (VBR). +func (c *Command) WithFixedQualityScale(q int) *Command { + c.args = append(c.args, "-q", strconv.Itoa(q)) + return c +} + +// WithAttachment adds an attachment to the output file. +func (c *Command) WithAttachment(filename string) *Command { + c.args = append(c.args, "-attach", filename) + return c +} + +// WithExtractAttachment extracts an attachment into a file. +func (c *Command) WithExtractAttachment(filename string) *Command { + c.args = append(c.args, "-dump_attachment", filename) + return c +} + +// WithMaximumDemuxDecodeDelay sets maximum demux-decode delay. +func (c *Command) WithMaximumDemuxDecodeDelay(seconds int) *Command { + c.args = append(c.args, "-muxdelay", strconv.Itoa(seconds)) + return c +} + +// WithInitialDemuxDecodeDelay sets initial demux-decode delay. +func (c *Command) WithInitialDemuxDecodeDelay(seconds int) *Command { + c.args = append(c.args, "-muxpreload", strconv.Itoa(seconds)) + return c +} + +// WithBitstreamFilters sets bitstream filters for matching streams. +func (c *Command) WithBitstreamFilters(filters string) *Command { + c.args = append(c.args, "-bsf", filters) + return c +} + +// WithPresetFile sets the preset file for matching stream(s). +func (c *Command) WithPresetFile(filename string) *Command { + c.args = append(c.args, "-fpre", filename) + return c +} + +// WithForceDataCodec forces data codec (‘copy’ to copy stream). +func (c *Command) WithForceDataCodec(codec string) *Command { + c.args = append(c.args, "-dcodec", codec) + return c +} + +// WithStreamLoop sets number of times input stream shall be looped. Loop 0 means no loop, loop -1 means infinite loop. +func (c *Command) WithStreamLoop(number int) *Command { + c.args = append(c.args, "-stream_loop", strconv.Itoa(number)) + return c +} + +// WithRecastMedia forces a decoder of a different media type than the one detected or designated by the demuxer. +// Useful for decoding media data muxed as data streams. +func (c *Command) WithRecastMedia() *Command { + c.args = append(c.args, "-recast_media") + return c +} diff --git a/ffmpeg/main_options_test.go b/ffmpeg/main_options_test.go new file mode 100644 index 0000000..95f8877 --- /dev/null +++ b/ffmpeg/main_options_test.go @@ -0,0 +1 @@ +package ffmpeg diff --git a/ffmpeg/progress/progress.go b/ffmpeg/progress/progress.go new file mode 100644 index 0000000..e447dbf --- /dev/null +++ b/ffmpeg/progress/progress.go @@ -0,0 +1,14 @@ +package progress + +import "time" + +type Progress struct { + FramesProcessed int64 + Bitrate float64 + Speed float64 + Size int64 + Fps float64 + Drop int32 + Dup int32 + Duration time.Duration +} diff --git a/ffmpeg/progress/reader.go b/ffmpeg/progress/reader.go new file mode 100644 index 0000000..19787ba --- /dev/null +++ b/ffmpeg/progress/reader.go @@ -0,0 +1,218 @@ +package progress + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + fferror "github.com/xfrr/goffmpeg/v2/ffmpeg/error" +) + +// Reader sets the interface for reading progress from ffmpeg. +type Reader interface { + // Read reads from r until EOF or an error occurs and writes to progress channel. + Read(ctx context.Context, r io.Reader, progress chan<- Progress) error +} + +type reader struct { + buffSize int +} + +func NewReader() Reader { + return &reader{ + buffSize: 1024, + } +} + +func (dr *reader) Read(ctx context.Context, r io.Reader, progress chan<- Progress) error { + scanner := bufio.NewScanner(r) + scanner.Split(splitFunc) + + buf := make([]byte, dr.buffSize) + scanner.Buffer(buf, bufio.MaxScanTokenSize) + + p := &Progress{} + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + line := scanner.Text() + + if err := checkError(line); err != nil { + return err + } + + if strings.HasPrefix(line, "frame=") { + p.FramesProcessed = parseFrame(line) + continue + } + + if strings.HasPrefix(line, "fps=") { + p.Fps = parseFps(line) + continue + } + + if strings.HasPrefix(line, "bitrate=") { + p.Bitrate = parseBitrate(line) + continue + } + + if strings.HasPrefix(line, "total_size=") { + p.Size = parseSize(line) + continue + } + + if strings.HasPrefix(line, "out_time_ms=") { + p.Duration = parseOutTimeMs(line) + continue + } + + if strings.HasPrefix(line, "dup_frames=") { + p.Dup = parseDupFrames(line) + continue + } + + if strings.HasPrefix(line, "drop_frames=") { + p.Drop = parseDropFrames(line) + continue + } + + if strings.HasPrefix(line, "speed=") { + p.Speed = parseSpeed(line) + continue + } + + if strings.Contains(line, "progress=continue") { + progress <- *p + p = &Progress{} + continue + } + + if strings.Contains(line, "progress=end") { + progress <- *p + return nil + } + } + } + + return scanner.Err() +} + +func checkError(line string) error { + if strings.Contains(line, "Unable to find a suitable output format") { + return fferror.ErrUnableToFindOutputFormat + } + + if strings.Contains(line, "No such file or directory") { + return fferror.ErrInputFileNotFound + } + + return nil +} + +func parseFrame(line string) int64 { + var i int64 + _, err := fmt.Sscanf(line, "frame=%d", &i) + if err != nil { + return 0 + } + + return i +} + +func parseFps(line string) float64 { + var f float64 + _, err := fmt.Sscanf(line, "fps=%f", &f) + if err != nil { + return 0 + } + + return f +} + +func parseBitrate(line string) float64 { + var f float64 + _, err := fmt.Sscanf(line, "bitrate=%f", &f) + if err != nil { + return 0 + } + + return f +} + +func parseSize(line string) int64 { + var s int64 + _, err := fmt.Sscanf(line, "total_size=%d", &s) + if err != nil { + return 0 + } + + return s +} + +func parseOutTimeMs(line string) time.Duration { + var i int64 + _, err := fmt.Sscanf(line, "out_time_ms=%d", &i) + if err != nil { + return 0 + } + + return time.Duration(i/1000) * time.Millisecond +} + +func parseDupFrames(line string) int32 { + var i int32 + _, err := fmt.Sscanf(line, "dup_frames=%d", &i) + if err != nil { + return 0 + } + + return i +} + +func parseDropFrames(line string) int32 { + var i int32 + _, err := fmt.Sscanf(line, "drop_frames=%d", &i) + if err != nil { + return 0 + } + + return i +} + +func parseSpeed(line string) float64 { + var f float64 + _, err := fmt.Sscanf(line, "speed=%f", &f) + if err != nil { + return 0 + } + + return f +} + +func splitFunc(data []byte, atEOF bool) (advance int, token []byte, spliterror error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + //windows \r\n + //so first \r and then \n can remove unexpected line break + if i := bytes.IndexByte(data, '\r'); i >= 0 { + // We have a cr terminated line + return i + 1, data[0:i], nil + } + if i := bytes.IndexByte(data, '\n'); i >= 0 { + // We have a full newline-terminated line. + return i + 1, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + + return 0, nil, nil +} diff --git a/ffmpeg/video_options.go b/ffmpeg/video_options.go new file mode 100644 index 0000000..dba7ac5 --- /dev/null +++ b/ffmpeg/video_options.go @@ -0,0 +1,212 @@ +package ffmpeg + +import "strconv" + +// WithVideoFrames set the number of video frames to record +func (c *Command) WithVideoFrames(frames int) *Command { + c.args = append(c.args, "-vframes", strconv.Itoa(frames)) + return c +} + +// WithFrameRate set frame rate (Hz value, fraction or abbreviation) +func (c *Command) WithFrameRate(rate string) *Command { + c.args = append(c.args, "-r", rate) + return c +} + +// WithFrameSize set frame size (WxH or abbreviation) +func (c *Command) WithFrameSize(size string) *Command { + c.args = append(c.args, "-s", size) + return c +} + +// WithAspectRatio set aspect ratio (4:3, 16:9 or 1.3333, 1.7777) +func (c *Command) WithAspectRatio(aspect string) *Command { + c.args = append(c.args, "-aspect", aspect) + return c +} + +// WithDisableVideo disable video +func (c *Command) WithDisableVideo() *Command { + c.args = append(c.args, "-vn") + return c +} + +// WithVideoCodec force video codec (‘copy’ to copy stream) +func (c *Command) WithVideoCodec(codec string) *Command { + c.args = append(c.args, "-vcodec", codec) + return c +} + +// WithTimeCode set initial TimeCode value. +func (c *Command) WithTimeCode(timecode string) *Command { + c.args = append(c.args, "-timecode", timecode) + return c +} + +// WithPass select the pass number (1 to 3) +func (c *Command) WithPass(pass int) *Command { + c.args = append(c.args, "-pass", strconv.Itoa(pass)) + return c +} + +// WithVideoFilters set video filters +func (c *Command) WithVideoFilters(filters string) *Command { + c.args = append(c.args, "-vf", filters) + return c +} + +// WithVideoBitrate set video bitrate (please use -b:v) +func (c *Command) WithVideoBitrate(bitrate string) *Command { + c.args = append(c.args, "-b:v", bitrate) + return c +} + +// WithDisableData disable data +func (c *Command) WithDisableData() *Command { + c.args = append(c.args, "-dn") + return c +} + +// WithPixelFormat set pixel format +func (c *Command) WithPixelFormat(format string) *Command { + c.args = append(c.args, "-pix_fmt", format) + return c +} + +// WithIntra deprecated use -g 1 +func (c *Command) WithDiscardThreshold(discard int) *Command { + c.args = append(c.args, "-vdt", strconv.Itoa(discard)) + return c +} + +// WithRateControlOverride override rate control override for specific intervals +func (c *Command) WithRateControlOverride(override string) *Command { + c.args = append(c.args, "-rc_override", override) + return c +} + +// WithPassLogFile select two pass log file name prefix +func (c *Command) WithPassLogFile(prefix string) *Command { + c.args = append(c.args, "-passlogfile", prefix) + return c +} + +// WithDeinterlace this option is deprecated, use the yadif filter instead +func (c *Command) WithDeinterlace() *Command { + c.args = append(c.args, "-deinterlace") + return c +} + +// WithPSNR calculate PSNR of compressed frames +func (c *Command) WithPSNR() *Command { + c.args = append(c.args, "-psnr") + return c +} + +// WithVideoStats dump video coding statistics to file +func (c *Command) WithVideoStats() *Command { + c.args = append(c.args, "-vstats") + return c +} + +// WithVideoStatsFile dump video coding statistics to file +func (c *Command) WithVideoStatsFile(file string) *Command { + c.args = append(c.args, "-vstats_file", file) + return c +} + +// WithIntraMatrix specify intra matrix coeffs +func (c *Command) WithIntraMatrix(matrix string) *Command { + c.args = append(c.args, "-intra_matrix", matrix) + return c +} + +// WithInterMatrix specify inter matrix coeffs +func (c *Command) WithInterMatrix(matrix string) *Command { + c.args = append(c.args, "-inter_matrix", matrix) + return c +} + +// WithChromaIntraMatrix specify intra matrix coeffs +func (c *Command) WithChromaIntraMatrix(matrix string) *Command { + c.args = append(c.args, "-chroma_intra_matrix", matrix) + return c +} + +// WithTop top=1/bottom=0/auto=-1 field first +func (c *Command) WithTop(top int) *Command { + c.args = append(c.args, "-top", strconv.Itoa(top)) + return c +} + +// WithDC precision intra_dc_precision +func (c *Command) WithDC(precision int) *Command { + c.args = append(c.args, "-dc", strconv.Itoa(precision)) + return c +} + +// WithVideoTag force video tag/fourcc +func (c *Command) WithVideoTag(tag string) *Command { + c.args = append(c.args, "-vtag", tag) + return c +} + +func (c *Command) WithVideoProfile(profile string) *Command { + c.args = append(c.args, "-profile:v", profile) + return c +} + +// WithQPHist show QP histogram +func (c *Command) WithQPHist() *Command { + c.args = append(c.args, "-qphist") + return c +} + +// WithForceFPS force the selected framerate, disable the best supported framerate selection +func (c *Command) WithForceFPS() *Command { + c.args = append(c.args, "-force_fps") + return c +} + +// WithForceKeyFrames force key frames at specified timestamps +func (c *Command) WithForceKeyFrames(timestamps string) *Command { + c.args = append(c.args, "-force_key_frames", timestamps) + return c +} + +// WithHWAccel use HW accelerated decoding +func (c *Command) WithHWAccel(name string) *Command { + c.args = append(c.args, "-hwaccel", name) + return c +} + +// WithHWAccelDevice select a device for HW accelerationdevicename +func (c *Command) WithHWAccelDevice(device string) *Command { + c.args = append(c.args, "-hwaccel_device", device) + return c +} + +// WithChannel deprecated, use -channel +func (c *Command) WithChannel(channel string) *Command { + c.args = append(c.args, "-channel", channel) + return c +} + +// WithTVStandard deprecated, use -standard +func (c *Command) WithTVStandard(standard string) *Command { + c.args = append(c.args, "-standard", standard) + return c +} + +// WithVideoBitstreamFilters deprecated +func (c *Command) WithVideoBitstreamFilters(filters string) *Command { + c.args = append(c.args, "-vbsf", filters) + return c +} + +// WithVideoPreset set the video options to the indicated preset +func (c *Command) WithVideoPreset(preset string) *Command { + c.args = append(c.args, "-vpre", preset) + return c +} diff --git a/ffmpeg/video_options_test.go b/ffmpeg/video_options_test.go new file mode 100644 index 0000000..bd63f51 --- /dev/null +++ b/ffmpeg/video_options_test.go @@ -0,0 +1,277 @@ +package ffmpeg_test + +import ( + "testing" + + "github.com/xfrr/goffmpeg/v2/ffmpeg" +) + +func TestVideoOptions(t *testing.T) { + testCases := []struct { + name string + expected []string + fn func(*ffmpeg.Command) *ffmpeg.Command + }{ + { + name: "WithVideoFrames", + expected: []string{"-vframes", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoFrames(10) + }, + }, + { + name: "WithFrameRate", + expected: []string{"-r", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithFrameRate("10") + }, + }, + { + name: "WithFrameSize", + expected: []string{"-s", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithFrameSize("10") + }, + }, + { + name: "WithAspectRatio", + expected: []string{"-aspect", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithAspectRatio("10") + }, + }, + { + name: "WithDisableVideo", + expected: []string{"-vn"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithDisableVideo() + }, + }, + { + name: "WithVideoCodec", + expected: []string{"-vcodec", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoCodec("10") + }, + }, + { + name: "WithTimeCode", + expected: []string{"-timecode", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithTimeCode("10") + }, + }, + { + name: "WithPass", + expected: []string{"-pass", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithPass(10) + }, + }, + { + name: "WithVideoFilters", + expected: []string{"-vf", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoFilters("10") + }, + }, + { + name: "WithVideoBitrate", + expected: []string{"-b:v", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoBitrate("10") + }, + }, + { + name: "WithDisableData", + expected: []string{"-dn"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithDisableData() + }, + }, + { + name: "WithPixelFormat", + expected: []string{"-pix_fmt", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithPixelFormat("sample") + }, + }, + { + name: "WithDiscardThreshold", + expected: []string{"-vdt", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithDiscardThreshold(10) + }, + }, + { + name: "WithRateControlOverride", + expected: []string{"-rc_override", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithRateControlOverride("10") + }, + }, + { + name: "WithPassLogFile", + expected: []string{"-passlogfile", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithPassLogFile("sample") + }, + }, + { + name: "WithDeinterlace", + expected: []string{"-deinterlace"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithDeinterlace() + }, + }, + { + name: "WithPSNR", + expected: []string{"-psnr"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithPSNR() + }, + }, + { + name: "WithVideoStats", + expected: []string{"-vstats"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoStats() + }, + }, + { + name: "WithVideoStatsFile", + expected: []string{"-vstats_file", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoStatsFile("sample") + }, + }, + { + name: "WithIntraMatrix", + expected: []string{"-intra_matrix", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithIntraMatrix("sample") + }, + }, + { + name: "WithInterMatrix", + expected: []string{"-inter_matrix", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithInterMatrix("sample") + }, + }, + { + name: "WithChromaIntraMatrix", + expected: []string{"-chroma_intra_matrix", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithChromaIntraMatrix("sample") + }, + }, + { + name: "WithTop", + expected: []string{"-top", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithTop(10) + }, + }, + { + name: "WithDC", + expected: []string{"-dc", "10"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithDC(10) + }, + }, + { + name: "WithVideoTag", + expected: []string{"-vtag", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoTag("sample") + }, + }, + { + name: "WithVideoProfile", + expected: []string{"-profile:v", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoProfile("sample") + }, + }, + { + name: "WithQPHist", + expected: []string{"-qphist"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithQPHist() + }, + }, + { + name: "WithForceFPS", + expected: []string{"-force_fps"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithForceFPS() + }, + }, + { + name: "WithForceKeyFrames", + expected: []string{"-force_key_frames", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithForceKeyFrames("sample") + }, + }, + { + name: "WithHWAccel", + expected: []string{"-hwaccel", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithHWAccel("sample") + }, + }, + { + name: "WithHWAccelDevice", + expected: []string{"-hwaccel_device", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithHWAccelDevice("sample") + }, + }, + { + name: "WithChannel", + expected: []string{"-channel", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithChannel("sample") + }, + }, + { + name: "WithTVStandard", + expected: []string{"-standard", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithTVStandard("sample") + }, + }, + { + name: "WithVideoBitstreamFilters", + expected: []string{"-vbsf", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoBitstreamFilters("sample") + }, + }, + { + name: "WithVideoPreset", + expected: []string{"-vpre", "sample"}, + fn: func(c *ffmpeg.Command) *ffmpeg.Command { + return c.WithVideoPreset("sample") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := ffmpeg.NewCommand() + cmd = tc.fn(cmd) + if len(cmd.Args()) != len(tc.expected)+len(ffmpeg.DefaultArgs()) { + t.Fatalf("expected %d args, got %d", len(tc.expected), len(cmd.Args())) + } + + for i := len(ffmpeg.DefaultArgs()) - 1; i < len(tc.expected); i++ { + if cmd.Args()[i] != tc.expected[i] { + t.Fatalf("expected %s, got %s", tc.expected[i], cmd.Args()[i]) + } + } + }) + } +} diff --git a/ffprobe/command.go b/ffprobe/command.go new file mode 100644 index 0000000..f5b02b2 --- /dev/null +++ b/ffprobe/command.go @@ -0,0 +1,47 @@ +package ffprobe + +import ( + "context" + "io" + "os/exec" + + "github.com/xfrr/goffmpeg/v2/pkg/media" +) + +type Command struct { + cmd *exec.Cmd + binPath string + args []string + inputReader io.Reader + outputWriter io.Writer +} + +func (c *Command) Args() []string { + return c.args +} + +func NewCommand() *Command { + return &Command{ + binPath: "ffprobe", + cmd: &exec.Cmd{}, + args: defaultArgs(), + inputReader: nil, + outputWriter: nil, + } +} + +func defaultArgs() []string { + return []string{ + "-loglevel", "error", + "-print_format", "json", + "-show_format", + "-show_streams", + "-show_error", + } +} + +// Run executes the ffprobe command and returns the media file +func (c *Command) Run(ctx context.Context) (media.File, error) { + cmd := exec.CommandContext(ctx, "ffprobe", c.args...) + return c.run(ctx, cmd) +} diff --git a/ffprobe/command_test.go b/ffprobe/command_test.go new file mode 100644 index 0000000..5b3cbf5 --- /dev/null +++ b/ffprobe/command_test.go @@ -0,0 +1,156 @@ +package ffprobe_test + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + + "github.com/xfrr/goffmpeg/v2/ffprobe" +) + +var ( + inputPath = "../testdata/input.mp4" + defaultArgs = []string{ + "-loglevel", "error", + "-print_format", "json", + "-show_format", + "-show_streams", + "-show_error", + } +) + +func Test_Ffprobe_NewCommand(t *testing.T) { + tests := []struct { + name string + command *ffprobe.Command + expectedArgs []string + }{ + { + name: "default", + command: ffprobe.NewCommand(), + expectedArgs: defaultArgs, + }, + { + name: "with input path", + command: ffprobe.NewCommand().WithInputPath(inputPath), + expectedArgs: append(defaultArgs, + "-i", inputPath, + ), + }, + { + name: "with input reader", + command: ffprobe.NewCommand().WithInputReader(bytes.NewBuffer(nil)), + expectedArgs: append(defaultArgs, + "-i", "pipe:0", + ), + }, + { + name: "with output writer", + command: ffprobe.NewCommand().WithOutputWriter(bytes.NewBuffer(nil)), + expectedArgs: append(defaultArgs, + "-o", "pipe:1", + ), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if len(test.command.Args()) != len(test.expectedArgs) { + t.Errorf("expected %d args, got %d", len(test.expectedArgs), len(test.command.Args())) + } + + for i, arg := range test.command.Args() { + if arg != test.expectedArgs[i] { + t.Errorf("expected arg %s, got %s", test.expectedArgs[i], arg) + } + } + }) + } +} + +func Test_Ffprobe_Command_Run(t *testing.T) { + tests := []struct { + name string + command *ffprobe.Command + withInputReader bool + withOutputWriter bool + shouldFail bool + }{ + { + name: "default", + command: ffprobe.NewCommand(), + shouldFail: true, + }, + { + name: "with input path", + command: ffprobe.NewCommand().WithInputPath(inputPath), + }, + { + name: "with input reader", + command: ffprobe.NewCommand(), + withInputReader: true, + }, + { + name: "with input path and output writer", + command: ffprobe.NewCommand().WithInputPath(inputPath), + withOutputWriter: true, + }, + { + name: "with input reader and output writer", + command: ffprobe.NewCommand(), + withInputReader: true, + withOutputWriter: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var err error + + if test.withInputReader { + inputReader, err := os.Open(inputPath) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + defer inputReader.Close() + + test.command.WithInputReader(inputReader) + } + + if test.withOutputWriter { + outputWriter, err := os.Create(fmt.Sprintf("../testdata/%s.json", test.name)) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + defer outputWriter.Close() + + test.command.WithOutputWriter(outputWriter) + } + + file, err := test.command.Run(context.Background()) + if err != nil && !test.shouldFail { + t.Fatalf("expected no error, got %s", err) + } else if err == nil && test.shouldFail { + t.Fatalf("expected error, got nil") + } else if err != nil && test.shouldFail { + return + } + + if len(file.Streams) == 0 { + t.Errorf("expected streams, got none") + } + + if !test.withInputReader && file.Format.Filename != inputPath { + t.Errorf("expected filename %s, got %s", inputPath, file.Format.Filename) + } + + if test.withOutputWriter { + if _, err := os.Stat(fmt.Sprintf("../testdata/%s.json", test.name)); os.IsNotExist(err) { + t.Errorf("expected output file to exist, got %s", err) + } + } + }) + } +} diff --git a/ffprobe/options.go b/ffprobe/options.go new file mode 100644 index 0000000..91a338e --- /dev/null +++ b/ffprobe/options.go @@ -0,0 +1,26 @@ +package ffprobe + +import "io" + +// WithBinPath sets the path to the ffmpeg binary. +func (c *Command) WithBinPath(binPath string) *Command { + c.binPath = binPath + return c +} + +func (c *Command) WithInputPath(inputPath string) *Command { + c.args = append(c.args, "-i", inputPath) + return c +} + +func (c *Command) WithInputReader(inputReader io.Reader) *Command { + c.args = append(c.args, "-i", "pipe:0") + c.inputReader = inputReader + return c +} + +func (c *Command) WithOutputWriter(outputWriter io.Writer) *Command { + c.args = append(c.args, "-o", "pipe:1") + c.outputWriter = outputWriter + return c +} diff --git a/ffprobe/read.go b/ffprobe/read.go new file mode 100644 index 0000000..e1a678f --- /dev/null +++ b/ffprobe/read.go @@ -0,0 +1,61 @@ +package ffprobe + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + + "github.com/xfrr/goffmpeg/v2/pkg/media" +) + +func (c *Command) run(ctx context.Context, cmd *exec.Cmd, args ...string) (media.File, error) { + if c.inputReader != nil { + cmd.Stdin = c.inputReader + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return media.File{}, err + } + + errb := new(bytes.Buffer) + go io.Copy(errb, stderr) + + outb := new(bytes.Buffer) + cmd.Stdout = outb + + err = cmd.Run() + if err != nil { + if errb.Len() > 0 { + return media.File{}, fmt.Errorf(errb.String()) + } + return media.File{}, err + } + + // make a copy of the output buffer + // because it will be modified by json.Unmarshal + // and we want to return it as is + outbCopy := make([]byte, outb.Len()) + copy(outbCopy, outb.Bytes()) + + if c.outputWriter != nil { + n, err := c.outputWriter.Write(outbCopy) + if err != nil { + return media.File{}, err + } + if n != len(outbCopy) { + return media.File{}, fmt.Errorf("failed to write all output to writer") + } + } + + mediafile := new(media.File) + err = json.Unmarshal(outb.Bytes(), mediafile) + if err != nil { + return media.File{}, err + } + + return *mediafile, nil +} diff --git a/go.mod b/go.mod index 590a993..dde345a 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,3 @@ -module github.com/xfrr/goffmpeg +module github.com/xfrr/goffmpeg/v2 -go 1.20 - -require github.com/stretchr/testify v1.8.4 - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +go 1.21 diff --git a/go.sum b/go.sum index fa4b6e6..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +0,0 @@ -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/media/file.go b/media/file.go deleted file mode 100644 index 51f1875..0000000 --- a/media/file.go +++ /dev/null @@ -1,1220 +0,0 @@ -package media - -import ( - "fmt" - "io" - "reflect" - "strconv" - "strings" -) - -type File struct { - aspect string - resolution string - videoBitRate string - videoBitRateTolerance int - videoMaxBitRate int - videoMinBitrate int - videoCodec string - vframes int - frameRate int - audioRate int - maxKeyframe int - minKeyframe int - keyframeInterval int - audioCodec string - audioBitrate string - audioChannels int - audioVariableBitrate bool - bufferSize int - threadset bool - threads int - preset string - tune string - audioProfile string - videoProfile string - target string - duration string - durationInput string - seekTime string - qscale uint32 - crf uint32 - strict int - singleFile int - muxDelay string - seekUsingTsInput bool - seekTimeInput string - inputPath string - inputPipe bool - inputPipeReader io.ReadCloser - inputPipeWriter io.Writer - outputPipe bool - outputPipeReader io.Reader - outputPipeWriter io.WriteCloser - movFlags string - hideBanner bool - outputPath string - outputFormat string - copyTs bool - nativeFramerateInput bool - inputInitialOffset string - rtmpLive string - hlsPlaylistType string - hlsListSize int - hlsSegmentDuration int - hlsMasterPlaylistName string - hlsSegmentFilename string - httpMethod string - httpKeepAlive bool - hwaccel string - streamIds map[int]string - metadata Metadata - videoFilter string - audioFilter string - skipVideo bool - skipAudio bool - compressionLevel int - mapMetadata string - tags map[string]string - encryptionKey string - movflags string - bframe int - pixFmt string - rawInputArgs []string - rawOutputArgs []string -} - -/*** SETTERS ***/ -func (m *File) SetAudioFilter(v string) { - m.audioFilter = v -} - -func (m *File) SetVideoFilter(v string) { - m.videoFilter = v -} - -// Deprecated: Use SetVideoFilter instead. -func (m *File) SetFilter(v string) { - m.SetVideoFilter(v) -} - -func (m *File) SetAspect(v string) { - m.aspect = v -} - -func (m *File) SetResolution(v string) { - m.resolution = v -} - -func (m *File) SetVideoBitRate(v string) { - m.videoBitRate = v -} - -func (m *File) SetVideoBitRateTolerance(v int) { - m.videoBitRateTolerance = v -} - -func (m *File) SetVideoMaxBitrate(v int) { - m.videoMaxBitRate = v -} - -func (m *File) SetVideoMinBitRate(v int) { - m.videoMinBitrate = v -} - -func (m *File) SetVideoCodec(v string) { - m.videoCodec = v -} - -func (m *File) SetVframes(v int) { - m.vframes = v -} - -func (m *File) SetFrameRate(v int) { - m.frameRate = v -} - -func (m *File) SetAudioRate(v int) { - m.audioRate = v -} - -func (m *File) SetAudioVariableBitrate() { - m.audioVariableBitrate = true -} - -func (m *File) SetMaxKeyFrame(v int) { - m.maxKeyframe = v -} - -func (m *File) SetMinKeyFrame(v int) { - m.minKeyframe = v -} - -func (m *File) SetKeyframeInterval(v int) { - m.keyframeInterval = v -} - -func (m *File) SetAudioCodec(v string) { - m.audioCodec = v -} - -func (m *File) SetAudioBitRate(v string) { - m.audioBitrate = v -} - -func (m *File) SetAudioChannels(v int) { - m.audioChannels = v -} - -func (m *File) SetPixFmt(v string) { - m.pixFmt = v -} - -func (m *File) SetBufferSize(v int) { - m.bufferSize = v -} - -func (m *File) SetThreads(v int) { - m.threadset = true - m.threads = v -} - -func (m *File) SetPreset(v string) { - m.preset = v -} - -func (m *File) SetTune(v string) { - m.tune = v -} - -func (m *File) SetAudioProfile(v string) { - m.audioProfile = v -} - -func (m *File) SetVideoProfile(v string) { - m.videoProfile = v -} - -func (m *File) SetDuration(v string) { - m.duration = v -} - -func (m *File) SetDurationInput(v string) { - m.durationInput = v -} - -func (m *File) SetSeekTime(v string) { - m.seekTime = v -} - -func (m *File) SetSeekTimeInput(v string) { - m.seekTimeInput = v -} - -// Q Scale must be integer between 1 to 31 - https://trac.ffmpeg.org/wiki/Encode/MPEG-4 -func (m *File) SetQScale(v uint32) { - m.qscale = v -} - -func (m *File) SetCRF(v uint32) { - m.crf = v -} - -func (m *File) SetStrict(v int) { - m.strict = v -} - -func (m *File) SetSingleFile(v int) { - m.singleFile = v -} - -func (m *File) SetSeekUsingTsInput(val bool) { - m.seekUsingTsInput = val -} - -func (m *File) SetCopyTs(val bool) { - m.copyTs = val -} - -func (m *File) SetInputPath(val string) { - m.inputPath = val -} - -func (m *File) SetInputPipe(val bool) { - m.inputPipe = val -} - -func (m *File) SetInputPipeReader(r io.ReadCloser) { - m.inputPipeReader = r -} - -func (m *File) SetInputPipeWriter(w io.Writer) { - m.inputPipeWriter = w -} - -func (m *File) SetOutputPipe(val bool) { - m.outputPipe = val -} - -func (m *File) SetOutputPipeReader(r io.Reader) { - m.outputPipeReader = r -} - -func (m *File) SetOutputPipeWriter(w io.WriteCloser) { - m.outputPipeWriter = w -} - -func (m *File) SetMovFlags(val string) { - m.movFlags = val -} - -func (m *File) SetHideBanner(val bool) { - m.hideBanner = val -} - -func (m *File) SetMuxDelay(val string) { - m.muxDelay = val -} - -func (m *File) SetOutputPath(val string) { - m.outputPath = val -} - -func (m *File) SetOutputFormat(val string) { - m.outputFormat = val -} - -func (m *File) SetNativeFramerateInput(val bool) { - m.nativeFramerateInput = val -} - -func (m *File) SetRtmpLive(val string) { - m.rtmpLive = val -} - -func (m *File) SetHlsListSize(val int) { - m.hlsListSize = val -} - -func (m *File) SetHlsSegmentDuration(val int) { - m.hlsSegmentDuration = val -} - -func (m *File) SetHlsPlaylistType(val string) { - m.hlsPlaylistType = val -} - -func (m *File) SetHlsMasterPlaylistName(val string) { - m.hlsMasterPlaylistName = val -} - -func (m *File) SetHlsSegmentFilename(val string) { - m.hlsSegmentFilename = val -} - -func (m *File) SetHttpMethod(val string) { - m.httpMethod = val -} - -func (m *File) SetHttpKeepAlive(val bool) { - m.httpKeepAlive = val -} - -func (m *File) SetHardwareAcceleration(val string) { - m.hwaccel = val -} - -func (m *File) SetInputInitialOffset(val string) { - m.inputInitialOffset = val -} - -func (m *File) SetStreamIds(val map[int]string) { - m.streamIds = val -} - -func (m *File) SetSkipVideo(val bool) { - m.skipVideo = val -} - -func (m *File) SetSkipAudio(val bool) { - m.skipAudio = val -} - -func (m *File) SetMetadata(v Metadata) { - m.metadata = v -} - -func (m *File) SetCompressionLevel(val int) { - m.compressionLevel = val -} - -func (m *File) SetMapMetadata(val string) { - m.mapMetadata = val -} - -func (m *File) SetTags(val map[string]string) { - m.tags = val -} - -func (m *File) SetBframe(v int) { - m.bframe = v -} - -func (m *File) SetRawInputArgs(args []string) { - m.rawInputArgs = args -} - -func (m *File) SetRawOutputArgs(args []string) { - m.rawOutputArgs = args -} - -/*** GETTERS ***/ - -// Deprecated: Use VideoFilter instead. -func (m *File) Filter() string { - return m.VideoFilter() -} - -func (m *File) VideoFilter() string { - return m.videoFilter -} - -func (m *File) AudioFilter() string { - return m.audioFilter -} - -func (m *File) Aspect() string { - return m.aspect -} - -func (m *File) Resolution() string { - return m.resolution -} - -func (m *File) VideoBitrate() string { - return m.videoBitRate -} - -func (m *File) VideoBitRateTolerance() int { - return m.videoBitRateTolerance -} - -func (m *File) VideoMaxBitRate() int { - return m.videoMaxBitRate -} - -func (m *File) VideoMinBitRate() int { - return m.videoMinBitrate -} - -func (m *File) VideoCodec() string { - return m.videoCodec -} - -func (m *File) Vframes() int { - return m.vframes -} - -func (m *File) FrameRate() int { - return m.frameRate -} - -func (m *File) GetPixFmt() string { - return m.pixFmt -} - -func (m *File) AudioRate() int { - return m.audioRate -} - -func (m *File) MaxKeyFrame() int { - return m.maxKeyframe -} - -func (m *File) MinKeyFrame() int { - return m.minKeyframe -} - -func (m *File) KeyFrameInterval() int { - return m.keyframeInterval -} - -func (m *File) AudioCodec() string { - return m.audioCodec -} - -func (m *File) AudioBitrate() string { - return m.audioBitrate -} - -func (m *File) AudioChannels() int { - return m.audioChannels -} - -func (m *File) BufferSize() int { - return m.bufferSize -} - -func (m *File) Threads() int { - return m.threads -} - -func (m *File) Target() string { - return m.target -} - -func (m *File) Duration() string { - return m.duration -} - -func (m *File) DurationInput() string { - return m.durationInput -} - -func (m *File) SeekTime() string { - return m.seekTime -} - -func (m *File) Preset() string { - return m.preset -} - -func (m *File) AudioProfile() string { - return m.audioProfile -} - -func (m *File) VideoProfile() string { - return m.videoProfile -} - -func (m *File) Tune() string { - return m.tune -} - -func (m *File) SeekTimeInput() string { - return m.seekTimeInput -} - -func (m *File) QScale() uint32 { - return m.qscale -} - -func (m *File) CRF() uint32 { - return m.crf -} - -func (m *File) Strict() int { - return m.strict -} - -func (m *File) SingleFile() int { - return m.singleFile -} - -func (m *File) MuxDelay() string { - return m.muxDelay -} - -func (m *File) SeekUsingTsInput() bool { - return m.seekUsingTsInput -} - -func (m *File) CopyTs() bool { - return m.copyTs -} - -func (m *File) InputPath() string { - return m.inputPath -} - -func (m *File) InputPipe() bool { - return m.inputPipe -} - -func (m *File) InputPipeReader() io.ReadCloser { - return m.inputPipeReader -} - -func (m *File) InputPipeWriter() io.Writer { - return m.inputPipeWriter -} - -func (m *File) OutputPipe() bool { - return m.outputPipe -} - -func (m *File) OutputPipeReader() io.Reader { - return m.outputPipeReader -} - -func (m *File) OutputPipeWriter() io.WriteCloser { - return m.outputPipeWriter -} - -func (m *File) MovFlags() string { - return m.movFlags -} - -func (m *File) HideBanner() bool { - return m.hideBanner -} - -func (m *File) OutputPath() string { - return m.outputPath -} - -func (m *File) OutputFormat() string { - return m.outputFormat -} - -func (m *File) NativeFramerateInput() bool { - return m.nativeFramerateInput -} - -func (m *File) RtmpLive() string { - return m.rtmpLive -} - -func (m *File) HlsListSize() int { - return m.hlsListSize -} - -func (m *File) HlsSegmentDuration() int { - return m.hlsSegmentDuration -} - -func (m *File) HlsMasterPlaylistName() string { - return m.hlsMasterPlaylistName -} - -func (m *File) HlsSegmentFilename() string { - return m.hlsSegmentFilename -} - -func (m *File) HlsPlaylistType() string { - return m.hlsPlaylistType -} - -func (m *File) InputInitialOffset() string { - return m.inputInitialOffset -} - -func (m *File) HttpMethod() string { - return m.httpMethod -} - -func (m *File) HttpKeepAlive() bool { - return m.httpKeepAlive -} - -func (m *File) HardwareAcceleration() string { - return m.hwaccel -} - -func (m *File) StreamIds() map[int]string { - return m.streamIds -} - -func (m *File) SkipVideo() bool { - return m.skipVideo -} - -func (m *File) SkipAudio() bool { - return m.skipAudio -} - -func (m *File) Metadata() Metadata { - return m.metadata -} - -func (m *File) CompressionLevel() int { - return m.compressionLevel -} - -func (m *File) MapMetadata() string { - return m.mapMetadata -} - -func (m *File) Tags() map[string]string { - return m.tags -} - -func (m *File) SetEncryptionKey(v string) { - m.encryptionKey = v -} - -func (m *File) EncryptionKey() string { - return m.encryptionKey -} - -func (m *File) RawInputArgs() []string { - return m.rawInputArgs -} - -func (m *File) RawOutputArgs() []string { - return m.rawOutputArgs -} - -/** OPTS **/ -func (m *File) ToStrCommand() []string { - var strCommand []string - - opts := []string{ - "SeekTimeInput", - "SeekUsingTsInput", - "NativeFramerateInput", - "DurationInput", - "RtmpLive", - "InputInitialOffset", - "HardwareAcceleration", - "RawInputArgs", - "InputPath", - "InputPipe", - "HideBanner", - "Aspect", - "Resolution", - "FrameRate", - "AudioRate", - "VideoCodec", - "Vframes", - "VideoBitRate", - "VideoBitRateTolerance", - "VideoMaxBitRate", - "VideoMinBitRate", - "VideoProfile", - "SkipVideo", - "AudioCodec", - "AudioBitRate", - "AudioChannels", - "AudioProfile", - "SkipAudio", - "CRF", - "QScale", - "Strict", - "SingleFile", - "BufferSize", - "MuxDelay", - "Threads", - "KeyframeInterval", - "Preset", - "PixFmt", - "Tune", - "Target", - "SeekTime", - "Duration", - "CopyTs", - "StreamIds", - "MovFlags", - "RawOutputArgs", - "OutputFormat", - "OutputPipe", - "HlsListSize", - "HlsSegmentDuration", - "HlsPlaylistType", - "HlsMasterPlaylistName", - "HlsSegmentFilename", - "AudioFilter", - "VideoFilter", - "HttpMethod", - "HttpKeepAlive", - "CompressionLevel", - "MapMetadata", - "Tags", - "EncryptionKey", - "OutputPath", - "Bframe", - "MovFlags", - } - - for _, name := range opts { - opt := reflect.ValueOf(m).MethodByName(fmt.Sprintf("Obtain%s", name)) - if (opt != reflect.Value{}) { - result := opt.Call([]reflect.Value{}) - - if val, ok := result[0].Interface().([]string); ok { - strCommand = append(strCommand, val...) - } - } - } - - return strCommand -} - -func (m *File) ObtainAudioFilter() []string { - if m.audioFilter != "" { - return []string{"-af", m.audioFilter} - } - return nil -} - -func (m *File) ObtainVideoFilter() []string { - if m.videoFilter != "" { - return []string{"-vf", m.videoFilter} - } - return nil -} - -func (m *File) ObtainAspect() []string { - // Set aspect - if m.resolution != "" { - resolution := strings.Split(m.resolution, "x") - if len(resolution) != 0 { - width, _ := strconv.ParseFloat(resolution[0], 64) - height, _ := strconv.ParseFloat(resolution[1], 64) - return []string{"-aspect", fmt.Sprintf("%f", width/height)} - } - } - - if m.aspect != "" { - return []string{"-aspect", m.aspect} - } - return nil -} - -func (m *File) ObtainHardwareAcceleration() []string { - if m.hwaccel != "" { - return []string{"-hwaccel", m.hwaccel} - } - return nil -} - -func (m *File) ObtainInputPath() []string { - if m.inputPath != "" { - return []string{"-i", m.inputPath} - } - return nil -} - -func (m *File) ObtainInputPipe() []string { - if m.inputPipe { - return []string{"-i", "pipe:0"} - } - return nil -} - -func (m *File) ObtainOutputPipe() []string { - if m.outputPipe { - return []string{"pipe:1"} - } - return nil -} - -func (m *File) ObtainMovFlags() []string { - if m.movFlags != "" { - return []string{"-movflags", m.movFlags} - } - return nil -} - -func (m *File) ObtainHideBanner() []string { - if m.hideBanner { - return []string{"-hide_banner"} - } - return nil -} - -func (m *File) ObtainNativeFramerateInput() []string { - if m.nativeFramerateInput { - return []string{"-re"} - } - return nil -} - -func (m *File) ObtainOutputPath() []string { - if m.outputPath != "" { - return []string{m.outputPath} - } - return nil -} - -func (m *File) ObtainVideoCodec() []string { - if m.videoCodec != "" { - return []string{"-c:v", m.videoCodec} - } - return nil -} - -func (m *File) ObtainVframes() []string { - if m.vframes != 0 { - return []string{"-vframes", fmt.Sprintf("%d", m.vframes)} - } - return nil -} - -func (m *File) ObtainFrameRate() []string { - if m.frameRate != 0 { - return []string{"-r", fmt.Sprintf("%d", m.frameRate)} - } - return nil -} - -func (m *File) ObtainAudioRate() []string { - if m.audioRate != 0 { - return []string{"-ar", fmt.Sprintf("%d", m.audioRate)} - } - return nil -} - -func (m *File) ObtainResolution() []string { - if m.resolution != "" { - return []string{"-s", m.resolution} - } - return nil -} - -func (m *File) ObtainVideoBitRate() []string { - if m.videoBitRate != "" { - return []string{"-b:v", m.videoBitRate} - } - return nil -} - -func (m *File) ObtainAudioCodec() []string { - if m.audioCodec != "" { - return []string{"-c:a", m.audioCodec} - } - return nil -} - -func (m *File) ObtainAudioBitRate() []string { - switch { - case !m.audioVariableBitrate && m.audioBitrate != "": - return []string{"-b:a", m.audioBitrate} - case m.audioVariableBitrate && m.audioBitrate != "": - return []string{"-q:a", m.audioBitrate} - case m.audioVariableBitrate: - return []string{"-q:a", "0"} - default: - return nil - } -} - -func (m *File) ObtainAudioChannels() []string { - if m.audioChannels != 0 { - return []string{"-ac", fmt.Sprintf("%d", m.audioChannels)} - } - return nil -} - -func (m *File) ObtainVideoMaxBitRate() []string { - if m.videoMaxBitRate != 0 { - return []string{"-maxrate", fmt.Sprintf("%dk", m.videoMaxBitRate)} - } - return nil -} - -func (m *File) ObtainVideoMinBitRate() []string { - if m.videoMinBitrate != 0 { - return []string{"-minrate", fmt.Sprintf("%dk", m.videoMinBitrate)} - } - return nil -} - -func (m *File) ObtainBufferSize() []string { - if m.bufferSize != 0 { - return []string{"-bufsize", fmt.Sprintf("%dk", m.bufferSize)} - } - return nil -} - -func (m *File) ObtainVideoBitRateTolerance() []string { - if m.videoBitRateTolerance != 0 { - return []string{"-bt", fmt.Sprintf("%dk", m.videoBitRateTolerance)} - } - return nil -} - -func (m *File) ObtainThreads() []string { - if m.threadset { - return []string{"-threads", fmt.Sprintf("%d", m.threads)} - } - return nil -} - -func (m *File) ObtainTarget() []string { - if m.target != "" { - return []string{"-target", m.target} - } - return nil -} - -func (m *File) ObtainDuration() []string { - if m.duration != "" { - return []string{"-t", m.duration} - } - return nil -} - -func (m *File) ObtainDurationInput() []string { - if m.durationInput != "" { - return []string{"-t", m.durationInput} - } - return nil -} - -func (m *File) ObtainKeyframeInterval() []string { - if m.keyframeInterval != 0 { - return []string{"-g", fmt.Sprintf("%d", m.keyframeInterval)} - } - return nil -} - -func (m *File) ObtainSeekTime() []string { - if m.seekTime != "" { - return []string{"-ss", m.seekTime} - } - return nil -} - -func (m *File) ObtainSeekTimeInput() []string { - if m.seekTimeInput != "" { - return []string{"-ss", m.seekTimeInput} - } - return nil -} - -func (m *File) ObtainPreset() []string { - if m.preset != "" { - return []string{"-preset", m.preset} - } - return nil -} - -func (m *File) ObtainTune() []string { - if m.tune != "" { - return []string{"-tune", m.tune} - } - return nil -} - -func (m *File) ObtainCRF() []string { - if m.crf != 0 { - return []string{"-crf", fmt.Sprintf("%d", m.crf)} - } - return nil -} - -func (m *File) ObtainQScale() []string { - if m.qscale != 0 { - return []string{"-qscale", fmt.Sprintf("%d", m.qscale)} - } - return nil -} - -func (m *File) ObtainStrict() []string { - if m.strict != 0 { - return []string{"-strict", fmt.Sprintf("%d", m.strict)} - } - return nil -} - -func (m *File) ObtainSingleFile() []string { - if m.singleFile != 0 { - return []string{"-single_file", fmt.Sprintf("%d", m.singleFile)} - } - return nil -} - -func (m *File) ObtainVideoProfile() []string { - if m.videoProfile != "" { - return []string{"-profile:v", m.videoProfile} - } - return nil -} - -func (m *File) ObtainAudioProfile() []string { - if m.audioProfile != "" { - return []string{"-profile:a", m.audioProfile} - } - return nil -} - -func (m *File) ObtainCopyTs() []string { - if m.copyTs { - return []string{"-copyts"} - } - return nil -} - -func (m *File) ObtainOutputFormat() []string { - if m.outputFormat != "" { - return []string{"-f", m.outputFormat} - } - return nil -} - -func (m *File) ObtainMuxDelay() []string { - if m.muxDelay != "" { - return []string{"-muxdelay", m.muxDelay} - } - return nil -} - -func (m *File) ObtainSeekUsingTsInput() []string { - if m.seekUsingTsInput { - return []string{"-seek_timestamp", "1"} - } - return nil -} - -func (m *File) ObtainRtmpLive() []string { - if m.rtmpLive != "" { - return []string{"-rtmp_live", m.rtmpLive} - } else { - return nil - } -} - -func (m *File) ObtainHlsPlaylistType() []string { - if m.hlsPlaylistType != "" { - return []string{"-hls_playlist_type", m.hlsPlaylistType} - } else { - return nil - } -} - -func (m *File) ObtainInputInitialOffset() []string { - if m.inputInitialOffset != "" { - return []string{"-itsoffset", m.inputInitialOffset} - } else { - return nil - } -} - -func (m *File) ObtainHlsListSize() []string { - return []string{"-hls_list_size", fmt.Sprintf("%d", m.hlsListSize)} -} - -func (m *File) ObtainHlsSegmentDuration() []string { - if m.hlsSegmentDuration != 0 { - return []string{"-hls_time", fmt.Sprintf("%d", m.hlsSegmentDuration)} - } else { - return nil - } -} - -func (m *File) ObtainHlsMasterPlaylistName() []string { - if m.hlsMasterPlaylistName != "" { - return []string{"-master_pl_name", fmt.Sprintf("%s", m.hlsMasterPlaylistName)} - } else { - return nil - } -} - -func (m *File) ObtainHlsSegmentFilename() []string { - if m.hlsSegmentFilename != "" { - return []string{"-hls_segment_filename", fmt.Sprintf("%s", m.hlsSegmentFilename)} - } else { - return nil - } -} - -func (m *File) ObtainHttpMethod() []string { - if m.httpMethod != "" { - return []string{"-method", m.httpMethod} - } else { - return nil - } -} - -func (m *File) ObtainPixFmt() []string { - if m.pixFmt != "" { - return []string{"-pix_fmt", m.pixFmt} - } else { - return nil - } -} - -func (m *File) ObtainHttpKeepAlive() []string { - if m.httpKeepAlive { - return []string{"-multiple_requests", "1"} - } else { - return nil - } -} - -func (m *File) ObtainSkipVideo() []string { - if m.skipVideo { - return []string{"-vn"} - } else { - return nil - } -} - -func (m *File) ObtainSkipAudio() []string { - if m.skipAudio { - return []string{"-an"} - } else { - return nil - } -} - -func (m *File) ObtainStreamIds() []string { - if m.streamIds != nil && len(m.streamIds) != 0 { - result := []string{} - for i, val := range m.streamIds { - result = append(result, []string{"-streamid", fmt.Sprintf("%d:%s", i, val)}...) - } - return result - } - return nil -} - -func (m *File) ObtainCompressionLevel() []string { - if m.compressionLevel != 0 { - return []string{"-compression_level", fmt.Sprintf("%d", m.compressionLevel)} - } - return nil -} - -func (m *File) ObtainMapMetadata() []string { - if m.mapMetadata != "" { - return []string{"-map_metadata", m.mapMetadata} - } - return nil -} - -func (m *File) ObtainEncryptionKey() []string { - if m.encryptionKey != "" { - return []string{"-hls_key_info_file", m.encryptionKey} - } - - return nil -} - -func (m *File) ObtainBframe() []string { - if m.bframe != 0 { - return []string{"-bf", fmt.Sprintf("%d", m.bframe)} - } - return nil -} - -func (m *File) ObtainTags() []string { - if m.tags != nil && len(m.tags) != 0 { - result := []string{} - for key, val := range m.tags { - result = append(result, []string{"-metadata", fmt.Sprintf("%s=%s", key, val)}...) - } - return result - } - return nil -} - -func (m *File) ObtainRawInputArgs() []string { - return m.rawInputArgs -} - -func (m *File) ObtainRawOutputArgs() []string { - return m.rawOutputArgs -} - -func CheckFileType(streams []Streams) string { - for i := 0; i < len(streams); i++ { - st := streams[i] - if st.CodecType == "video" { - return "video" - } - } - - return "audio" -} diff --git a/media/format.go b/media/format.go deleted file mode 100644 index 557f6a7..0000000 --- a/media/format.go +++ /dev/null @@ -1,18 +0,0 @@ -package media - -type Format struct { - Filename string - NbStreams int `json:"nb_streams"` - NbPrograms int `json:"nb_programs"` - FormatName string `json:"format_name"` - FormatLongName string `json:"format_long_name"` - Duration string `json:"duration"` - Size string `json:"size"` - BitRate string `json:"bit_rate"` - ProbeScore int `json:"probe_score"` - Tags Tags `json:"tags"` -} - -type Tags struct { - Encoder string `json:"ENCODER"` -} diff --git a/media/metadata.go b/media/metadata.go deleted file mode 100644 index b89e4d6..0000000 --- a/media/metadata.go +++ /dev/null @@ -1,6 +0,0 @@ -package media - -type Metadata struct { - Streams []Streams `json:"streams"` - Format Format `json:"format"` -} diff --git a/pkg/cmd/exec.go b/pkg/cmd/exec.go deleted file mode 100644 index 5d2b45e..0000000 --- a/pkg/cmd/exec.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "fmt" - "os/exec" -) - -func FindBinPath(ctx context.Context, command string) (string, error) { - if command == "" { - return "", fmt.Errorf("command cannot be empty") - } - - path, err := execBufferOutput(ctx, getFindCommand(), command) - if err != nil { - return "", err - } - - return path, nil -} - -func execBufferOutput(ctx context.Context, command string, args ...string) (string, error) { - var out bytes.Buffer - - c := exec.CommandContext(ctx, command, args...) - c.Stdout = &out - - err := c.Run() - if err != nil { - return "", fmt.Errorf("%s: %s", c.String(), err) - } - - return out.String(), nil -} diff --git a/pkg/cmd/find.go b/pkg/cmd/find.go deleted file mode 100644 index e259886..0000000 --- a/pkg/cmd/find.go +++ /dev/null @@ -1,16 +0,0 @@ -package cmd - -import "runtime" - -var ( - platform = runtime.GOOS -) - -func getFindCommand() string { - switch platform { - case "windows": - return "where" - default: - return "which" - } -} diff --git a/pkg/duration/duration.go b/pkg/duration/duration.go index 15bcb78..b3530ce 100644 --- a/pkg/duration/duration.go +++ b/pkg/duration/duration.go @@ -1,10 +1,12 @@ package duration import ( + "fmt" "strconv" "strings" ) +// DurToSec converts duration string to seconds func DurToSec(dur string) (sec float64) { durAry := strings.Split(dur, ":") var secs float64 @@ -19,3 +21,17 @@ func DurToSec(dur string) (sec float64) { secs += second return secs } + +// SecToDur converts seconds to duration string +func SecToDur(sec float64) (dur string) { + hr := int(sec / (60 * 60)) + sec -= float64(hr) * (60 * 60) + min := int(sec / (60)) + sec -= float64(min) * (60) + if sec < 0 { + sec = 0 + } + + format := "%02d:%02d:%02d" + return fmt.Sprintf(format, hr, min, int(sec)) +} diff --git a/pkg/duration/duration_test.go b/pkg/duration/duration_test.go new file mode 100644 index 0000000..189e17a --- /dev/null +++ b/pkg/duration/duration_test.go @@ -0,0 +1,68 @@ +package duration_test + +import ( + "testing" + + "github.com/xfrr/goffmpeg/v2/pkg/duration" +) + +func TestDurToSec(t *testing.T) { + testCases := []struct { + name string + duration string + expected float64 + }{ + { + name: "valid duration", + duration: "01:23:45", + expected: 5025.0, + }, + { + name: "invalid duration", + duration: "01:23", + expected: 0.0, + }, + { + name: "empty duration", + duration: "", + expected: 0.0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := duration.DurToSec(tc.duration) + if actual != tc.expected { + t.Errorf("expected %f, but got %f", tc.expected, actual) + } + }) + } +} + +func TestSecToDur(t *testing.T) { + testCases := []struct { + name string + seconds float64 + expected string + }{ + { + name: "valid seconds", + seconds: 5025.0, + expected: "01:23:45", + }, + { + name: "invalid seconds", + seconds: -1.0, + expected: "00:00:00", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := duration.SecToDur(tc.seconds) + if actual != tc.expected { + t.Errorf("expected %s, but got %s", tc.expected, actual) + } + }) + } +} diff --git a/pkg/media/file.go b/pkg/media/file.go new file mode 100644 index 0000000..cdb0e8a --- /dev/null +++ b/pkg/media/file.go @@ -0,0 +1,19 @@ +package media + +import ( + "strconv" + "time" +) + +type File struct { + Streams []FileStream `json:"streams"` + Format FileFormat `json:"format"` +} + +func (f File) GetDuration() time.Duration { + fileDurationSeconds, err := strconv.ParseFloat(f.Format.Duration, 64) + if err != nil { + panic(err) + } + return time.Duration(fileDurationSeconds) * time.Second +} diff --git a/pkg/media/format.go b/pkg/media/format.go new file mode 100644 index 0000000..90c1e17 --- /dev/null +++ b/pkg/media/format.go @@ -0,0 +1,18 @@ +package media + +type FileFormat struct { + Filename string + NbStreams int `json:"nb_streams"` + NbPrograms int `json:"nb_programs"` + FormatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + Duration string `json:"duration"` + Size string `json:"size"` + BitRate string `json:"bit_rate"` + ProbeScore int `json:"probe_score"` + Tags FileFormatTags `json:"tags"` +} + +type FileFormatTags struct { + Encoder string `json:"ENCODER"` +} diff --git a/media/stream.go b/pkg/media/stream.go similarity index 98% rename from media/stream.go rename to pkg/media/stream.go index 24f9746..744ff8d 100644 --- a/media/stream.go +++ b/pkg/media/stream.go @@ -1,6 +1,6 @@ package media -type Streams struct { +type FileStream struct { Index int ID string `json:"id"` CodecName string `json:"codec_name"` diff --git a/testdata/input.mp4 b/testdata/input.mp4 new file mode 100644 index 0000000..b11552f Binary files /dev/null and b/testdata/input.mp4 differ diff --git a/transcoder/progress.go b/transcoder/progress.go deleted file mode 100644 index 7ea42d3..0000000 --- a/transcoder/progress.go +++ /dev/null @@ -1,9 +0,0 @@ -package transcoder - -type Progress struct { - FramesProcessed string - CurrentTime string - CurrentBitrate string - Progress float64 - Speed string -} diff --git a/transcoder/transcoder.go b/transcoder/transcoder.go deleted file mode 100644 index 9288bdb..0000000 --- a/transcoder/transcoder.go +++ /dev/null @@ -1,396 +0,0 @@ -package transcoder - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os/exec" - "regexp" - "strconv" - "strings" - - "github.com/xfrr/goffmpeg" - "github.com/xfrr/goffmpeg/media" - "github.com/xfrr/goffmpeg/pkg/duration" -) - -// Transcoder Main struct -type Transcoder struct { - stdErrPipe io.ReadCloser - stdStdinPipe io.WriteCloser - process *exec.Cmd - mediafile *media.File - configuration goffmpeg.Configuration - whiteListProtocols []string -} - -// SetProcessStderrPipe Set the STDERR pipe -func (t *Transcoder) SetProcessStderrPipe(v io.ReadCloser) { - t.stdErrPipe = v -} - -// SetProcessStdinPipe Set the STDIN pipe -func (t *Transcoder) SetProcessStdinPipe(v io.WriteCloser) { - t.stdStdinPipe = v -} - -// SetProcess Set the transcoding process -func (t *Transcoder) SetProcess(cmd *exec.Cmd) { - t.process = cmd -} - -// SetMediaFile Set the media file -func (t *Transcoder) SetMediaFile(v *media.File) { - t.mediafile = v -} - -// SetConfiguration Set the transcoding configuration -func (t *Transcoder) SetConfiguration(v goffmpeg.Configuration) { - t.configuration = v -} - -func (t *Transcoder) SetWhiteListProtocols(availableProtocols []string) { - t.whiteListProtocols = availableProtocols -} - -// Process Get transcoding process -func (t Transcoder) Process() *exec.Cmd { - return t.process -} - -// MediaFile Get the ttranscoding media file. -func (t Transcoder) MediaFile() *media.File { - return t.mediafile -} - -// FFmpegExec Get FFmpeg Bin path -func (t Transcoder) FFmpegExec() string { - return t.configuration.FFmpegBinPath() -} - -// FFprobeExec Get FFprobe Bin path -func (t Transcoder) FFprobeExec() string { - return t.configuration.FFprobeBinPath() -} - -// GetCommand Build and get command -func (t Transcoder) GetCommand() []string { - media := t.mediafile - rcommand := append([]string{"-y"}, media.ToStrCommand()...) - - if t.whiteListProtocols != nil { - rcommand = append([]string{"-protocol_whitelist", strings.Join(t.whiteListProtocols, ",")}, rcommand...) - } - - return rcommand -} - -// InitializeEmptyTranscoder initializes the fields necessary for a blank transcoder -func (t *Transcoder) InitializeEmptyTranscoder() error { - var Metadata media.Metadata - - var err error - cfg := t.configuration - if len(cfg.FFmpegBinPath()) == 0 || len(cfg.FFprobeBinPath()) == 0 { - cfg, err = goffmpeg.Configure(context.Background()) - if err != nil { - return err - } - } - // Set new File - MediaFile := new(media.File) - MediaFile.SetMetadata(Metadata) - - // Set transcoder configuration - t.SetMediaFile(MediaFile) - t.SetConfiguration(cfg) - return nil -} - -// SetInputPath sets the input path for transcoding -func (t *Transcoder) SetInputPath(inputPath string) error { - if t.mediafile.InputPipe() { - return errors.New("cannot set an input path when an input pipe command has been set") - } - t.mediafile.SetInputPath(inputPath) - return nil -} - -// SetOutputPath sets the output path for transcoding -func (t *Transcoder) SetOutputPath(inputPath string) error { - if t.mediafile.OutputPipe() { - return errors.New("cannot set an input path when an input pipe command has been set") - } - t.mediafile.SetOutputPath(inputPath) - return nil -} - -// CreateInputPipe creates an input pipe for the transcoding process -func (t *Transcoder) CreateInputPipe() (*io.PipeWriter, error) { - if t.mediafile.InputPath() != "" { - return nil, errors.New("cannot set an input pipe when an input path exists") - } - inputPipeReader, inputPipeWriter := io.Pipe() - t.mediafile.SetInputPipe(true) - t.mediafile.SetInputPipeReader(inputPipeReader) - t.mediafile.SetInputPipeWriter(inputPipeWriter) - return inputPipeWriter, nil -} - -// CreateOutputPipe creates an output pipe for the transcoding process -func (t *Transcoder) CreateOutputPipe(containerFormat string) (*io.PipeReader, error) { - if t.mediafile.OutputPath() != "" { - return nil, errors.New("cannot set an output pipe when an output path exists") - } - t.mediafile.SetOutputFormat(containerFormat) - - t.mediafile.SetMovFlags("frag_keyframe") - outputPipeReader, outputPipeWriter := io.Pipe() - t.mediafile.SetOutputPipe(true) - t.mediafile.SetOutputPipeReader(outputPipeReader) - t.mediafile.SetOutputPipeWriter(outputPipeWriter) - return outputPipeReader, nil -} - -// Initialize Init the transcoding process -func (t *Transcoder) Initialize(inputPath string, outputPath string) error { - var err error - var outb, errb bytes.Buffer - var Metadata media.Metadata - - cfg := t.configuration - - if len(cfg.FFmpegBinPath()) == 0 || len(cfg.FFprobeBinPath()) == 0 { - cfg, err = goffmpeg.Configure(context.Background()) - if err != nil { - return err - } - } - - if inputPath == "" { - return errors.New("error on transcoder.Initialize: inputPath missing") - } - - command := []string{"-i", inputPath, "-print_format", "json", "-show_format", "-show_streams", "-show_error"} - - if t.whiteListProtocols != nil { - command = append([]string{"-protocol_whitelist", strings.Join(t.whiteListProtocols, ",")}, command...) - } - - cmd := exec.Command(cfg.FFprobeBinPath(), command...) - cmd.Stdout = &outb - cmd.Stderr = &errb - - err = cmd.Run() - if err != nil { - return fmt.Errorf("error executing (%s) | error: %s | message: %s %s", command, err, outb.String(), errb.String()) - } - - if err = json.Unmarshal(outb.Bytes(), &Metadata); err != nil { - return err - } - - // Set new File - MediaFile := new(media.File) - MediaFile.SetMetadata(Metadata) - MediaFile.SetInputPath(inputPath) - MediaFile.SetOutputPath(outputPath) - - // Set transcoder configuration - t.SetMediaFile(MediaFile) - t.SetConfiguration(cfg) - - return nil - -} - -// Run Starts the transcoding process -func (t *Transcoder) Run(progress bool) <-chan error { - done := make(chan error) - command := t.GetCommand() - - if !progress { - command = append([]string{"-nostats", "-loglevel", "0"}, command...) - } - - proc := exec.Command(t.configuration.FFmpegBinPath(), command...) - if progress { - errStream, err := proc.StderrPipe() - if err != nil { - fmt.Println("Progress not available: " + err.Error()) - } else { - t.stdErrPipe = errStream - } - } - - // Set the stdinPipe in case we need to stop the transcoding - stdin, err := proc.StdinPipe() - if nil != err { - fmt.Println("Stdin not available: " + err.Error()) - } - - t.stdStdinPipe = stdin - - // If the user has requested progress, we send it to them on a Buffer - var outb, errb bytes.Buffer - if progress { - proc.Stdout = &outb - } - - // If an input pipe has been set, we set it as stdin for the transcoding - if t.mediafile.InputPipe() { - proc.Stdin = t.mediafile.InputPipeReader() - } - - // If an output pipe has been set, we set it as stdout for the transcoding - if t.mediafile.OutputPipe() { - proc.Stdout = t.mediafile.OutputPipeWriter() - } - - err = proc.Start() - - t.SetProcess(proc) - - go func(err error) { - if err != nil { - done <- fmt.Errorf("failed start ffmpeg (%s) with %s, message %s %s", command, err, outb.String(), errb.String()) - close(done) - return - } - - err = proc.Wait() - if err != nil { - err = fmt.Errorf("failed finish ffmpeg (%s) with %s message %s %s", command, err, outb.String(), errb.String()) - } - - go t.closePipes() - - done <- err - close(done) - }(err) - - return done -} - -// Stop Ends the transcoding process -func (t *Transcoder) Stop() error { - if t.process != nil { - - stdin := t.stdStdinPipe - if stdin != nil { - stdin.Write([]byte("q\n")) - } - } - return nil -} - -// Output Returns the transcoding progress channel -func (t Transcoder) Output() <-chan Progress { - out := make(chan Progress) - - go func() { - defer close(out) - if t.stdErrPipe == nil { - out <- Progress{} - return - } - - defer t.stdErrPipe.Close() - - scanner := bufio.NewScanner(t.stdErrPipe) - - split := func(data []byte, atEOF bool) (advance int, token []byte, spliterror error) { - if atEOF && len(data) == 0 { - return 0, nil, nil - } - //windows \r\n - //so first \r and then \n can remove unexpected line break - if i := bytes.IndexByte(data, '\r'); i >= 0 { - // We have a cr terminated line - return i + 1, data[0:i], nil - } - if i := bytes.IndexByte(data, '\n'); i >= 0 { - // We have a full newline-terminated line. - return i + 1, data[0:i], nil - } - if atEOF { - return len(data), data, nil - } - - return 0, nil, nil - } - - scanner.Split(split) - buf := make([]byte, 2) - scanner.Buffer(buf, bufio.MaxScanTokenSize) - - for scanner.Scan() { - Progress := new(Progress) - line := scanner.Text() - if strings.Contains(line, "frame=") && strings.Contains(line, "time=") && strings.Contains(line, "bitrate=") { - var re = regexp.MustCompile(`=\s+`) - st := re.ReplaceAllString(line, `=`) - - f := strings.Fields(st) - var framesProcessed string - var currentTime string - var currentBitrate string - var currentSpeed string - - for j := 0; j < len(f); j++ { - field := f[j] - fieldSplit := strings.Split(field, "=") - - if len(fieldSplit) > 1 { - fieldname := strings.Split(field, "=")[0] - fieldvalue := strings.Split(field, "=")[1] - - if fieldname == "frame" { - framesProcessed = fieldvalue - } - - if fieldname == "time" { - currentTime = fieldvalue - } - - if fieldname == "bitrate" { - currentBitrate = fieldvalue - } - if fieldname == "speed" { - currentSpeed = fieldvalue - } - } - } - - timesec := duration.DurToSec(currentTime) - dursec, _ := strconv.ParseFloat(t.MediaFile().Metadata().Format.Duration, 64) - //live stream check - if dursec != 0 { - // Progress calculation - progress := (timesec * 100) / dursec - Progress.Progress = progress - } - Progress.CurrentBitrate = currentBitrate - Progress.FramesProcessed = framesProcessed - Progress.CurrentTime = currentTime - Progress.Speed = currentSpeed - out <- *Progress - } - } - }() - - return out -} - -func (t *Transcoder) closePipes() { - if t.mediafile.InputPipe() { - t.mediafile.InputPipeReader().Close() - } - if t.mediafile.OutputPipe() { - t.mediafile.OutputPipeWriter().Close() - } -} diff --git a/transcoder/transcored_test.go b/transcoder/transcored_test.go deleted file mode 100644 index d224272..0000000 --- a/transcoder/transcored_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package transcoder - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/xfrr/goffmpeg/media" -) - -func TestTranscoder(t *testing.T) { - t.Run("#SetWhiteListProtocols", func(t *testing.T) { - t.Run("Should not set -protocol_whitelist option if it isn't present", func(t *testing.T) { - ts := Transcoder{} - - ts.SetMediaFile(&media.File{}) - require.NotEqual(t, ts.GetCommand()[0:2], []string{"-protocol_whitelist", "file,http,https,tcp,tls"}) - require.NotContains(t, ts.GetCommand(), "protocol_whitelist") - }) - - t.Run("Should set -protocol_whitelist option if it's present", func(t *testing.T) { - ts := Transcoder{} - - ts.SetMediaFile(&media.File{}) - ts.SetWhiteListProtocols([]string{"file", "http", "https", "tcp", "tls"}) - - require.Equal(t, ts.GetCommand()[0:2], []string{"-protocol_whitelist", "file,http,https,tcp,tls"}) - }) - }) -}