From 09946c089474dc608d38b9f18bae3e1fdf090937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Fri, 22 Jul 2022 16:00:47 +0200 Subject: [PATCH] Worker: use bundled FFmpeg if available Worker will now try one of the following paths, relative to the flamenco-worker executable, in order to find FFmpeg. If they cannot be found, `$PATH` is searched for FFmpeg. - `tools/ffmpeg-$GOOS-$GOARCH` - `tools/ffmpeg-$GOOS` - `tools/ffmpeg` On Windows these paths will have a `.exe` suffix appended. `$GOOS` is the operating system, like "linux", "darwin", "windows", etc. `$GOARCH` is the architecture, like "amd64", "386", etc. --- cmd/flamenco-worker/main.go | 17 +++ internal/find_ffmpeg/filenames_nonwindows.go | 5 + internal/find_ffmpeg/filenames_windows.go | 5 + internal/find_ffmpeg/find_ffmpeg.go | 125 +++++++++++++++++++ internal/worker/command_ffmpeg.go | 17 +++ 5 files changed, 169 insertions(+) create mode 100644 internal/find_ffmpeg/filenames_nonwindows.go create mode 100644 internal/find_ffmpeg/filenames_windows.go create mode 100644 internal/find_ffmpeg/find_ffmpeg.go diff --git a/cmd/flamenco-worker/main.go b/cmd/flamenco-worker/main.go index 1c523ae2..9ee593b6 100644 --- a/cmd/flamenco-worker/main.go +++ b/cmd/flamenco-worker/main.go @@ -7,6 +7,7 @@ import ( "errors" "flag" "fmt" + "io/fs" "net/url" "os" "os/signal" @@ -20,6 +21,7 @@ import ( "github.com/rs/zerolog/log" "git.blender.org/flamenco/internal/appinfo" + "git.blender.org/flamenco/internal/find_ffmpeg" "git.blender.org/flamenco/internal/worker" ) @@ -92,6 +94,8 @@ func main() { configWrangler.SetManagerURL(url) } + findFFmpeg() + // Give the auto-discovery some time to find a Manager. discoverTimeout := 10 * time.Minute discoverCtx, discoverCancel := context.WithTimeout(context.Background(), discoverTimeout) @@ -252,3 +256,16 @@ func logFatalManagerDiscoveryError(err error, discoverTimeout time.Duration) { log.Fatal().Err(err).Msg("auto-discovery error") } } + +// findFFmpeg tries to find FFmpeg, in order to show its version (if found) or a warning (if not). +func findFFmpeg() { + result, err := find_ffmpeg.Find() + switch { + case errors.Is(err, fs.ErrNotExist): + log.Warn().Msg("FFmpeg could not be found on this system, render jobs may not run correctly") + case err != nil: + log.Warn().Err(err).Msg("there was an unexpected error finding FFmepg on this system, render jobs may not run correctly") + default: + log.Info().Str("path", result.Path).Str("version", result.Version).Msg("FFmpeg found on this system") + } +} diff --git a/internal/find_ffmpeg/filenames_nonwindows.go b/internal/find_ffmpeg/filenames_nonwindows.go new file mode 100644 index 00000000..02949127 --- /dev/null +++ b/internal/find_ffmpeg/filenames_nonwindows.go @@ -0,0 +1,5 @@ +//go:build !windows + +package find_ffmpeg + +const exeSuffix = "" diff --git a/internal/find_ffmpeg/filenames_windows.go b/internal/find_ffmpeg/filenames_windows.go new file mode 100644 index 00000000..d1603f5d --- /dev/null +++ b/internal/find_ffmpeg/filenames_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package find_ffmpeg + +const exeSuffix = ".exe" diff --git a/internal/find_ffmpeg/find_ffmpeg.go b/internal/find_ffmpeg/find_ffmpeg.go new file mode 100644 index 00000000..c7ca2abc --- /dev/null +++ b/internal/find_ffmpeg/find_ffmpeg.go @@ -0,0 +1,125 @@ +// package find_ffmpeg can find an FFmpeg binary on the system. +package find_ffmpeg + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +type Result struct { + Path string + Version string +} + +const ( + // ffmpegExename is the name of the ffmpeg executable. It will be suffixed by + // the platform-depentent `exeSuffix` + ffmpegExename = "ffmpeg" + + // toolsDir is the directory sitting next to the currently running executable, + // in which tools like FFmpeg are searched for. + toolsDir = "tools" +) + +// Find returns the path of an `ffmpeg` executable, +// If there is one next to the currently running executable, that one is used. +// Otherwise $PATH is searched. +func Find() (Result, error) { + path, err := findBundled() + switch { + case errors.Is(err, fs.ErrNotExist): + // Not finding FFmpeg next to the executable is fine, just continue searching. + case err != nil: + // Other errors might be more serious. Log them, but keep going. + log.Error().Err(err).Msg("error finding FFmpeg next to the current executable") + case path != "": + // Found FFmpeg! + return getVersion(path) + } + + path, err = exec.LookPath(ffmpegExename) + if err != nil { + return Result{}, err + } + return getVersion(path) +} + +func findBundled() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("finding current executable: %w", err) + } + + exeDir := filepath.Dir(exe) + + // Subdirectories to use to find the ffmpeg executable. Should go from most to + // least specific for the current platform. + filenames := []string{ + fmt.Sprintf("%s-%s-%s%s", ffmpegExename, runtime.GOOS, runtime.GOARCH, exeSuffix), + fmt.Sprintf("%s-%s%s", ffmpegExename, runtime.GOOS, exeSuffix), + fmt.Sprintf("%s%s", ffmpegExename, exeSuffix), + } + + var firstErr error + for _, filename := range filenames { + ffmpegPath := filepath.Join(exeDir, toolsDir, filename) + _, err = os.Stat(ffmpegPath) + + switch { + case err == nil: + return ffmpegPath, nil + case errors.Is(err, fs.ErrNotExist): + log.Debug().Str("path", ffmpegPath).Msg("FFmpeg not found on this path") + default: + log.Debug().Err(err).Str("path", ffmpegPath).Msg("FFmpeg could not be accessed on this path") + } + + // If every path fails, report on the first-failed path, as that's the most + // specific one. + if firstErr == nil { + firstErr = err + } + } + + return "", firstErr +} + +func getVersion(path string) (Result, error) { + ctx, ctxCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer ctxCancel() + + cmd := exec.CommandContext(ctx, path, "-version") + outputBytes, err := cmd.CombinedOutput() + if err != nil { + return Result{}, fmt.Errorf("running %s -version: %w", path, err) + } + output := string(outputBytes) + + lines := strings.SplitN(output, "\n", 2) + if len(lines) < 2 { + return Result{}, fmt.Errorf("unexpected output (only %d lines) from %s -version: %s", len(lines), path, output) + } + versionLine := lines[0] + + // Get the version from the first line of output. + // ffmpeg version 4.2.7-0ubuntu0.1 Copyright (c) 2000-2022 the FFmpeg developer + if !strings.HasPrefix(versionLine, "ffmpeg version ") { + return Result{}, fmt.Errorf("unexpected output from %s -version: [%s]", path, versionLine) + } + lineParts := strings.SplitN(versionLine, " ", 4) + + return Result{ + Path: path, + Version: lineParts[2], + }, nil +} diff --git a/internal/worker/command_ffmpeg.go b/internal/worker/command_ffmpeg.go index b60ff275..ee188a87 100644 --- a/internal/worker/command_ffmpeg.go +++ b/internal/worker/command_ffmpeg.go @@ -22,6 +22,7 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "git.blender.org/flamenco/internal/find_ffmpeg" "git.blender.org/flamenco/pkg/api" "git.blender.org/flamenco/pkg/crosspath" ) @@ -203,6 +204,22 @@ func cmdFramesToVideoParams(logger zerolog.Logger, cmd api.Command) (CreateVideo parameters.args = append(parameters.args, "-r", strconv.FormatFloat(parameters.fps, 'f', -1, 64)) + // If the executable is just "ffmpeg" or "ffmpeg.exe", find it on the system. + if parameters.exe == "ffmpeg" || parameters.exe == "ffmpeg.exe" { + result, err := find_ffmpeg.Find() + switch { + case errors.Is(err, fs.ErrNotExist): + log.Warn().Msg("FFmpeg could not be found on this system, render jobs may not run correctly") + return parameters, NewParameterInvalidError("exe", cmd, err.Error()) + case err != nil: + log.Warn().Err(err).Msg("there was an unexpected error finding FFmepg on this system, render jobs may not run correctly") + return parameters, NewParameterInvalidError("exe", cmd, err.Error()) + } + + log.Debug().Str("path", result.Path).Str("version", result.Version).Msg("FFmpeg found on this system") + parameters.exe = result.Path + } + return parameters, nil }