Worker: implement ffmpeg for frame-to-video conversion on Windows

This commit is contained in:
Sybren A. Stüvel 2022-04-09 16:20:29 +02:00
parent 38aedfe8cc
commit d98dbaa333
3 changed files with 117 additions and 24 deletions

View File

@ -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: [

View File

@ -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
}

View File

@ -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")
}