diff --git a/internal/manager/job_compilers/scripts/simple_blender_render.js b/internal/manager/job_compilers/scripts/simple_blender_render.js index b946dea4..45e53edd 100644 --- a/internal/manager/job_compilers/scripts/simple_blender_render.js +++ b/internal/manager/job_compilers/scripts/simple_blender_render.js @@ -154,6 +154,7 @@ function authorCreateVideoTask(settings, renderDir) { const task = author.Task('preview-video', 'ffmpeg'); const command = author.Command("frames-to-video", { exe: "{ffmpeg}", + fps: settings.fps, inputGlob: path.join(renderDir, `*${outfileExt}`), outputFile: outfile, args: [ diff --git a/internal/worker/command_ffmpeg.go b/internal/worker/command_ffmpeg.go index f0465660..196df5a1 100644 --- a/internal/worker/command_ffmpeg.go +++ b/internal/worker/command_ffmpeg.go @@ -9,11 +9,15 @@ import ( "context" "fmt" "io" + "os" "os/exec" + "path/filepath" "runtime" + "strings" "github.com/google/shlex" "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "git.blender.org/flamenco/pkg/api" "git.blender.org/flamenco/pkg/crosspath" @@ -21,6 +25,7 @@ import ( type CreateVideoParams struct { exe string // Expansion of `{ffmpeg}`: executable path + 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`. @@ -109,7 +114,10 @@ func (ce *CommandExecutor) cmdFramesToVideoExeCommand( return nil, nil, err } - inputGlobArgs, cleanup := parameters.getInputGlob() + inputGlobArgs, cleanup, err := parameters.getInputGlob() + if err != nil { + return nil, nil, fmt.Errorf("creating input for FFmpeg: %w", err) + } cliArgs := make([]string, 0) cliArgs = append(cliArgs, parameters.argsBefore...) @@ -119,13 +127,10 @@ func (ce *CommandExecutor) cmdFramesToVideoExeCommand( execCmd := ce.cli.CommandContext(ctx, parameters.exe, cliArgs...) if execCmd == nil { - logger.Error(). - Str("cmdName", cmd.Name). - Msg("unable to create command executor") + logger.Error().Msg("unable to create command executor") return nil, nil, ErrNoExecCmd } logger.Info(). - Str("cmdName", cmd.Name). Str("execCmd", execCmd.String()). Msg("going to execute FFmpeg") @@ -146,6 +151,10 @@ func cmdFramesToVideoParams(logger zerolog.Logger, cmd api.Command) (CreateVideo logger.Warn().Interface("command", cmd).Msg("missing 'exe' parameter") return parameters, fmt.Errorf("missing 'exe' parameter: %+v", cmd.Parameters) } + if parameters.fps, ok = cmdParameter[float64](cmd, "fps"); !ok || parameters.fps == 0.0 { + logger.Warn().Interface("command", cmd).Msg("missing 'fps' parameter") + return parameters, fmt.Errorf("missing 'fps' parameter: %+v", cmd.Parameters) + } if parameters.inputGlob, ok = cmdParameter[string](cmd, "inputGlob"); !ok || parameters.inputGlob == "" { logger.Warn().Interface("command", cmd).Msg("missing 'inputGlob' parameter") return parameters, fmt.Errorf("missing 'inputGlob' parameter: %+v", cmd.Parameters) @@ -182,28 +191,60 @@ func cmdFramesToVideoParams(logger zerolog.Logger, cmd api.Command) (CreateVideo // getInputGlob constructs CLI arguments for FFmpeg input file globbing. // The 2nd return value is a cleanup function. -func (p *CreateVideoParams) getInputGlob() ([]string, func()) { +func (p *CreateVideoParams) getInputGlob() ([]string, func(), error) { if runtime.GOOS == "windows" { - // FFMpeg on Windows doesn't support globbing, so we have to do that in Go - // instead. - // TODO: implement this! - // index_file = self.create_index_file(input_files) - // args += [ - // "-f", - // "concat", - // "-i", - // index_file.as_posix(), - // ] - // TODO: the returned cleanup function should delete the index file. - panic("not implemented yet") + return createIndexFile(p.inputGlob, p.fps) } cliArgs := []string{ - "-pattern_type", - "glob", - "-i", - crosspath.ToSlash(p.inputGlob), + "-pattern_type", "glob", + "-i", crosspath.ToSlash(p.inputGlob), } cleanup := func() {} - return cliArgs, cleanup + 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 && !os.IsNotExist(err) { + log.Warn(). + Err(err). + Str("filename", indexFilename). + Msg("error removing temporary FFmpeg index file") + } + } + + return cliArgs, cleanup, nil } diff --git a/internal/worker/command_ffmpeg_test.go b/internal/worker/command_ffmpeg_test.go index 71f03ba2..a73ce6fc 100644 --- a/internal/worker/command_ffmpeg_test.go +++ b/internal/worker/command_ffmpeg_test.go @@ -2,6 +2,7 @@ package worker import ( "context" + "runtime" "testing" "github.com/golang/mock/gomock" @@ -13,7 +14,11 @@ import ( // SPDX-License-Identifier: GPL-3.0-or-later -func TestCmdFramesToVideoSimple(t *testing.T) { +func TestCmdFramesToVideoSimplePosix(t *testing.T) { + // Windows and non-Windows platforms differ in how they communicate globs to FFmpeg. + if runtime.GOOS == "windows" { + t.Skipf("skipping POSIX test on %s", runtime.GOOS) + } mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() @@ -47,3 +52,49 @@ func TestCmdFramesToVideoSimple(t *testing.T) { err := ce.cmdFramesToVideo(context.Background(), zerolog.Nop(), taskID, cmd) assert.Equal(t, ErrNoExecCmd, err, "nil *exec.Cmd should result in ErrNoExecCmd") } + +func TestCmdFramesToVideoSimpleWindows(t *testing.T) { + // Windows and non-Windows platforms differ in how they communicate globs to FFmpeg. + if runtime.GOOS != "windows" { + t.Skipf("skipping Windows test on %s", runtime.GOOS) + } + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ce, mocks := testCommandExecutor(t, mockCtrl) + + taskID := "1d54c6fe-1242-4c8f-bd63-5a09e358d7b6" + cmd := api.Command{ + Name: "blender", + Parameters: map[string]interface{}{ + "exe": "/path/to/ffmpeg -v quiet", + "argsBefore": []string{"-report"}, + "inputGlob": "path/to/renders/*.png", + "args": []string{ + "-c:v", "hevc", + "-crf", "31", + "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", + }, + "outputFile": "path/to/renders/preview.mkv", + }, + } + + cliArgs := []string{ + "-v", "quiet", // exe + "-report", // argsBefore + "-f", "concat", "-i", "this-is-random.txt", // input glob + "-c:v", "hevc", "-crf", "31", "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // args + "path/to/renders/preview.mkv", // outputFile + } + mocks.cli.EXPECT(). + CommandContext(gomock.Any(), "/path/to/ffmpeg", gomock.Any()). + DoAndReturn(func(ctx context.Context, name string, arg ...string) error { + // Test all but the random filename. + assert.EqualValues(t, cliArgs[:6], arg[:6]) + assert.EqualValues(t, cliArgs[7:], arg[7:]) + return nil + }) + + err := ce.cmdFramesToVideo(context.Background(), zerolog.Nop(), taskID, cmd) + assert.Equal(t, ErrNoExecCmd, err, "nil *exec.Cmd should result in ErrNoExecCmd") +}