
Split "executable" from "its arguments" in blender & ffmpeg commands. Use `{blenderArgs}` variable to hold the default Blender arguments, instead of having both the executable and its arguments in `{blender}`. The reason for this is to support backslashes in the Blender executable path. These were interpreted as escape characters by the shell lexer. The shell lexer based splitting is now only performed on the default arguments, with the result that `C:\Program Files\Blender Foundation\3.3\blender.exe` is now a valid value for `{blender}`. This does mean that this is backward incompatible change, and that it requires setting up Flamenco Manager again, and that older jobs will not be able to be rerun. It is recommended to remove `flamenco-manager.yaml`, restart Flamenco Manager, and reconfigure via the setup assistant.
244 lines
8.0 KiB
Go
244 lines
8.0 KiB
Go
package worker
|
|
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/* This file contains the commands in the "ffmpeg" type group. */
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/shlex"
|
|
"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"
|
|
)
|
|
|
|
type CreateVideoParams struct {
|
|
exe string // Executable path defined by the Manager.
|
|
exeArgs string // Its CLI parameters defined by the Manager.
|
|
fps float64 // Frames per second of the video file.
|
|
inputGlob string // Glob of input files.
|
|
outputFile string // File to save the video to.
|
|
argsBefore []string // Additional CLI arguments from `exe`.
|
|
args []string // Additional CLI arguments defined by the job compiler script, to between the input and output filenames.
|
|
}
|
|
|
|
// cmdFramesToVideo uses ffmpeg to concatenate image frames to a video file.
|
|
func (ce *CommandExecutor) cmdFramesToVideo(ctx context.Context, logger zerolog.Logger, taskID string, cmd api.Command) error {
|
|
cmdCtx, cmdCtxCancel := context.WithCancel(ctx)
|
|
defer cmdCtxCancel()
|
|
|
|
execCmd, cleanup, err := ce.cmdFramesToVideoExeCommand(cmdCtx, logger, taskID, cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
logChunker := NewLogChunker(taskID, ce.listener, ce.timeService)
|
|
subprocessErr := ce.cli.RunWithTextOutput(ctx, logger, execCmd, logChunker, nil)
|
|
|
|
if subprocessErr != nil {
|
|
logger.Error().Err(subprocessErr).
|
|
Int("exitCode", execCmd.ProcessState.ExitCode()).
|
|
Msg("command exited abnormally")
|
|
return subprocessErr
|
|
}
|
|
|
|
logger.Info().Msg("command exited succesfully")
|
|
return nil
|
|
}
|
|
|
|
func (ce *CommandExecutor) cmdFramesToVideoExeCommand(
|
|
ctx context.Context,
|
|
logger zerolog.Logger,
|
|
taskID string,
|
|
cmd api.Command,
|
|
) (*exec.Cmd, func(), error) {
|
|
parameters, err := cmdFramesToVideoParams(logger, cmd)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
inputGlobArgs, cleanup, err := parameters.getInputGlob()
|
|
|
|
// runCleanup should be used if the cleanup function is *not* going to be
|
|
// returned (i.e. in case of error).
|
|
runCleanup := func() {
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
runCleanup()
|
|
return nil, nil, fmt.Errorf("creating input for FFmpeg: %w", err)
|
|
}
|
|
|
|
cliArgs := make([]string, 0)
|
|
cliArgs = append(cliArgs, parameters.argsBefore...)
|
|
cliArgs = append(cliArgs, inputGlobArgs...)
|
|
cliArgs = append(cliArgs, parameters.args...)
|
|
cliArgs = append(cliArgs, parameters.outputFile)
|
|
|
|
execCmd := ce.cli.CommandContext(ctx, parameters.exe, cliArgs...)
|
|
if execCmd == nil {
|
|
runCleanup()
|
|
logger.Error().Msg("unable to create command executor")
|
|
return nil, nil, ErrNoExecCmd
|
|
}
|
|
logger.Info().
|
|
Str("execCmd", execCmd.String()).
|
|
Msg("going to execute FFmpeg")
|
|
|
|
if err := ce.listener.LogProduced(ctx, taskID, fmt.Sprintf("going to run: %s %q", parameters.exe, cliArgs)); err != nil {
|
|
runCleanup()
|
|
return nil, nil, err
|
|
}
|
|
|
|
return execCmd, cleanup, nil
|
|
}
|
|
|
|
func cmdFramesToVideoParams(logger zerolog.Logger, cmd api.Command) (CreateVideoParams, error) {
|
|
var (
|
|
parameters CreateVideoParams
|
|
ok bool
|
|
)
|
|
|
|
if parameters.exe, ok = cmdParameter[string](cmd, "exe"); !ok || parameters.exe == "" {
|
|
logger.Warn().Interface("command", cmd).Msg("missing 'exe' parameter")
|
|
return parameters, NewParameterMissingError("exe", cmd)
|
|
}
|
|
if parameters.exeArgs, ok = cmdParameter[string](cmd, "exeArgs"); !ok {
|
|
logger.Warn().Interface("command", cmd).Msg("invalid 'exeArgs' parameter")
|
|
return parameters, NewParameterInvalidError("exeArgs", cmd, "parameter must be string")
|
|
}
|
|
if parameters.fps, ok = cmdParameter[float64](cmd, "fps"); !ok || parameters.fps == 0.0 {
|
|
logger.Warn().Interface("command", cmd).Msg("missing 'fps' parameter")
|
|
return parameters, NewParameterMissingError("fps", cmd)
|
|
}
|
|
if parameters.inputGlob, ok = cmdParameter[string](cmd, "inputGlob"); !ok || parameters.inputGlob == "" {
|
|
logger.Warn().Interface("command", cmd).Msg("missing 'inputGlob' parameter")
|
|
return parameters, NewParameterMissingError("inputGlob", cmd)
|
|
}
|
|
if parameters.outputFile, ok = cmdParameter[string](cmd, "outputFile"); !ok || parameters.outputFile == "" {
|
|
logger.Warn().Interface("command", cmd).Msg("missing 'outputFile' parameter")
|
|
return parameters, NewParameterMissingError("outputFile", cmd)
|
|
}
|
|
if parameters.argsBefore, ok = cmdParameterAsStrings(cmd, "argsBefore"); !ok {
|
|
logger.Warn().Interface("command", cmd).Msg("invalid 'argsBefore' parameter")
|
|
return parameters, NewParameterInvalidError("argsBefore", cmd, "cannot convert to list of strings")
|
|
}
|
|
if parameters.args, ok = cmdParameterAsStrings(cmd, "args"); !ok {
|
|
logger.Warn().Interface("command", cmd).Msg("invalid 'args' parameter")
|
|
return parameters, NewParameterInvalidError("args", cmd, "cannot convert to list of strings")
|
|
}
|
|
|
|
// Split the exeArgs string into separate parts, and prepend them to `argsBefore`.
|
|
exeArgs, err := shlex.Split(parameters.exeArgs)
|
|
if err != nil {
|
|
logger.Warn().Err(err).Interface("command", cmd).Msg("error parsing 'exeArgs' parameter with shlex")
|
|
return parameters, NewParameterInvalidError("exeArgs", cmd, err.Error())
|
|
}
|
|
if len(exeArgs) > 0 {
|
|
allArgsBefore := []string{}
|
|
allArgsBefore = append(allArgsBefore, exeArgs...)
|
|
allArgsBefore = append(allArgsBefore, parameters.argsBefore...)
|
|
parameters.argsBefore = allArgsBefore
|
|
}
|
|
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
|
|
}
|
|
|
|
// getInputGlob constructs CLI arguments for FFmpeg input file globbing.
|
|
// The 2nd return value is a cleanup function.
|
|
func (p *CreateVideoParams) getInputGlob() ([]string, func(), error) {
|
|
if runtime.GOOS == "windows" {
|
|
return createIndexFile(p.inputGlob, p.fps)
|
|
}
|
|
|
|
cliArgs := []string{
|
|
// FFmpeg needs the input frame rate as well, otherwise it'll default to 25
|
|
// FPS, and mysteriously drop frames when rendering a 24 FPS shot.
|
|
"-r", strconv.FormatFloat(p.fps, 'f', -1, 64),
|
|
"-pattern_type", "glob",
|
|
"-i", crosspath.ToSlash(p.inputGlob),
|
|
}
|
|
cleanup := func() {}
|
|
return cliArgs, cleanup, nil
|
|
}
|
|
|
|
// createIndexFile creates an FFmpeg index file, to make up for FFmpeg's lack of globbing support on Windows.
|
|
func createIndexFile(inputGlob string, frameRate float64) ([]string, func(), error) {
|
|
globDir := filepath.Dir(inputGlob)
|
|
|
|
files, err := filepath.Glob(inputGlob)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, nil, fmt.Errorf("no files found at %s", inputGlob)
|
|
}
|
|
|
|
indexFilename := filepath.Join(globDir, "ffmpeg-file-index.txt")
|
|
indexFile, err := os.Create(indexFilename)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer indexFile.Close()
|
|
|
|
frameDuration := 1.0 / frameRate
|
|
for _, fname := range files {
|
|
escaped := strings.ReplaceAll(fname, "'", "\\'")
|
|
fmt.Fprintf(indexFile, "file '%s'\n", escaped)
|
|
fmt.Fprintf(indexFile, "duration %f\n", frameDuration)
|
|
}
|
|
|
|
cliArgs := []string{
|
|
"-f", "concat",
|
|
"-safe", "0", // To allow absolute paths in the index file.
|
|
"-i", indexFilename,
|
|
}
|
|
|
|
cleanup := func() {
|
|
err := os.Remove(indexFilename)
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("filename", indexFilename).
|
|
Msg("error removing temporary FFmpeg index file")
|
|
}
|
|
}
|
|
|
|
return cliArgs, cleanup, nil
|
|
}
|