From 325c5f43b43fdc75c78d91cc48800217b2af84b4 Mon Sep 17 00:00:00 2001 From: Juris Bune Date: Fri, 11 Nov 2022 12:43:55 +0200 Subject: [PATCH] Simplify cli merging encode and analyse into single subcommand --- .github/workflows/ci.yaml | 2 +- .golangci.yaml | 5 +- analyse.go | 220 ----------------- common.go | 59 +++++ doc/usage.md | 145 ++++------- ease_test.go | 234 ++++++++---------- encode.go | 249 ------------------- helpers_test.go | 25 +- internal/encoding/plan.go | 7 +- internal/encoding/plan_config.go | 5 - internal/encoding/plan_config_test.go | 23 +- internal/encoding/plan_test.go | 12 +- main.go | 5 +- run.go | 338 ++++++++++++++++++++++++++ 14 files changed, 558 insertions(+), 771 deletions(-) delete mode 100644 analyse.go delete mode 100644 encode.go create mode 100644 run.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9a671fb..44c24e9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -58,7 +58,7 @@ jobs: - if: steps.cache-ffmpeg.outputs.cache-hit != 'true' name: Install static ffmpeg run: | - curl --connect-timeout 10 -Lv -o ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz + curl --connect-timeout 10 -Lv -o ffmpeg.tar.xz https://web.archive.org/web/20220502225332/https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz echo "07149022696e10e528f48f402c3b8570 ffmpeg.tar.xz" | md5sum --check - mkdir -p "$FFMPEG_DEST_DIR" tar -C "$FFMPEG_DEST_DIR" -xf ffmpeg.tar.xz --strip-components=1 diff --git a/.golangci.yaml b/.golangci.yaml index c3d7e1f..5ca8842 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -27,22 +27,19 @@ linters: disable-all: true enable: # Default list - - deadcode - errcheck - gosimple - govet - ineffassign - staticcheck - - structcheck - typecheck - unused - - varcheck # Additional - dupl - errname - misspell - gocritic - godot - - gofumpt + - goimports - goheader - gosec diff --git a/analyse.go b/analyse.go deleted file mode 100644 index 344c1f7..0000000 --- a/analyse.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright ©2022 Evolution. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -// ease tool's analyse subcommand implementation. - -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "path" - "strings" - - "github.com/evolution-gaming/ease/internal/analysis" - "github.com/evolution-gaming/ease/internal/logging" - "github.com/evolution-gaming/ease/internal/tools" - "github.com/evolution-gaming/ease/internal/vqm" -) - -// Make sure AnalyseApp implements Commander interface. -var _ Commander = (*AnalyseApp)(nil) - -// AnalyseApp is analyse subcommand context that implements Commander interface. -type AnalyseApp struct { - // FlagSet instance - fs *flag.FlagSet - // Source encoding report file to be parsed and used for sources for analysis - flSrcReport string - // Output directory for analysis results - flOutDir string -} - -// CreateAnalyseCommand will create Commander instace from AnalyseApp. -func CreateAnalyseCommand() Commander { - longHelp := `Subcommand "analyse" will execute analysis stage on report generated from "encode" -stage. Report file is provided via -report flag and it is mandatory. - -Examples: - - ease analyse -report encode_report.json -out-dir results` - - app := &AnalyseApp{ - fs: flag.NewFlagSet("analyse", flag.ContinueOnError), - } - app.fs.StringVar(&app.flSrcReport, "report", "", "Encoding report file as source for analysis (output from encoding stage)") - app.fs.StringVar(&app.flOutDir, "out-dir", "", "Output directory to store results") - app.fs.Usage = func() { - printSubCommandUsage(longHelp, app.fs) - } - - return app -} - -func (a *AnalyseApp) Name() string { - return a.fs.Name() -} - -func (a *AnalyseApp) Help() { - a.fs.Usage() -} - -// init will do App state initialization. -func (a *AnalyseApp) init(args []string) error { - if err := a.fs.Parse(args); err != nil { - return &AppError{ - exitCode: 2, - msg: fmt.Sprintf("%s usage error", a.Name()), - } - } - - // If after flag parsing report file is not defined - error out. - if a.flSrcReport == "" { - a.Help() - return &AppError{ - exitCode: 2, - msg: "mandatory option -report is missing", - } - } - - // If after flag parsing output directory is not defined - error out. - if a.flOutDir == "" { - a.Help() - return &AppError{ - exitCode: 2, - msg: "mandatory option -out-dir is missing", - } - } - - // Report file should exist. - if _, err := os.Stat(a.flSrcReport); err != nil { - a.Help() - return &AppError{ - exitCode: 2, - msg: fmt.Sprintf("report file does not exist? %s", err), - } - } - - return nil -} - -func (a *AnalyseApp) Run(args []string) error { - if err := a.init(args); err != nil { - return err - } - - // Check external tool dependencies - we require ffprobe to do bitrate calculations. - if _, err := tools.FfprobePath(); err != nil { - return &AppError{exitCode: 1, msg: fmt.Sprintf("dependency ffprobe: %s", err)} - } - - // Read and parse report JSON file. - logging.Debugf("Report JSON file %s", a.flSrcReport) - r := parseReportFile(a.flSrcReport) - - // Extract data to work with. - srcData := extractSourceData(r) - d, err := json.MarshalIndent(srcData, "", " ") - if err != nil { - return &AppError{ - exitCode: 1, - msg: err.Error(), - } - } - logging.Debugf("Analysis for:\n%s", d) - - // TODO: this is a good place to do goroutines iterate over sources and do stuff. - - for _, v := range srcData { - // Create separate dir for results. - base := path.Base(v.CompressedFile) - base = strings.TrimSuffix(base, path.Ext(base)) - logging.Infof("Analysing %s", v.CompressedFile) - resDir := path.Join(a.flOutDir, base) - if err := os.MkdirAll(resDir, os.FileMode(0o755)); err != nil { - return &AppError{ - msg: fmt.Sprintf("failed creating directory: %s", err), - exitCode: 1, - } - } - - compressedFile := v.CompressedFile - vqmFile := v.VqmResultFile - // In case compressed and VQM result file path in not absolute we assume - // it must be relative to WorkDir. - if !path.IsAbs(compressedFile) { - compressedFile = path.Join(v.WorkDir, compressedFile) - } - if !path.IsAbs(vqmFile) { - vqmFile = path.Join(v.WorkDir, vqmFile) - } - bitratePlot := path.Join(resDir, base+"_bitrate.png") - vmafPlot := path.Join(resDir, base+"_vmaf.png") - psnrPlot := path.Join(resDir, base+"_psnr.png") - msssimPlot := path.Join(resDir, base+"_ms-ssim.png") - - jsonFd, err := os.Open(vqmFile) - if err != nil { - return &AppError{ - msg: fmt.Sprintf("failed opening VQM file: %s", err), - exitCode: 1, - } - } - - var frameMetrics vqm.FrameMetrics - err = frameMetrics.FromFfmpegVMAF(jsonFd) - // Close jsonFd file descriptor at earliest convenience. Should avoid use of defer - // in loop in this case. - jsonFd.Close() - if err != nil { - return &AppError{ - msg: fmt.Sprintf("failed converting to FrameMetrics: %s", err), - exitCode: 1, - } - } - - var vmafs, psnrs, msssims []float64 - for _, v := range frameMetrics { - vmafs = append(vmafs, v.VMAF) - psnrs = append(psnrs, v.PSNR) - msssims = append(msssims, v.MS_SSIM) - } - - if err := analysis.MultiPlotBitrate(compressedFile, bitratePlot); err != nil { - return &AppError{ - msg: fmt.Sprintf("failed creating bitrate plot: %s", err), - exitCode: 1, - } - } - logging.Infof("Bitrate plot done: %s", bitratePlot) - - if err := analysis.MultiPlotVqm(vmafs, "VMAF", base, vmafPlot); err != nil { - return &AppError{ - msg: fmt.Sprintf("failed creating VMAF multiplot: %s", err), - exitCode: 1, - } - } - logging.Infof("VMAF multi-plot done: %s", vmafPlot) - - if err := analysis.MultiPlotVqm(psnrs, "PSNR", base, psnrPlot); err != nil { - return &AppError{ - msg: fmt.Sprintf("failed creating PSNR multiplot: %s", err), - exitCode: 1, - } - } - logging.Infof("PSNR multi-plot done: %s", psnrPlot) - - if err := analysis.MultiPlotVqm(msssims, "MS-SSIM", base, msssimPlot); err != nil { - return &AppError{ - msg: fmt.Sprintf("failed creating MS-SSIM multiplot: %s", err), - exitCode: 1, - } - } - logging.Infof("MS-SSIM multi-plot done: %s", msssimPlot) - } - - return nil -} diff --git a/common.go b/common.go index 8acada7..cf63316 100644 --- a/common.go +++ b/common.go @@ -7,11 +7,13 @@ package main import ( "encoding/json" + "errors" "flag" "fmt" "io" "log" "os" + "strings" "github.com/evolution-gaming/ease/internal/encoding" "github.com/evolution-gaming/ease/internal/logging" @@ -124,3 +126,60 @@ func extractSourceData(r *report) map[string]sourceData { } return s } + +// unrollResultErrors helper to unroll all errors from RunResults into a string. +func unrollResultErrors(results []encoding.RunResult) string { + sb := strings.Builder{} + for i := range results { + rr := &results[i] + if len(rr.Errors) != 0 { + for _, e := range rr.Errors { + sb.WriteString(fmt.Sprintf("%s:\n\t%s\n", rr.Name, e.Error())) + } + } + } + return sb.String() +} + +// createPlanConfig creates a PlanConfig instance from JSON configuration. +func createPlanConfig(cfgFile string) (pc encoding.PlanConfig, err error) { + fd, err := os.Open(cfgFile) + if err != nil { + return pc, fmt.Errorf("cannot open conf file: %w", err) + } + defer fd.Close() + + jdoc, err := io.ReadAll(fd) + if err != nil { + return pc, fmt.Errorf("cannot read data from conf file: %w", err) + } + + pc, err = encoding.NewPlanConfigFromJSON(jdoc) + if err != nil { + return pc, fmt.Errorf("cannot create PlanConfig: %w", err) + } + + if ok, err := pc.IsValid(); !ok { + ev := &encoding.PlanConfigError{} + if errors.As(err, &ev) { + logging.Debugf( + "PlanConfig validation failures:\n%s", + strings.Join(ev.Reasons(), "\n")) + } + return pc, fmt.Errorf("PlanConfig not valid: %w", err) + } + + return pc, nil +} + +// isNonEmptyDir will check if given directory is non-empty. +func isNonEmptyDir(path string) bool { + fs, err := os.Open(path) + if err != nil { + return false + } + defer fs.Close() + + n, _ := fs.Readdirnames(1) + return len(n) == 1 +} diff --git a/doc/usage.md b/doc/usage.md index 484bf30..ddd3565 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -1,7 +1,7 @@ # Usage of ease tool **Note:** Version 5.X of `ffmpeg` and `ffprobe` binaries (built with `libvmaf`) are -required to be available in `$PATH` for video quality calculations. +required to be available on `$PATH` for video quality calculations. For full and up-to-date usage examples and documentation of options consult `ease` tool help with `ease -h`. @@ -9,8 +9,7 @@ For full and up-to-date usage examples and documentation of options consult Tool consists of a number of subcommands, at this point following subcommands are implemented: -- `ease encode` -- `ease analyse` +- `ease run` - `ease bitrate` - `ease vqmplot` @@ -18,35 +17,26 @@ implemented: Usual workflow consists of a number of logical stages: -- [Encoding plan](#encoding-plan) preparation: this defines what to encoded (your mezzanine clips) and how - to encode (encoder string/scheme/command-line). Think of this as sort of declarative - approach to defining batch-encode commands. +- Preparation of [encoding plan](#encoding-plan): this defines what to encoded (your + mezzanine clips) and how to encode (encoder string/scheme/command-line). Think of this + as sort of declarative approach to defining batch-encode commands. -- Running [encoding stage](#encoding-stage): based on what is defined in encoding plan - perform encoding - according to defined encoding scheme, save compressed outputs, run VMAF calculations and - save results. +- [Running encoding plan](#run-encoding-plan): based on what is defined in encoding plan - + perform encoding according to defined encoding scheme, save compressed outputs, run VMAF + calculations, save results and calculate VQ metrics (VMAF, PSNR and MS-SSIM) and create + metrics plots (per-frame , histogram, Cumulative Distribution Function) -- Running [analysis stage](#analysis-stage): prepare charts and graphs with bitrate plots, VQ metrics such as - VMAF, PSNR etc. +## Run encoding plan -All above from can be achieved with following `ease` tool commands: +A sample command to run encodings according to given plan: ``` -$ ease encode -plan encoding_plan.json -report run_report.json -$ ease analyse -report run_report.json -out-dir analysis +$ ease run -plan encoding_plan.json -out-dir results ``` -## Encoding stage - -Example of a simple batch encoding stage looks like: - -``` -$ ease encode -plan encoding_plan.json -report run_report.json -``` - -Where `encoding_plan.json` defines all encoder runs - essentially an batch -encode configuration and `run_report.json` contains encoding run metadata and -per encoding results. +Where `encoding_plan.json` defines all encoder runs - essentially an batch encode +configuration and `results` is a directory where all by-products of encoding will be +saved. Full list of options are as follows (from `ease encode -h`): @@ -56,20 +46,12 @@ Full list of options are as follows (from `ease encode -h`): Mandatory option. Path to "encoding plan" configuration file. -> -report string -> -> Encoding plan report file (default is stdout) - -Optional path to JSON report file. - -> -vqm +> -out-dir string > -> Calculate VQMs (default true) +> Output directory to store results -Controls if VQMs are calculated for this run. Since VQM calculation is CPU -intensive and time consuming - it is possible to disable VQM calculation via -`-vqm=false`. Default is to run VQM calculations for all encoded files. This -stage can be time consuming for long videos and/or on weak hardware. +Mandatory option. Path to directory fo saving results. It will include plan execution +report `report.json`, actual compressed clips, encoder logs, VQM logs, VQM plots etc. > -dry-run > @@ -90,7 +72,6 @@ This is a simple example of encoding plan configuration in JSON: ```json { - "OutDir": "x264_out", "Inputs": [ "./videos/clip01.mp4", "./videos/clip02.mp4" @@ -122,8 +103,6 @@ This is a simple example of encoding plan configuration in JSON: JSON configuration file consists of following: -- `OutDir` is directory in which to save encoded/compressed files and log output - generated by encoder command. - `Inputs` is an array of source/mezzanine video files that are subject to compression - `Schemes` is an array that contains various encoder commands. This is @@ -144,89 +123,53 @@ JSON configuration file consists of following: If we would execute this sample encoding plan with `ease` tool via: ``` -$ ease encode -plan encoding_plan.json -report run_report.json +$ ease run -plan encoding_plan.json -out-dir out ``` As a result we would get following file structure: ``` -├── encoding_plan.json -├── run_report.json -├── videos -│   ├── clip01.mp4 -│   └── clip02.mp4 -└── x264_out - ├── clip01_tbr_1700k.mp4 - ├── clip01_tbr_1700k.out - ├── clip01_tbr_1700k_vqm.json - ├── clip01_tbr_2000k.mp4 - ├── clip01_tbr_2000k.out - ├── clip01_tbr_2000k_vqm.json - ├── clip02_tbr_1700k.mp4 - ├── clip02_tbr_1700k.out - ├── clip02_tbr_1700k_vqm.json - ├── clip02_tbr_2000k.mp4 - ├── clip02_tbr_2000k.out - └── clip02_tbr_2000k_vqm.json -``` - -Compressed clips `*.mp4` along with encoder generated log output `*.out` and libvmaf log -output `*.json` are saved into `x264_out/` directory as specified by `OutDir` -configuration option from encoding plan. Also, encoding run result is saved to -`run_report.json` as specified with `-report` command-line flag. - -## Analysis stage - -To aid in analysis part of encoded videos there is `ease analyse` subcommand. -This subcommand requires artifacts from previous encoding stage. For time being -input for `ease analyse` is encoding plan report generated by `ease encode` -tool. - -Example usage: - -``` -ease analyse -report path/to/ease-encode-generated/report.json -out-dir analysis -``` - -Analysis artifacts will be placed in directory specified with option `-out-dir`. -These artifacts include: - -- Bitrate plot (aggregated into 1s buckets) and frame size plot -- VMAF, PSNR and MS-SSIM metrics related plots (per-frame , histogram, - Cumulative Distribution Function) - -If we would continue with analysis stage where we left off in [Encoding plan](#encoding-plan) after running encoding stage: - -``` -$ ease analyse -report run_report.json -out-dir analysis -``` - -As a result we would get following charts and plots in `analysis` directory: - -``` -analysis/ +out/ ├── clip01_tbr_1700k │   ├── clip01_tbr_1700k_bitrate.png │   ├── clip01_tbr_1700k_ms-ssim.png │   ├── clip01_tbr_1700k_psnr.png │   └── clip01_tbr_1700k_vmaf.png +├── clip01_tbr_1700k.mp4 +├── clip01_tbr_1700k.out +├── clip01_tbr_1700k_vqm.json ├── clip01_tbr_2000k │   ├── clip01_tbr_2000k_bitrate.png │   ├── clip01_tbr_2000k_ms-ssim.png │   ├── clip01_tbr_2000k_psnr.png │   └── clip01_tbr_2000k_vmaf.png +├── clip01_tbr_2000k.mp4 +├── clip01_tbr_2000k.out +├── clip01_tbr_2000k_vqm.json ├── clip02_tbr_1700k │   ├── clip02_tbr_1700k_bitrate.png │   ├── clip02_tbr_1700k_ms-ssim.png │   ├── clip02_tbr_1700k_psnr.png │   └── clip02_tbr_1700k_vmaf.png -└── clip02_tbr_2000k - ├── clip02_tbr_2000k_bitrate.png - ├── clip02_tbr_2000k_ms-ssim.png - ├── clip02_tbr_2000k_psnr.png - └── clip02_tbr_2000k_vmaf.png +├── clip02_tbr_1700k.mp4 +├── clip02_tbr_1700k.out +├── clip02_tbr_1700k_vqm.json +├── clip02_tbr_2000k +│   ├── clip02_tbr_2000k_bitrate.png +│   ├── clip02_tbr_2000k_ms-ssim.png +│   ├── clip02_tbr_2000k_psnr.png +│   └── clip02_tbr_2000k_vmaf.png +├── clip02_tbr_2000k.mp4 +├── clip02_tbr_2000k.out +├── clip02_tbr_2000k_vqm.json +└── report.json ``` +Compressed clips `*.mp4` along with encoder generated log output `*.out` and libvmaf log +output `*.json` are saved into `out/` directory as specified by `-out-dir` commandline +flag. Encoding run result is saved to `report.json`. Also, for each compressed file there +is a subdirectory containing VQM plots `*.png` files. + ## Other subcommands For convenience purposes there are also 2 other subcommands - namely `bitrate` diff --git a/ease_test.go b/ease_test.go index 0fe22d4..ff39d81 100644 --- a/ease_test.go +++ b/ease_test.go @@ -17,33 +17,77 @@ import ( "github.com/google/go-cmp/cmp" ) -// Encode subcommand related tests. -func TestEncodeApp_WrongFlags(t *testing.T) { +// Happy path functional test for run sub-command. +func TestRunApp_Run(t *testing.T) { + tempDir := t.TempDir() + ePlan := fixPlanConfig(t) + outDir := path.Join(tempDir, "out") + + t.Log("Should succeed execution with -plan flag") + // Run command will generate encoding artifacts and analysis artifacts. + err := CreateRunCommand().Run([]string{"-plan", ePlan, "-out-dir", outDir}) + if err != nil { + t.Errorf("Unexpected error running encode: %v", err) + } + + buf, err2 := os.ReadFile(path.Join(outDir, "report.json")) + if err2 != nil { + t.Errorf("Unexpected error: %v", err2) + } + if len(buf) == 0 { + t.Errorf("No data in report file") + } + + t.Log("Analyse should create bitrate, VMAF, PSNR and SSIM plots") + if m, _ := filepath.Glob(fmt.Sprintf("%s/*/*bitrate.png", outDir)); len(m) != 1 { + t.Errorf("Expecting one file for bitrate plot, got: %s", m) + } + if m, _ := filepath.Glob(fmt.Sprintf("%s/*/*vmaf.png", outDir)); len(m) != 1 { + t.Errorf("Expecting one file for VMAF plot, got: %s", m) + } + if m, _ := filepath.Glob(fmt.Sprintf("%s/*/*psnr.png", outDir)); len(m) != 1 { + t.Errorf("Expecting one file for PSNR plot, got: %s", m) + } + if m, _ := filepath.Glob(fmt.Sprintf("%s/*/*ms-ssim.png", outDir)); len(m) != 1 { + t.Errorf("Expecting one file for MS-SSIM plot, got: %s", m) + } +} + +// Error cases for run sub-command flags. +func TestRunApp_FlagErrors(t *testing.T) { tests := map[string]struct { // substring in Error() want string givenArgs []string }{ "Wrong flags": { - givenArgs: []string{"-zzz", "aaaa", "-plan", "a/xxx"}, - want: "encode usage error", + givenArgs: []string{"-zzz", "aaaa", "-plan", "a/xxx", "-out-dir", "out"}, + want: "run usage error", }, - "Empty flags": { - givenArgs: []string{""}, + "Mandatory plan flag missing": { + givenArgs: []string{"-out-dir", "out"}, want: "mandatory option -plan is missing", }, + "Mandatory out-dir flag missing": { + givenArgs: []string{"-plan", "a/xxx"}, + want: "mandatory option -out-dir is missing", + }, "Non-existent conf": { - givenArgs: []string{"-plan", "a/yyy"}, + givenArgs: []string{"-plan", "a/yyy", "-out-dir", "out"}, want: "encoding plan file does not exist?", }, + "Empty flags": { + givenArgs: []string{}, + want: "mandatory option", + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - cmd := CreateEncodeCommand() + cmd := CreateRunCommand() // Discard usage output so that during test execution test output is // not flooded with command Usage/Help stuff. - if c, ok := cmd.(*EncodeApp); ok { + if c, ok := cmd.(*App); ok { c.fs.SetOutput(io.Discard) } gotErr := cmd.Run(tc.givenArgs) @@ -54,14 +98,20 @@ func TestEncodeApp_WrongFlags(t *testing.T) { } } -func TestEncodeApp_Run_WithFailedVQM(t *testing.T) { +/************************************* +* Negative tests for run sub-command. + *************************************/ + +func TestRunApp_Run_WithFailedVQM(t *testing.T) { // Create a fake ffmpeg and modify PATH so that it's picked up first and // blows up VQM calculation. fixCreateFakeFfmpegAndPutItOnPath(t) - app := CreateEncodeCommand() - plan, _ := fixPlanConfig(t) - gotErr := app.Run([]string{"-plan", plan}) + app := CreateRunCommand() + plan := fixPlanConfig(t) + outDir := path.Join(t.TempDir(), "out") + + gotErr := app.Run([]string{"-plan", plan, "-out-dir", outDir}) if gotErr == nil { t.Fatal("Error expected but go ") } @@ -78,9 +128,9 @@ func TestEncodeApp_Run_WithFailedVQM(t *testing.T) { } } -func TestEncodeApp_Run_WithInvalidPlanConfigParseError(t *testing.T) { - app := CreateEncodeCommand() - gotErr := app.Run([]string{"-plan", fixPlanConfigInvalid(t)}) +func TestRunApp_Run_WithInvalidPlanConfigParseError(t *testing.T) { + app := CreateRunCommand() + gotErr := app.Run([]string{"-plan", fixPlanConfigInvalid(t), "-out-dir", t.TempDir()}) if gotErr == nil { t.Fatal("Error expected but go ") } @@ -97,142 +147,54 @@ func TestEncodeApp_Run_WithInvalidPlanConfigParseError(t *testing.T) { } } -func TestEncodeApp_Run(t *testing.T) { - plan, _ := fixPlanConfig(t) - t.Run("Should succeed execution with -plan flag", func(t *testing.T) { - // Since we do not specify -report option, report content will end up on - // stdout, we want to redirect stdout to avoid flooding test output and - // also to do minimal checks. - redirStdout := path.Join(t.TempDir(), "report.json") - redirectStdout(redirStdout, t) - app := CreateEncodeCommand() - err := app.Run([]string{"-plan", plan}) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } +func TestRunApp_Run_WithNonEmptyOutDirShouldTerminate(t *testing.T) { + app := CreateRunCommand() + plan := fixPlanConfig(t) + // Dir containing plan file by definition is non-empty. + outDir := path.Dir(plan) - buf, err2 := os.ReadFile(redirStdout) - if err2 != nil { - t.Errorf("Unexpected error: %v", err2) - } - if len(buf) == 0 { - t.Errorf("No data in report file") - } - }) - t.Run("Should succeed execution with -report flag", func(t *testing.T) { - app := CreateEncodeCommand() - report := path.Join(t.TempDir(), "report.json") - err := app.Run([]string{"-plan", plan, "-report", report}) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - buf, err2 := os.ReadFile(report) - if err2 != nil { - t.Errorf("Unexpected error: %v", err2) - } - if len(buf) == 0 { - t.Errorf("No data in report file") - } - }) - t.Run("Should succeed execution with -dry-run flag", func(t *testing.T) { - app := CreateEncodeCommand() - err := app.Run([]string{"-dry-run", "-plan", plan}) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - }) -} + t.Logf("Given existing out dir: %s", outDir) + if err := os.MkdirAll(outDir, 0o755); err != nil { + t.Fatalf("Cannot create out dir: %s", err) + } -// Analyse subcommand related tests. -func TestAnalyseApp_WrongFlags(t *testing.T) { - tests := map[string]struct { - // substring in Error() - want string - givenArgs []string - }{ - "Wrong flags": { - givenArgs: []string{"-zzz", "aaaa", "-version"}, - want: "analyse usage error", - }, - "Mandatory -report flag": { - givenArgs: []string{"-out-dir", "/tmp"}, - want: "mandatory option -report is missing", - }, + t.Log("When plan is executed") + gotErr := app.Run([]string{"-plan", plan, "-out-dir", outDir}) - "Mandatory -out-dir flag": { - givenArgs: []string{"-report", "testdata/encoding_artifacts/report.json"}, - want: "mandatory option -out-dir is missing", - }, - "Non-existent conf": { - givenArgs: []string{"-report", "a/yyy", "-out-dir", "/tmp"}, - want: "report file does not exist?", - }, + t.Log("Then there is an error and program terminates") + if gotErr == nil { + t.Fatal("Error expected but go ") } + wantErrMsg := "non-empty out dir" + wantExitCode := 1 - for name, tc := range tests { - wantExitCode := 2 - t.Run(name, func(t *testing.T) { - cmd := CreateAnalyseCommand() - // Discard usage output so that during test execution test output is - // not flooded with command Usage/Help stuff. - if c, ok := cmd.(*AnalyseApp); ok { - c.fs.SetOutput(io.Discard) - } - gotErr := cmd.Run(tc.givenArgs) - if !strings.Contains(gotErr.Error(), tc.want) { - t.Errorf("Error mismatch (-want +got):\n-%s\n+%s\n", tc.want, gotErr.Error()) - } - if e, ok := gotErr.(*AppError); ok { - gotExitCode := e.ExitCode() - if diff := cmp.Diff(wantExitCode, gotExitCode); diff != "" { - t.Errorf("ExitCode mismatch (-want +got):\n%s", diff) - } - } else { - t.Errorf("Unexpected error type: %v", gotErr) - } - }) + if !strings.HasPrefix(gotErr.Error(), wantErrMsg) { + t.Errorf("Error message mismatch (-want +got):\n-%s\n+%s", wantErrMsg, gotErr.Error()) + } + + gotExitCode := gotErr.(*AppError).ExitCode() + if diff := cmp.Diff(wantExitCode, gotExitCode); diff != "" { + t.Errorf("ExitCode mismatch (-want +got):\n%s", diff) } } -// Integration tests for ease tool. +// Functional tests for other sub-commands.. func TestIntegration_AllSubcommands(t *testing.T) { tempDir := t.TempDir() - ePlan, encOutDir := fixPlanConfig(t) - report := path.Join(tempDir, "report.json") - analyseOutDir := path.Join(tempDir, "out") + outDir := path.Join(tempDir, "out") + ePlan := fixPlanConfig(t) - // Encode command will generate artifacts needed for other subcommands, so - // it is more like precondition. - err := CreateEncodeCommand().Run([]string{"-plan", ePlan, "-report", report}) + // Run command will generate encoding artifacts and analysis artifacts for later use + // ans inputs. + err := CreateRunCommand().Run([]string{"-plan", ePlan, "-out-dir", outDir}) if err != nil { - t.Errorf("Unexpected error running encode: %v", err) + t.Fatalf("Unexpected during plan execution: %v", err) } - t.Run("Analyse should create bitrate, VMAF, PSNR and SSIM plots", func(t *testing.T) { - // Run analyse subcommand. - err := CreateAnalyseCommand().Run([]string{"-report", report, "-out-dir", analyseOutDir}) - if err != nil { - t.Errorf("Unexpected error running analysis: %v", err) - } - - if m, _ := filepath.Glob(fmt.Sprintf("%s/*/*bitrate.png", analyseOutDir)); len(m) != 1 { - t.Errorf("Expecting one file for bitrate plot, got: %s", m) - } - if m, _ := filepath.Glob(fmt.Sprintf("%s/*/*vmaf.png", analyseOutDir)); len(m) != 1 { - t.Errorf("Expecting one file for VMAF plot, got: %s", m) - } - if m, _ := filepath.Glob(fmt.Sprintf("%s/*/*psnr.png", analyseOutDir)); len(m) != 1 { - t.Errorf("Expecting one file for PSNR plot, got: %s", m) - } - if m, _ := filepath.Glob(fmt.Sprintf("%s/*/*ms-ssim.png", analyseOutDir)); len(m) != 1 { - t.Errorf("Expecting one file for MS-SSIM plot, got: %s", m) - } - }) - t.Run("Vqmplot should create plots", func(t *testing.T) { var vqmFile string // Need to get file with VQMs from encode stage. - if m, _ := filepath.Glob(fmt.Sprintf("%s/*vqm.json", encOutDir)); len(m) != 1 { + if m, _ := filepath.Glob(fmt.Sprintf("%s/*vqm.json", outDir)); len(m) != 1 { t.Errorf("Expecting one file with VQM data, got: %s", m) } else { vqmFile = m[0] @@ -255,7 +217,7 @@ func TestIntegration_AllSubcommands(t *testing.T) { t.Run("Bitrate should create bitrate plot", func(t *testing.T) { var compressedFile string // Need to get compressed file from encode stage. - if m, _ := filepath.Glob(fmt.Sprintf("%s/*.mp4", encOutDir)); len(m) != 1 { + if m, _ := filepath.Glob(fmt.Sprintf("%s/*.mp4", outDir)); len(m) != 1 { t.Errorf("Expecting one compressed file, got: %s", m) } else { compressedFile = m[0] diff --git a/encode.go b/encode.go deleted file mode 100644 index 5d930cc..0000000 --- a/encode.go +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright ©2022 Evolution. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -// ease tool's encode subcommand implementation. - -package main - -import ( - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/evolution-gaming/ease/internal/encoding" - "github.com/evolution-gaming/ease/internal/logging" - "github.com/evolution-gaming/ease/internal/tools" - "github.com/evolution-gaming/ease/internal/vqm" -) - -// CreateEncodeCommand will create Commander instace from EncodeApp. -func CreateEncodeCommand() Commander { - longHelp := `Subcommand "encode" will execute encoding plan according to definition in file -provided as parameter to -plan flag. This flag is mandatory. - -Examples: - - ease encode -plan plan.json -report encode_report.json` - app := &EncodeApp{ - fs: flag.NewFlagSet("encode", flag.ContinueOnError), - } - app.fs.StringVar(&app.flPlan, "plan", "", "Encoding plan configuration file") - app.fs.StringVar(&app.flReport, "report", "", "Encoding plan report file (default is stdout)") - app.fs.BoolVar(&app.flCalculateVQM, "vqm", true, "Calculate VQMs") - app.fs.BoolVar(&app.flDryRun, "dry-run", false, "Do not actually run, just do checks and validation") - app.fs.Usage = func() { - printSubCommandUsage(longHelp, app.fs) - } - - return app -} - -// Make sure EncodeApp implements Commander interface. -var _ Commander = (*EncodeApp)(nil) - -// EncodeApp is subcommand application context that implements Commander interface. -type EncodeApp struct { - // FlagSet instance - fs *flag.FlagSet - // Encoding plan config file flag - flPlan string - // Execution report output file flag - flReport string - // Calculate VQM flag - flCalculateVQM bool - // Dry run mode flag - flDryRun bool -} - -func (a *EncodeApp) Name() string { - return a.fs.Name() -} - -func (a *EncodeApp) Help() { - a.fs.Usage() -} - -// ReportWriter returns io.Writer for report. -func (a *EncodeApp) ReportWriter() io.Writer { - var out io.WriteCloser - // Either write to file or stdout. - if a.flReport == "" { - // Case to write to stdout. - return os.Stdout - } - // Case to write to file. - out, err := os.Create(a.flReport) - if err != nil { - logging.Infof("Unable to create result file redirecting to stdout: %s", err) - return os.Stdout - } - return out -} - -// Init will do App state initialization. -func (a *EncodeApp) Init(args []string) error { - if err := a.fs.Parse(args); err != nil { - return &AppError{ - exitCode: 2, - msg: fmt.Sprintf("%s usage error", a.Name()), - } - } - - // Encoding plan config file is mandatory. - if a.flPlan == "" { - a.Help() - return &AppError{ - exitCode: 2, - msg: "mandatory option -plan is missing", - } - } - - // Encoding plan config file should exist. - if _, err := os.Stat(a.flPlan); err != nil { - a.Help() - return &AppError{ - exitCode: 2, - msg: fmt.Sprintf("encoding plan file does not exist? %s", err), - } - } - - return nil -} - -// Run is main entry point into App execution. -func (a *EncodeApp) Run(args []string) error { - if err := a.Init(args); err != nil { - return err - } - - logging.Debugf("Encoding plan config file: %v", a.flPlan) - - plan, err := createPlanFromJSONConfig(a.flPlan) - if err != nil { - return &AppError{exitCode: 1, msg: err.Error()} - } - - // Check external tool dependencies - for VMAF calculations we require - // ffmpeg and libvmaf model file available. - ffmpegPath, err := tools.FfmpegPath() - if err != nil { - return &AppError{exitCode: 1, msg: fmt.Sprintf("dependency ffmpeg: %s", err)} - } - - libvmafModelPath, err := tools.FindLibvmafModel() - if err != nil { - return &AppError{exitCode: 1, msg: fmt.Sprintf("dependency libvmaf model: %s", err)} - } - - // Early return in "dry run" mode. - if a.flDryRun { - logging.Info("Dry run mode finished!") - return nil - } - - result, err := plan.Run() - // Make sure to log any errors from RunResults. - if ur := unrollResultErrors(result.RunResults); ur != "" { - logging.Infof("Run had following ERRORS:\n%s", ur) - } - if err != nil { - return &AppError{exitCode: 1, msg: err.Error()} - } - - // Do VQM calculations for encoded videos. - var vqmFailed bool = false - var vqmResults []namedVqmResult - if a.flCalculateVQM { - for i := range result.RunResults { - r := &result.RunResults[i] - resFile := strings.TrimSuffix(r.CompressedFile, filepath.Ext(r.CompressedFile)) + "_vqm.json" - vqmTool, err := vqm.NewFfmpegVMAF(ffmpegPath, libvmafModelPath, r.CompressedFile, r.SourceFile, resFile) - if err != nil { - vqmFailed = true - logging.Infof("Error while initializing VQM tool: %s", err) - continue - } - - logging.Infof("Start measuring VQMs for %s", r.CompressedFile) - if err = vqmTool.Measure(); err != nil { - vqmFailed = true - logging.Infof("Failed calculate VQM for %s due to error: %s", r.CompressedFile, err) - continue - } - - res, err := vqmTool.GetResult() - if err != nil { - logging.Infof("Error while getting VQM result for %s: %s", r.CompressedFile, err) - } - vqmResults = append(vqmResults, namedVqmResult{Name: r.Name, Result: res}) - - logging.Infof("Done measuring VQMs for %s", r.CompressedFile) - } - } - if vqmFailed { - return &AppError{ - msg: "VQM calculations had errors, see log for reasons", - exitCode: 1, - } - } - - // Report encoding application results. - rep := report{ - EncodingResult: result, - VQMResults: vqmResults, - } - rep.WriteJSON(a.ReportWriter()) - - return nil -} - -// unrollResultErrors helper to unroll all errors from RunResults into a string. -func unrollResultErrors(results []encoding.RunResult) string { - sb := strings.Builder{} - for i := range results { - rr := &results[i] - if len(rr.Errors) != 0 { - for _, e := range rr.Errors { - sb.WriteString(fmt.Sprintf("%s:\n\t%s\n", rr.Name, e.Error())) - } - } - } - return sb.String() -} - -// createPlanFromJSONConfig creates a Plan instance from JSON configuration. -func createPlanFromJSONConfig(cfgFile string) (encoding.Plan, error) { - var plan encoding.Plan - fd, err := os.Open(cfgFile) - if err != nil { - return plan, fmt.Errorf("cannot open conf file: %w", err) - } - defer fd.Close() - - jdoc, err := io.ReadAll(fd) - if err != nil { - return plan, fmt.Errorf("cannot read data from conf file: %w", err) - } - - pc, err := encoding.NewPlanConfigFromJSON(jdoc) - if err != nil { - return plan, fmt.Errorf("cannot create PlanConfig: %w", err) - } - - if ok, err := pc.IsValid(); !ok { - ev := &encoding.PlanConfigError{} - if errors.As(err, &ev) { - logging.Debugf( - "PlanConfig validation failures:\n%s", - strings.Join(ev.Reasons(), "\n")) - } - return plan, fmt.Errorf("PlanConfig not valid: %w", err) - } - - return encoding.NewPlan(pc), nil -} diff --git a/helpers_test.go b/helpers_test.go index 9f2a86c..b50abc7 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -19,10 +19,8 @@ import ( // To make "encoding" faster we just copy source to destination, for the // purposes of tests it is irrelevant if we use realistic encoder or just a // simple file copy. -func fixPlanConfig(t *testing.T) (fPath, outDir string) { - outDir = t.TempDir() +func fixPlanConfig(t *testing.T) (fPath string) { payload := []byte(fmt.Sprintf(`{ - "OutDir": "%s", "Inputs": [ "testdata/video/testsrc01.mp4" ], @@ -32,8 +30,8 @@ func fixPlanConfig(t *testing.T) (fPath, outDir string) { "CommandTpl": ["cp -v ", "%%INPUT%% ", "%%OUTPUT%%.mp4"] } ] - }`, outDir)) - fPath = path.Join(outDir, "minimal.json") + }`)) + fPath = path.Join(t.TempDir(), "minimal.json") err := os.WriteFile(fPath, payload, fs.FileMode(0o644)) if err != nil { t.Fatalf("Unexpected error: %v", err) @@ -44,7 +42,6 @@ func fixPlanConfig(t *testing.T) (fPath, outDir string) { // fixPlanConfigInvalid fixture provides invalid encoding plan. func fixPlanConfigInvalid(t *testing.T) (fPath string) { payload := []byte(`{ - "OutDir": "/tmp", "Inputs": [ "non-existent" ] @@ -81,19 +78,3 @@ func fixCreateFakeFfmpegAndPutItOnPath(t *testing.T) { t.Fatalf("Failure copying: %v", err) } } - -// redirectStdout will temporarily redirect stdout to file. -// -// Useful to silence all output to stdout during tests and/or to capture stdout. -func redirectStdout(filePath string, t *testing.T) { - stdout := os.Stdout - redirected, err := os.Create(filePath) - if err != nil { - t.Fatalf("unable to open file for redirection: %v", err) - } - os.Stdout = redirected - t.Cleanup(func() { - os.Stdout = stdout - defer redirected.Close() - }) -} diff --git a/internal/encoding/plan.go b/internal/encoding/plan.go index 10c3048..edaeb91 100644 --- a/internal/encoding/plan.go +++ b/internal/encoding/plan.go @@ -156,7 +156,7 @@ func (s *Scheme) Expand(sourceFiles []string, outDir string) (cmds []EncoderCmd) compressedFileExt = m[1] } - // Generate varios filenames for later use. + // Generate various filenames for later use. compressedFile := fmt.Sprintf("%s%s", oFileBase, compressedFileExt) outputFile := fmt.Sprintf("%s.out", oFileBase) logFile := fmt.Sprintf("%s.log", oFileBase) @@ -196,14 +196,17 @@ type Plan struct { PlanConfig // Executable encoder commands Commands []EncoderCmd + // Output directory + OutDir string // Flag to signal if output dir has been created outDirCreated bool } // NewPlan will create Plan instance from given PlanConfig. -func NewPlan(pc PlanConfig) Plan { +func NewPlan(pc PlanConfig, outDir string) Plan { p := Plan{ PlanConfig: pc, + OutDir: outDir, outDirCreated: false, } for _, scheme := range p.Schemes { diff --git a/internal/encoding/plan_config.go b/internal/encoding/plan_config.go index 9abec69..1a461cb 100644 --- a/internal/encoding/plan_config.go +++ b/internal/encoding/plan_config.go @@ -35,8 +35,6 @@ func (e *PlanConfigError) addReason(reason string) { // PlanConfig holds configuration for new Plan creation. type PlanConfig struct { - // Directory to store compressed videos, logfiles etc. - OutDir string // List of source (mezzanine) video files. Inputs []string Schemes []Scheme @@ -64,9 +62,6 @@ func (p *PlanConfig) IsValid() (bool, error) { if len(p.Schemes) == 0 { errPlanConfig.addReason("Schemes missing") } - if p.OutDir == "" { - errPlanConfig.addReason("OutDir missing") - } for _, i := range p.Inputs { if _, err := os.Stat(i); err != nil { diff --git a/internal/encoding/plan_config_test.go b/internal/encoding/plan_config_test.go index b38358e..c80e6e7 100644 --- a/internal/encoding/plan_config_test.go +++ b/internal/encoding/plan_config_test.go @@ -24,7 +24,6 @@ func TestNewPlanConfigFromJSON(t *testing.T) { }{ "Positive": { given: []byte(`{ - "OutDir": "out", "Inputs": [ "src/vid1.mp4", "src/vid2.mp4" @@ -41,7 +40,6 @@ func TestNewPlanConfigFromJSON(t *testing.T) { ] }`), want: PlanConfig{ - OutDir: "out", Inputs: []string{ "src/vid1.mp4", "src/vid2.mp4", @@ -55,8 +53,8 @@ func TestNewPlanConfigFromJSON(t *testing.T) { }, // Should this be positive?! "Positive incomplete JSON": { - given: []byte(`{ "OutDir": "out" }`), - want: PlanConfig{OutDir: "out"}, + given: []byte(`{ "Inputs": ["input1"]}`), + want: PlanConfig{Inputs: []string{"input1"}}, err: nil, }, "Negative invalid JSON": { @@ -89,7 +87,6 @@ func TestNewPlanConfigFromJSON(t *testing.T) { func TestPlanConfigIsValid(t *testing.T) { pc := PlanConfig{ - OutDir: ".", Inputs: []string{"../../testdata/video/testsrc01.mp4"}, Schemes: []Scheme{{}}, } @@ -119,12 +116,11 @@ func TestNegativePlanConfigIsValid(t *testing.T) { "Negative nil value": { given: PlanConfig{}, wantReasons: []string{ - "Inputs missing", "Schemes missing", "OutDir missing", + "Inputs missing", "Schemes missing", }, }, "Negative Schemes missing": { given: PlanConfig{ - OutDir: ".", Inputs: []string{"../../testdata/video/testsrc01.mp4"}, }, wantReasons: []string{ @@ -133,7 +129,6 @@ func TestNegativePlanConfigIsValid(t *testing.T) { }, "Negative Inputs missing": { given: PlanConfig{ - OutDir: ".", Schemes: []Scheme{{}}, }, wantReasons: []string{ @@ -142,7 +137,6 @@ func TestNegativePlanConfigIsValid(t *testing.T) { }, "Negative duplicate Inputs": { given: PlanConfig{ - OutDir: ".", Schemes: []Scheme{{}}, Inputs: []string{"../../testdata/video/testsrc01.mp4", "../../testdata/video/testsrc01.mp4"}, }, @@ -150,19 +144,8 @@ func TestNegativePlanConfigIsValid(t *testing.T) { "Duplicate inputs detected", }, }, - "Negative empty OutDir": { - given: PlanConfig{ - OutDir: "", - Inputs: []string{"../../testdata/video/testsrc01.mp4"}, - Schemes: []Scheme{{}}, - }, - wantReasons: []string{ - "OutDir missing", - }, - }, "Negative wrong file in Inputs": { given: PlanConfig{ - OutDir: ".", Inputs: []string{"no_existent_file"}, Schemes: []Scheme{{}}, }, diff --git a/internal/encoding/plan_test.go b/internal/encoding/plan_test.go index 6660e16..55c2802 100644 --- a/internal/encoding/plan_test.go +++ b/internal/encoding/plan_test.go @@ -26,10 +26,9 @@ func TestCreatePlanFromConfig(t *testing.T) { {"x264 param1 x", "ffmpeg -i %INPUT% -param1 x -y %OUTPUT%.mp4"}, {"x264_param1_y", "ffmpeg -i %INPUT% -param1 y -y %OUTPUT%.mp4"}, }, - OutDir: "out", } // When I create a new Plan from PlanConfig - plan := NewPlan(planConfig) + plan := NewPlan(planConfig, "out") var gotCommands, gotOutputFiles []string for _, c := range plan.Commands { gotCommands = append(gotCommands, c.Cmd) @@ -86,11 +85,10 @@ func Test_HappyPathPlanExecution(t *testing.T) { "ffmpeg -i %INPUT% -an -c:v copy -y %OUTPUT%.mkv", }, }, - OutDir: outDir, } wantResultCount := len(pc.Schemes) * len(pc.Inputs) - plan = NewPlan(pc) + plan = NewPlan(pc, outDir) gotResult, err := plan.Run() t.Run("Encoding result should have start and end time stamps", func(t *testing.T) { @@ -278,12 +276,11 @@ func TestNegativeEncodingPlanRunWitOutputOverflow(t *testing.T) { // Unix yes should be fast enough to generate output that overflows {"large output", "../../testdata/helpers/stderr yes"}, }, - OutDir: outDir, } // 128 + 13 (SIGPIPE) wantExitCode := 141 // Given a Plan - plan := NewPlan(planConfig) + plan := NewPlan(planConfig, outDir) // When I do an unsuccessful Run of a Plan gotResult, err := plan.Run() @@ -309,10 +306,9 @@ func TestNegativeEncodingPlanResults(t *testing.T) { // For the sake of completeness - have a successful run also {"passing", "../../testdata/helpers/stderr cp -v %INPUT% %OUTPUT%.mp4"}, }, - OutDir: outDir, } // Given a Plan - plan := NewPlan(planConfig) + plan := NewPlan(planConfig, outDir) // When I do an unsuccessful Run of a Plan gotResult, err := plan.Run() diff --git a/main.go b/main.go index eba3e93..c2af57f 100644 --- a/main.go +++ b/main.go @@ -32,8 +32,7 @@ func root() error { // Register all subcommands here. subCmds := []Commander{ - CreateEncodeCommand(), - CreateAnalyseCommand(), + CreateRunCommand(), CreateBitrateCommand(), CreateVQMPlotCommand(), } @@ -72,7 +71,7 @@ func root() error { // Parse global flags. if err := fs.Parse(os.Args[1:]); err != nil { return &AppError{ - msg: err.Error(), + msg: fmt.Sprintf("parsing global flags: %s", err), exitCode: 1, } } diff --git a/run.go b/run.go new file mode 100644 index 0000000..5cde69a --- /dev/null +++ b/run.go @@ -0,0 +1,338 @@ +// Copyright ©2022 Evolution. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// ease tool's run subcommand implementation. + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/evolution-gaming/ease/internal/analysis" + "github.com/evolution-gaming/ease/internal/encoding" + "github.com/evolution-gaming/ease/internal/logging" + "github.com/evolution-gaming/ease/internal/tools" + "github.com/evolution-gaming/ease/internal/vqm" +) + +const reportFileName = "report.json" + +// CreateRunCommand will create Commander instance from App. +func CreateRunCommand() Commander { + longHelp := `Subcommand "run" will execute encoding plan according to definition in file +provided as parameter to -plan flag and will calculate and report VQM +metrics. This flag is mandatory. + +Examples: + + ease run -plan plan.json -out-dir path/to/output/dir` + app := &App{ + fs: flag.NewFlagSet("run", flag.ContinueOnError), + } + app.fs.StringVar(&app.flPlan, "plan", "", "Encoding plan configuration file") + app.fs.StringVar(&app.flOutDir, "out-dir", "", "Output directory to store results") + app.fs.BoolVar(&app.flDryRun, "dry-run", false, "Do not actually run, just do checks and validation") + app.fs.Usage = func() { + printSubCommandUsage(longHelp, app.fs) + } + + return app +} + +// Make sure App implements Commander interface. +var _ Commander = (*App)(nil) + +// App is subcommand application context that implements Commander interface. +type App struct { + // FlagSet instance + fs *flag.FlagSet + // Encoding plan config file flag + flPlan string + // Output directory for analysis results + flOutDir string + // Dry run mode flag + flDryRun bool + // ffmpeg tool's path + ffmpegPath string + // VMAF model path + libvmafModelPath string +} + +func (a *App) Name() string { + return a.fs.Name() +} + +func (a *App) Help() { + a.fs.Usage() +} + +// init will do App state initialization. +func (a *App) init(args []string) error { + if err := a.fs.Parse(args); err != nil { + return &AppError{ + exitCode: 2, + msg: fmt.Sprintf("%s usage error", a.Name()), + } + } + + // Encoding plan config file is mandatory. + if a.flPlan == "" { + a.Help() + return &AppError{ + exitCode: 2, + msg: "mandatory option -plan is missing", + } + } + + // Output dir is mandatory. + if a.flOutDir == "" { + a.Help() + return &AppError{ + exitCode: 2, + msg: "mandatory option -out-dir is missing", + } + } + + // Encoding plan config file should exist. + if _, err := os.Stat(a.flPlan); err != nil { + a.Help() + return &AppError{ + exitCode: 2, + msg: fmt.Sprintf("encoding plan file does not exist? %s", err), + } + } + + // Do not write over existing output directory. + if isNonEmptyDir(a.flOutDir) { + return &AppError{exitCode: 1, msg: fmt.Sprintf("non-empty out dir: %s", a.flOutDir)} + } + // Check external tool dependencies - we require ffprobe to do bitrate calculations. + if _, err := tools.FfprobePath(); err != nil { + return &AppError{exitCode: 1, msg: fmt.Sprintf("dependency ffprobe: %s", err)} + } + + // Check external tool dependencies - for VMAF calculations we require + // ffmpeg and libvmaf model file available. + ffmpegPath, err := tools.FfmpegPath() + if err != nil { + return &AppError{exitCode: 1, msg: fmt.Sprintf("dependency ffmpeg: %s", err)} + } + a.ffmpegPath = ffmpegPath + + libvmafModelPath, err := tools.FindLibvmafModel() + if err != nil { + return &AppError{exitCode: 1, msg: fmt.Sprintf("dependency libvmaf model: %s", err)} + } + a.libvmafModelPath = libvmafModelPath + + return nil +} + +// encode will run encoding stage of plan execution. +func (a *App) encode(plan encoding.Plan) (*report, error) { + rep := &report{} + + result, err := plan.Run() + // Make sure to log any errors from RunResults. + if ur := unrollResultErrors(result.RunResults); ur != "" { + logging.Infof("Run had following ERRORS:\n%s", ur) + } + if err != nil { + return rep, &AppError{exitCode: 1, msg: err.Error()} + } + rep.EncodingResult = result + + // Do VQM calculations for encoded videos. + var vqmFailed bool = false + + for i := range result.RunResults { + r := &result.RunResults[i] + resFile := strings.TrimSuffix(r.CompressedFile, filepath.Ext(r.CompressedFile)) + "_vqm.json" + vqmTool, err2 := vqm.NewFfmpegVMAF(a.ffmpegPath, a.libvmafModelPath, r.CompressedFile, r.SourceFile, resFile) + if err2 != nil { + vqmFailed = true + logging.Infof("Error while initializing VQM tool: %s", err2) + continue + } + + logging.Infof("Start measuring VQMs for %s", r.CompressedFile) + if err2 = vqmTool.Measure(); err2 != nil { + vqmFailed = true + logging.Infof("Failed calculate VQM for %s due to error: %s", r.CompressedFile, err2) + continue + } + + res, err2 := vqmTool.GetResult() + if err2 != nil { + logging.Infof("Error while getting VQM result for %s: %s", r.CompressedFile, err2) + } + rep.VQMResults = append(rep.VQMResults, namedVqmResult{Name: r.Name, Result: res}) + + logging.Infof("Done measuring VQMs for %s", r.CompressedFile) + } + + if vqmFailed { + return rep, &AppError{ + msg: "VQM calculations had errors, see log for reasons", + exitCode: 1, + } + } + + // Write report of encoding results. + reportPath := path.Join(a.flOutDir, reportFileName) + reportOut, err := os.Create(reportPath) + if err != nil { + return rep, &AppError{ + msg: fmt.Sprintf("Unable to create report file: %s", err), + exitCode: 1, + } + } + defer reportOut.Close() + rep.WriteJSON(reportOut) + + return rep, nil +} + +// analyse will run analysis stage of plan execution. +func (a *App) analyse(rep *report) error { + // Extract data to work with. + srcData := extractSourceData(rep) + d, err := json.MarshalIndent(srcData, "", " ") + if err != nil { + return &AppError{ + exitCode: 1, + msg: err.Error(), + } + } + logging.Debugf("Analysis for:\n%s", d) + + // TODO: this is a good place to do goroutines iterate over sources and do stuff. + + for _, v := range srcData { + // Create separate dir for results. + base := path.Base(v.CompressedFile) + base = strings.TrimSuffix(base, path.Ext(base)) + logging.Infof("Analysing %s", v.CompressedFile) + resDir := path.Join(a.flOutDir, base) + if err := os.MkdirAll(resDir, os.FileMode(0o755)); err != nil { + return &AppError{ + msg: fmt.Sprintf("failed creating directory: %s", err), + exitCode: 1, + } + } + + compressedFile := v.CompressedFile + vqmFile := v.VqmResultFile + // In case compressed and VQM result file path in not absolute we assume + // it must be relative to WorkDir. + if !path.IsAbs(compressedFile) { + compressedFile = path.Join(v.WorkDir, compressedFile) + } + if !path.IsAbs(vqmFile) { + vqmFile = path.Join(v.WorkDir, vqmFile) + } + bitratePlot := path.Join(resDir, base+"_bitrate.png") + vmafPlot := path.Join(resDir, base+"_vmaf.png") + psnrPlot := path.Join(resDir, base+"_psnr.png") + msssimPlot := path.Join(resDir, base+"_ms-ssim.png") + + jsonFd, err := os.Open(vqmFile) + if err != nil { + return &AppError{ + msg: fmt.Sprintf("failed opening VQM file: %s", err), + exitCode: 1, + } + } + + var frameMetrics vqm.FrameMetrics + err = frameMetrics.FromFfmpegVMAF(jsonFd) + // Close jsonFd file descriptor at earliest convenience. Should avoid use of defer + // in loop in this case. + jsonFd.Close() + if err != nil { + return &AppError{ + msg: fmt.Sprintf("failed converting to FrameMetrics: %s", err), + exitCode: 1, + } + } + + var vmafs, psnrs, msssims []float64 + for _, v := range frameMetrics { + vmafs = append(vmafs, v.VMAF) + psnrs = append(psnrs, v.PSNR) + msssims = append(msssims, v.MS_SSIM) + } + + if err := analysis.MultiPlotBitrate(compressedFile, bitratePlot); err != nil { + return &AppError{ + msg: fmt.Sprintf("failed creating bitrate plot: %s", err), + exitCode: 1, + } + } + logging.Infof("Bitrate plot done: %s", bitratePlot) + + if err := analysis.MultiPlotVqm(vmafs, "VMAF", base, vmafPlot); err != nil { + return &AppError{ + msg: fmt.Sprintf("failed creating VMAF multiplot: %s", err), + exitCode: 1, + } + } + logging.Infof("VMAF multi-plot done: %s", vmafPlot) + + if err := analysis.MultiPlotVqm(psnrs, "PSNR", base, psnrPlot); err != nil { + return &AppError{ + msg: fmt.Sprintf("failed creating PSNR multiplot: %s", err), + exitCode: 1, + } + } + logging.Infof("PSNR multi-plot done: %s", psnrPlot) + + if err := analysis.MultiPlotVqm(msssims, "MS-SSIM", base, msssimPlot); err != nil { + return &AppError{ + msg: fmt.Sprintf("failed creating MS-SSIM multiplot: %s", err), + exitCode: 1, + } + } + logging.Infof("MS-SSIM multi-plot done: %s", msssimPlot) + } + + return nil +} + +// Run is main entry point into App execution. +func (a *App) Run(args []string) error { + if err := a.init(args); err != nil { + return err + } + + logging.Debugf("Encoding plan config file: %v", a.flPlan) + + pc, err := createPlanConfig(a.flPlan) + if err != nil { + return &AppError{exitCode: 1, msg: err.Error()} + } + + plan := encoding.NewPlan(pc, a.flOutDir) + + // Early return in "dry run" mode. + if a.flDryRun { + logging.Info("Dry run mode finished!") + return nil + } + + // Run encode stage. + rep, err := a.encode(plan) + if err != nil { + return &AppError{exitCode: 1, msg: err.Error()} + } + + // Run analysis stage. + return a.analyse(rep) +}