Worker: implement ffmpeg for frame-to-video conversion on Windows
This commit is contained in:
parent
38aedfe8cc
commit
d98dbaa333
@ -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: [
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user