From 6477e908d1feafc206f596b96de5c57896949894 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= <sybren@stuvel.eu>
Date: Thu, 6 Dec 2018 15:48:05 +0100
Subject: [PATCH] Added support for commands used in the blender-video-chunks
 job type

Adds the following commands:

    - blender_render_audio
    - concat_videos
    - create_video
    - move_with_counter
    - mux_audio
---
 CHANGELOG.md                                |   7 +
 flamenco_worker/commands.py                 | 381 ++++++++++++++++++--
 tests/test_commands_blender_render_audio.py |  62 ++++
 tests/test_commands_concat_videos.py        |  69 ++++
 tests/test_commands_create_video.py         |   4 +-
 tests/test_commands_encode_audio.py         |  48 +++
 tests/test_commands_move_with_counter.py    |  61 ++++
 tests/test_commands_mux_audio.py            |  47 +++
 tests/test_commands_remove_file.py          |  60 +++
 tests/test_frames/chunk-0001-0004.mkv       | Bin 0 -> 4597 bytes
 tests/test_frames/chunk-0005-0007.mkv       | Bin 0 -> 4029 bytes
 11 files changed, 709 insertions(+), 30 deletions(-)
 create mode 100644 tests/test_commands_blender_render_audio.py
 create mode 100644 tests/test_commands_concat_videos.py
 create mode 100644 tests/test_commands_encode_audio.py
 create mode 100644 tests/test_commands_move_with_counter.py
 create mode 100644 tests/test_commands_mux_audio.py
 create mode 100644 tests/test_commands_remove_file.py
 create mode 100644 tests/test_frames/chunk-0001-0004.mkv
 create mode 100644 tests/test_frames/chunk-0005-0007.mkv

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ca13397..653515a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ changed functionality, fixed bugs).
 ## Version 2.2 (in development)
 
 - Always log the version of Flamenco Worker.
+- Requires Flamenco Manager 2.2 or newer.
 - Include missing merge-exr.blend, required for progressive rendering, in the distribution bundle.
 - Include `exr-merge` task type in default configuration, which is required for progressive
   rendering.
@@ -31,6 +32,12 @@ changed functionality, fixed bugs).
   sequence. It's up to Flamenco Server to include (or not) this command in a render job.
 - Explicitly return tasks to the Manager queue when stopping them (that is, when going asleep or
   shutting down). Requires Flamenco Manager 2.2 or newer.
+- Added support for commands used in the blender-video-chunks job type:
+    - blender_render_audio
+    - concat_videos
+    - create_video
+    - move_with_counter
+    - mux_audio
 
 
 ## Version 2.1.0 (2018-01-04)
diff --git a/flamenco_worker/commands.py b/flamenco_worker/commands.py
index 4538ada7..d117a63d 100644
--- a/flamenco_worker/commands.py
+++ b/flamenco_worker/commands.py
@@ -206,6 +206,18 @@ class AbstractCommand(metaclass=abc.ABCMeta):
 
         return value, None
 
+    async def _mkdir_if_not_exists(self, dirpath: Path):
+        """Create a directory if it doesn't exist yet.
+
+        Also logs a message to the Worker to indicate the directory was created.
+        """
+        if dirpath.exists():
+            return
+
+        await self.worker.register_log('%s: Directory %s does not exist; creating.',
+                                       self.command_name, dirpath)
+        dirpath.mkdir(parents=True)
+
 
 @command_executor('echo')
 class EchoCommand(AbstractCommand):
@@ -294,6 +306,29 @@ def _unique_path(path: Path) -> Path:
     return path.with_name(path.name + '~%i' % (max_nr + 1))
 
 
+def _numbered_path(directory: Path, fname_prefix: str, fname_suffix: str) -> Path:
+    """Return a unique Path with a number between prefix and suffix.
+
+    :return: directory / '{fname_prefix}001{fname_suffix}' where 001 is
+        replaced by the highest number + 1 if there already is a file with
+        such a prefix & suffix.
+    """
+
+    # See which suffixes are in use
+    max_nr = 0
+    len_prefix = len(fname_prefix)
+    len_suffix = len(fname_suffix)
+    for altpath in directory.glob(f'{fname_prefix}*{fname_suffix}'):
+        num_str: str = altpath.name[len_prefix:-len_suffix]
+
+        try:
+            num = int(num_str)
+        except ValueError:
+            continue
+        max_nr = max(max_nr, num)
+    return directory / f'{fname_prefix}{max_nr+1:03}{fname_suffix}'
+
+
 @command_executor('move_out_of_way')
 class MoveOutOfWayCommand(AbstractCommand):
     def validate(self, settings: Settings):
@@ -386,10 +421,7 @@ class CopyFileCommand(AbstractCommand):
         self._log.info('Copying %s to %s', src, dest)
         await self.worker.register_log('%s: Copying %s to %s', self.command_name, src, dest)
 
-        if not dest.parent.exists():
-            await self.worker.register_log('%s: Target directory %s does not exist; creating.',
-                                           self.command_name, dest.parent)
-            dest.parent.mkdir(parents=True)
+        await self._mkdir_if_not_exists(dest.parent)
 
         shutil.copy(str(src), str(dest))
         self.worker.output_produced(dest)
@@ -422,6 +454,34 @@ class RemoveTreeCommand(AbstractCommand):
             path.unlink()
 
 
+@command_executor('remove_file')
+class RemoveFileCommand(AbstractCommand):
+    def validate(self, settings: Settings):
+        path, err = self._setting(settings, 'path', True)
+        if err:
+            return err
+        if not path:
+            return "Parameter 'path' cannot be empty."
+
+    async def execute(self, settings: Settings):
+        path = Path(settings['path'])
+        if not path.exists():
+            msg = 'Path %s does not exist, so not removing.' % path
+            self._log.debug(msg)
+            await self.worker.register_log(msg)
+            return
+
+        if path.is_dir():
+            raise CommandExecutionError(f'Path {path} is a directory. Cannot remove with '
+                                        'this command; use remove_tree instead.')
+
+        msg = 'Removing file %s' % path
+        self._log.info(msg)
+        await self.worker.register_log(msg)
+
+        path.unlink()
+
+
 @attr.s
 class AbstractSubprocessCommand(AbstractCommand, abc.ABC):
     readline_timeout = attr.ib(default=SUBPROC_READLINE_TIMEOUT)
@@ -856,7 +916,7 @@ class MergeProgressiveRendersCommand(AbstractSubprocessCommand):
 
         # set up node properties and render settings.
         output = Path(settings['output'])
-        output.parent.mkdir(parents=True, exist_ok=True)
+        await self._mkdir_if_not_exists(output.parent)
 
         with tempfile.TemporaryDirectory(dir=str(output.parent)) as tmpdir:
             tmppath = Path(tmpdir)
@@ -890,19 +950,57 @@ class MergeProgressiveRendersCommand(AbstractSubprocessCommand):
         shutil.move(str(src), str(dst))
 
 
-@command_executor('create_video')
-class CreateVideoCommand(AbstractSubprocessCommand):
-    """Create a video from individual frames.
+@command_executor('blender_render_audio')
+class BlenderRenderAudioCommand(BlenderRenderCommand):
+    def validate(self, settings: Settings):
+        err = super().validate(settings)
+        if err:
+            return err
 
-    Requires FFmpeg to be installed and available with the 'ffmpeg' command.
-    """
+        render_output, err = self._setting(settings, 'render_output', True)
+        if err:
+            return err
+        if not render_output:
+            return "'render_output' is a required setting"
 
-    codec_video = 'h264'
+        _, err = self._setting(settings, 'frame_start', False, int)
+        if err:
+            return err
+        _, err = self._setting(settings, 'frame_end', False, int)
+        if err:
+            return err
+
+    def _build_blender_cmd(self, settings: Settings) -> typing.List[str]:
+        frame_start = settings.get('frame_start')
+        frame_end = settings.get('frame_end')
+        render_output = settings.get('render_output')
+
+        py_lines = [
+            "import bpy"
+        ]
+        if frame_start is not None:
+            py_lines.append(f'bpy.context.scene.frame_start = {frame_start}')
+        if frame_end is not None:
+            py_lines.append(f'bpy.context.scene.frame_end = {frame_end}')
+
+        py_lines.append(f"bpy.ops.sound.mixdown(filepath={render_output!r}, "
+                        f"codec='FLAC', container='FLAC', "
+                        f"accuracy=128)")
+        py_lines.append('bpy.ops.wm.quit_blender()')
+        py_script = '\n'.join(py_lines)
+
+        return [
+            *settings['blender_cmd'],
+            '--enable-autoexec',
+            '-noaudio',
+            '--background',
+            settings['filepath'],
+            '--python-exit-code', '47',
+            '--python-expr', py_script
+        ]
 
-    # Select some settings that are useful for scrubbing through the video.
-    constant_rate_factor = 17  # perceptually lossless
-    keyframe_interval = 1  # GOP size
-    max_b_frames: typing.Optional[int] = 0
+
+class AbstractFFmpegCommand(AbstractSubprocessCommand, abc.ABC):
 
     def validate(self, settings: Settings) -> typing.Optional[str]:
         # Check that FFmpeg can be found and shlex-split the string.
@@ -916,6 +1014,49 @@ class CreateVideoCommand(AbstractSubprocessCommand):
             return f'FFmpeg command {ffmpeg_cmd!r} not found on $PATH'
         settings['ffmpeg_cmd'] = cmd
         self._log.debug('Found FFmpeg command at %r', executable_path)
+        return None
+
+    async def execute(self, settings: Settings) -> None:
+        cmd = self._build_ffmpeg_command(settings)
+        await self.subprocess(cmd)
+
+    def _build_ffmpeg_command(self, settings: Settings) -> typing.List[str]:
+        assert isinstance(settings['ffmpeg_cmd'], list), \
+            'run validate() before _build_ffmpeg_command'
+        cmd = [
+            *settings['ffmpeg_cmd'],
+            *self.ffmpeg_args(settings),
+        ]
+        return cmd
+
+    @abc.abstractmethod
+    def ffmpeg_args(self, settings: Settings) -> typing.List[str]:
+        """Construct the FFmpeg arguments to execute.
+
+        Does not need to include the FFmpeg command itself, just
+        its arguments.
+        """
+        pass
+
+
+@command_executor('create_video')
+class CreateVideoCommand(AbstractFFmpegCommand):
+    """Create a video from individual frames.
+
+    Requires FFmpeg to be installed and available with the 'ffmpeg' command.
+    """
+
+    codec_video = 'h264'
+
+    # Select some settings that are useful for scrubbing through the video.
+    constant_rate_factor = 17  # perceptually lossless
+    keyframe_interval = 1  # GOP size
+    max_b_frames: typing.Optional[int] = 0
+
+    def validate(self, settings: Settings) -> typing.Optional[str]:
+        err = super().validate(settings)
+        if err:
+            return err
 
         # Check that we know our input and output image files.
         input_files, err = self._setting(settings, 'input_files', is_required=True)
@@ -933,26 +1074,210 @@ class CreateVideoCommand(AbstractSubprocessCommand):
         self._log.debug('Frame rate: %r fps', fps)
         return None
 
-    async def execute(self, settings: Settings) -> None:
-        cmd = self._build_ffmpeg_command(settings)
-        await self.subprocess(cmd)
-
-    def _build_ffmpeg_command(self, settings) -> typing.List[str]:
-        assert isinstance(settings['ffmpeg_cmd'], list), \
-            'run validate() before _build_ffmpeg_command'
-        cmd = [
-            *settings['ffmpeg_cmd'],
+    def ffmpeg_args(self, settings: Settings) -> typing.List[str]:
+        args = [
             '-pattern_type', 'glob',
+            '-r', str(settings['fps']),
             '-i', settings['input_files'],
             '-c:v', self.codec_video,
             '-crf', str(self.constant_rate_factor),
             '-g', str(self.keyframe_interval),
-            '-r', str(settings['fps']),
             '-y',
         ]
         if self.max_b_frames is not None:
-            cmd.extend(['-bf', str(self.max_b_frames)])
-        cmd += [
+            args.extend(['-bf', str(self.max_b_frames)])
+        args += [
             settings['output_file']
         ]
-        return cmd
+        return args
+
+
+@command_executor('concatenate_videos')
+class ConcatenateVideosCommand(AbstractFFmpegCommand):
+    """Create a video by concatenating other videos.
+
+    Requires FFmpeg to be installed and available with the 'ffmpeg' command.
+    """
+
+    index_file: typing.Optional[Path] = None
+
+    def validate(self, settings: Settings) -> typing.Optional[str]:
+        err = super().validate(settings)
+        if err:
+            return err
+
+        # Check that we know our input and output image files.
+        input_files, err = self._setting(settings, 'input_files', is_required=True)
+        if err:
+            return err
+        self._log.debug('Input files: %s', input_files)
+        output_file, err = self._setting(settings, 'output_file', is_required=True)
+        if err:
+            return err
+        self._log.debug('Output file: %s', output_file)
+
+        return None
+
+    async def execute(self, settings: Settings) -> None:
+        await super().execute(settings)
+
+        if self.index_file is not None and self.index_file.exists():
+            try:
+                self.index_file.unlink()
+            except IOError:
+                msg = f'unable to unlink file {self.index_file}, ignoring'
+                self.worker.register_log(msg)
+                self._log.warning(msg)
+
+    def ffmpeg_args(self, settings: Settings) -> typing.List[str]:
+        input_files = Path(settings['input_files']).absolute()
+        self.index_file = input_files.with_name('ffmpeg-input.txt')
+
+        # Construct the list of filenames for ffmpeg to process.
+        # The index file needs to sit next to the input files, as
+        # ffmpeg checks for 'unsafe paths'.
+        with self.index_file.open('w') as outfile:
+            for video_path in sorted(input_files.parent.glob(input_files.name)):
+                escaped = str(video_path.name).replace("'", "\\'")
+                print("file '%s'" % escaped, file=outfile)
+
+        output_file = Path(settings['output_file'])
+        self._log.debug('Output file: %s', output_file)
+
+        args = [
+            '-f', 'concat',
+            '-i', str(self.index_file),
+            '-c', 'copy',
+            '-y',
+            str(output_file),
+        ]
+        return args
+
+
+@command_executor('mux_audio')
+class MuxAudioCommand(AbstractFFmpegCommand):
+
+    def validate(self, settings: Settings) -> typing.Optional[str]:
+        err = super().validate(settings)
+        if err:
+            return err
+
+        # Check that we know our input and output image files.
+        audio_file, err = self._setting(settings, 'audio_file', is_required=True)
+        if err:
+            return err
+        if not Path(audio_file).exists():
+            return f'Audio file {audio_file} does not exist'
+        self._log.debug('Audio file: %s', audio_file)
+
+        video_file, err = self._setting(settings, 'video_file', is_required=True)
+        if err:
+            return err
+        if not Path(video_file).exists():
+            return f'Video file {video_file} does not exist'
+        self._log.debug('Video file: %s', video_file)
+
+        output_file, err = self._setting(settings, 'output_file', is_required=True)
+        if err:
+            return err
+        self._log.debug('Output file: %s', output_file)
+
+        return None
+
+    def ffmpeg_args(self, settings: Settings) -> typing.List[str]:
+        audio_file = Path(settings['audio_file']).absolute()
+        video_file = Path(settings['video_file']).absolute()
+        output_file = Path(settings['output_file']).absolute()
+
+        args = [
+            '-i', str(audio_file),
+            '-i', str(video_file),
+            '-c', 'copy',
+            '-y',
+            str(output_file),
+        ]
+        return args
+
+
+@command_executor('encode_audio')
+class EncodeAudioCommand(AbstractFFmpegCommand):
+
+    def validate(self, settings: Settings) -> typing.Optional[str]:
+        err = super().validate(settings)
+        if err:
+            return err
+
+        # Check that we know our input and output image files.
+        input_file, err = self._setting(settings, 'input_file', is_required=True)
+        if err:
+            return err
+        if not Path(input_file).exists():
+            return f'Audio file {input_file} does not exist'
+        self._log.debug('Audio file: %s', input_file)
+
+        output_file, err = self._setting(settings, 'output_file', is_required=True)
+        if err:
+            return err
+        self._log.debug('Output file: %s', output_file)
+
+        _, err = self._setting(settings, 'bitrate', is_required=True)
+        if err:
+            return err
+        _, err = self._setting(settings, 'codec', is_required=True)
+        if err:
+            return err
+        return None
+
+    def ffmpeg_args(self, settings: Settings) -> typing.List[str]:
+        input_file = Path(settings['input_file']).absolute()
+        output_file = Path(settings['output_file']).absolute()
+
+        args = [
+            '-i', str(input_file),
+            '-c:a', settings['codec'],
+            '-b:a', settings['bitrate'],
+            '-y',
+            str(output_file),
+        ]
+        return args
+
+
+@command_executor('move_with_counter')
+class MoveWithCounterCommand(AbstractCommand):
+    # Split '2018_12_06-spring.mkv' into a '2018_12_06' prefix and '-spring.mkv' suffix.
+    filename_parts = re.compile(r'(?P<prefix>^[0-9_]+)(?P<suffix>.*)$')
+
+    def validate(self, settings: Settings):
+        src, err = self._setting(settings, 'src', True)
+        if err:
+            return err
+        if not src:
+            return 'src may not be empty'
+        dest, err = self._setting(settings, 'dest', True)
+        if err:
+            return err
+        if not dest:
+            return 'dest may not be empty'
+
+    async def execute(self, settings: Settings):
+        src = Path(settings['src'])
+        if not src.exists():
+            raise CommandExecutionError('Path %s does not exist, unable to move' % src)
+
+        dest = Path(settings['dest'])
+        fname_parts = self.filename_parts.match(dest.name)
+        if fname_parts:
+            prefix = fname_parts.group('prefix') + '_'
+            suffix = fname_parts.group('suffix')
+        else:
+            prefix = dest.stem + '_'
+            suffix = dest.suffix
+        self._log.debug('Adding counter to output name between %r and %r', prefix, suffix)
+        dest = _numbered_path(dest.parent, prefix, suffix)
+
+        self._log.info('Moving %s to %s', src, dest)
+        await self.worker.register_log('%s: Moving %s to %s', self.command_name, src, dest)
+        await self._mkdir_if_not_exists(dest.parent)
+
+        shutil.move(str(src), str(dest))
+        self.worker.output_produced(dest)
diff --git a/tests/test_commands_blender_render_audio.py b/tests/test_commands_blender_render_audio.py
new file mode 100644
index 00000000..dfa3fe27
--- /dev/null
+++ b/tests/test_commands_blender_render_audio.py
@@ -0,0 +1,62 @@
+from pathlib import Path
+import subprocess
+from unittest import mock
+
+from unittest.mock import patch
+
+from tests.test_runner import AbstractCommandTest
+
+expected_script = """
+import bpy
+bpy.context.scene.frame_start = 1
+bpy.context.scene.frame_end = 47
+bpy.ops.sound.mixdown(filepath='/tmp/output.flac', codec='FLAC', container='FLAC', accuracy=128)
+bpy.ops.wm.quit_blender()
+""".strip('\n')
+
+
+class RenderAudioTest(AbstractCommandTest):
+    def setUp(self):
+        super().setUp()
+
+        from flamenco_worker.commands import BlenderRenderAudioCommand
+
+        self.cmd = BlenderRenderAudioCommand(
+            worker=self.fworker,
+            task_id='12345',
+            command_idx=0,
+        )
+
+    def test_cli_args(self):
+        from tests.mock_responses import CoroMock
+
+        filepath = Path(__file__)
+        settings = {
+            # Point blender_cmd to this file so that we're sure it exists.
+            'blender_cmd': '%s --with --cli="args for CLI"' % __file__,
+            'frame_start': 1,
+            'frame_end': 47,
+            'filepath': str(filepath),
+            'render_output': '/tmp/output.flac',
+        }
+
+        cse = CoroMock(...)
+        cse.coro.return_value.wait = CoroMock(return_value=0)
+        cse.coro.return_value.pid = 47
+        with patch('asyncio.create_subprocess_exec', new=cse) as mock_cse:
+            self.loop.run_until_complete(self.cmd.run(settings))
+
+            mock_cse.assert_called_once_with(
+                __file__,
+                '--with',
+                '--cli=args for CLI',
+                '--enable-autoexec',
+                '-noaudio',
+                '--background',
+                str(filepath),
+                '--python-exit-code', '47',
+                '--python-expr', expected_script,
+                stdin=subprocess.DEVNULL,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+            )
diff --git a/tests/test_commands_concat_videos.py b/tests/test_commands_concat_videos.py
new file mode 100644
index 00000000..52383c92
--- /dev/null
+++ b/tests/test_commands_concat_videos.py
@@ -0,0 +1,69 @@
+import logging
+import shutil
+import typing
+from pathlib import Path
+import shlex
+import subprocess
+import sys
+import tempfile
+
+from tests.test_runner import AbstractCommandTest
+
+log = logging.getLogger(__name__)
+frame_dir = Path(__file__).with_name('test_frames')
+
+
+class ConcatVideosTest(AbstractCommandTest):
+    settings: typing.Dict[str, typing.Any] = {
+        'ffmpeg_cmd': f'"{sys.executable}" -hide_banner',
+        'input_files': str(frame_dir / 'chunk-*.mkv'),
+        'output_file': '/tmp/merged.mkv',
+    }
+
+    def setUp(self):
+        super().setUp()
+
+        from flamenco_worker.commands import ConcatenateVideosCommand
+
+        self.cmd = ConcatenateVideosCommand(
+            worker=self.fworker,
+            task_id='12345',
+            command_idx=0,
+        )
+        self.settings = self.settings.copy()
+
+    def test_build_ffmpeg_cmd(self):
+        self.cmd.validate(self.settings)
+        cliargs = self.cmd._build_ffmpeg_command(self.settings)
+
+        self.assertEqual([
+            sys.executable, '-hide_banner',
+            '-f', 'concat',
+            '-i', str(frame_dir / 'ffmpeg-input.txt'),
+            '-c', 'copy',
+            '-y',
+            '/tmp/merged.mkv',
+        ], cliargs)
+
+    def test_run_ffmpeg(self):
+        with tempfile.TemporaryDirectory() as tempdir:
+            outfile = Path(tempdir) / 'merged.mkv'
+            settings: typing.Dict[str, typing.Any] = {
+                **self.settings,
+                'ffmpeg_cmd': 'ffmpeg',  # use the real FFmpeg for this test.
+                'output_file': str(outfile),
+            }
+
+            self.loop.run_until_complete(self.cmd.run(settings))
+            self.assertTrue(outfile.exists())
+
+            ffprobe_cmd = [shutil.which('ffprobe'), '-v', 'error',
+                           '-show_entries', 'format=duration',
+                           '-of', 'default=noprint_wrappers=1:nokey=1',
+                           str(outfile)]
+            log.debug('Running %s', ' '.join(shlex.quote(arg) for arg in ffprobe_cmd))
+            probe_out = subprocess.check_output(ffprobe_cmd)
+            probed_duration = float(probe_out)
+
+            # The combined videos are 7 frames @ 24 frames per second.
+            self.assertAlmostEqual(0.291, probed_duration, places=3)
diff --git a/tests/test_commands_create_video.py b/tests/test_commands_create_video.py
index 6c554174..02f01693 100644
--- a/tests/test_commands_create_video.py
+++ b/tests/test_commands_create_video.py
@@ -12,7 +12,7 @@ from tests.test_runner import AbstractCommandTest
 log = logging.getLogger(__name__)
 
 
-class BlenderRenderTest(AbstractCommandTest):
+class CreateVideoTest(AbstractCommandTest):
     settings: typing.Dict[str, typing.Any] = {
         'ffmpeg_cmd': f'"{sys.executable}" -hide_banner',
         'input_files': '/tmp/*.png',
@@ -51,11 +51,11 @@ class BlenderRenderTest(AbstractCommandTest):
         self.assertEqual([
             sys.executable, '-hide_banner',
             '-pattern_type', 'glob',
+            '-r', '24',
             '-i', '/tmp/*.png',
             '-c:v', 'h264',
             '-crf', '17',
             '-g', '1',
-            '-r', '24',
             '-y',
             '-bf', '0',
             '/tmp/merged.mkv',
diff --git a/tests/test_commands_encode_audio.py b/tests/test_commands_encode_audio.py
new file mode 100644
index 00000000..9f39d124
--- /dev/null
+++ b/tests/test_commands_encode_audio.py
@@ -0,0 +1,48 @@
+import logging
+import shutil
+import typing
+from pathlib import Path
+import shlex
+import subprocess
+import sys
+import tempfile
+
+from tests.test_runner import AbstractCommandTest
+
+log = logging.getLogger(__name__)
+frame_dir = Path(__file__).with_name('test_frames')
+
+
+class EncodeAudioTest(AbstractCommandTest):
+    settings: typing.Dict[str, typing.Any] = {
+        'ffmpeg_cmd': f'"{sys.executable}" -hide_banner',
+        'input_file': f'{frame_dir}/audio.flac',
+        'codec': 'aac',
+        'bitrate': '192k',
+        'output_file': f'{frame_dir}/audio.aac',
+    }
+
+    def setUp(self):
+        super().setUp()
+
+        from flamenco_worker.commands import EncodeAudioCommand
+
+        self.cmd = EncodeAudioCommand(
+            worker=self.fworker,
+            task_id='12345',
+            command_idx=0,
+        )
+        self.settings = self.__class__.settings.copy()
+
+    def test_build_ffmpeg_cmd(self):
+        self.cmd.validate(self.settings)
+        cliargs = self.cmd._build_ffmpeg_command(self.settings)
+
+        self.assertEqual([
+            sys.executable, '-hide_banner',
+            '-i', str(frame_dir / 'audio.flac'),
+            '-c:a', 'aac',
+            '-b:a', '192k',
+            '-y',
+            str(frame_dir / 'audio.aac'),
+        ], cliargs)
diff --git a/tests/test_commands_move_with_counter.py b/tests/test_commands_move_with_counter.py
new file mode 100644
index 00000000..97857882
--- /dev/null
+++ b/tests/test_commands_move_with_counter.py
@@ -0,0 +1,61 @@
+import logging
+import shutil
+import typing
+from pathlib import Path
+import shlex
+import subprocess
+import sys
+import tempfile
+
+from tests.test_runner import AbstractCommandTest
+
+log = logging.getLogger(__name__)
+frame_dir = Path(__file__).with_name('test_frames')
+
+
+class MoveWithCounterTest(AbstractCommandTest):
+    def setUp(self):
+        super().setUp()
+
+        from flamenco_worker.commands import MoveWithCounterCommand
+
+        self.cmd = MoveWithCounterCommand(
+            worker=self.fworker,
+            task_id='12345',
+            command_idx=0,
+        )
+
+        self._tempdir = tempfile.TemporaryDirectory()
+        self.temppath = Path(self._tempdir.name)
+
+        self.srcpath = (self.temppath / 'somefile.mkv')
+        self.srcpath.touch()
+
+        (self.temppath / '2018_06_12_001-spring.mkv').touch()
+        (self.temppath / '2018_06_12_004-spring.mkv').touch()
+
+    def tearDown(self):
+        self._tempdir.cleanup()
+        super().tearDown()
+
+    def test_numbers_with_holes(self):
+        settings = {
+            'src': str(self.srcpath),
+            'dest': str(self.temppath / '2018_06_12-spring.mkv'),
+        }
+        task = self.cmd.execute(settings)
+        self.loop.run_until_complete(task)
+
+        self.assertFalse(self.srcpath.exists())
+        self.assertTrue((self.temppath / '2018_06_12_005-spring.mkv').exists())
+
+    def test_no_regexp_match(self):
+        settings = {
+            'src': str(self.srcpath),
+            'dest': str(self.temppath / 'jemoeder.mkv'),
+        }
+        task = self.cmd.execute(settings)
+        self.loop.run_until_complete(task)
+
+        self.assertFalse(self.srcpath.exists())
+        self.assertTrue((self.temppath / 'jemoeder_001.mkv').exists())
diff --git a/tests/test_commands_mux_audio.py b/tests/test_commands_mux_audio.py
new file mode 100644
index 00000000..c5eae85e
--- /dev/null
+++ b/tests/test_commands_mux_audio.py
@@ -0,0 +1,47 @@
+import logging
+import shutil
+import typing
+from pathlib import Path
+import shlex
+import subprocess
+import sys
+import tempfile
+
+from tests.test_runner import AbstractCommandTest
+
+log = logging.getLogger(__name__)
+frame_dir = Path(__file__).with_name('test_frames')
+
+
+class MuxAudioTest(AbstractCommandTest):
+    settings: typing.Dict[str, typing.Any] = {
+        'ffmpeg_cmd': f'"{sys.executable}" -hide_banner',
+        'audio_file': str(frame_dir / 'audio.mkv'),
+        'video_file': str(frame_dir / 'video.mkv'),
+        'output_file': str(frame_dir / 'muxed.mkv'),
+    }
+
+    def setUp(self):
+        super().setUp()
+
+        from flamenco_worker.commands import MuxAudioCommand
+
+        self.cmd = MuxAudioCommand(
+            worker=self.fworker,
+            task_id='12345',
+            command_idx=0,
+        )
+        self.settings = self.settings.copy()
+
+    def test_build_ffmpeg_cmd(self):
+        self.cmd.validate(self.settings)
+        cliargs = self.cmd._build_ffmpeg_command(self.settings)
+
+        self.assertEqual([
+            sys.executable, '-hide_banner',
+            '-i', str(frame_dir / 'audio.mkv'),
+            '-i', str(frame_dir / 'video.mkv'),
+            '-c', 'copy',
+            '-y',
+            str(frame_dir / 'muxed.mkv'),
+        ], cliargs)
diff --git a/tests/test_commands_remove_file.py b/tests/test_commands_remove_file.py
new file mode 100644
index 00000000..fc4bc83e
--- /dev/null
+++ b/tests/test_commands_remove_file.py
@@ -0,0 +1,60 @@
+from pathlib import Path
+
+from tests.test_runner import AbstractCommandTest
+
+
+class RemoveFileTest(AbstractCommandTest):
+    def setUp(self):
+        super().setUp()
+
+        from flamenco_worker.commands import RemoveFileCommand
+        import tempfile
+
+        self.tmpdir = tempfile.TemporaryDirectory()
+        self.tmppath = Path(self.tmpdir.name)
+
+        self.cmd = RemoveFileCommand(
+            worker=self.fworker,
+            task_id='12345',
+            command_idx=0,
+        )
+
+    def tearDown(self):
+        super().tearDown()
+        self.tmpdir.cleanup()
+
+    def test_validate_settings(self):
+        self.assertIn('path', self.cmd.validate({'path': 12}))
+        self.assertIn('path', self.cmd.validate({'path': ''}))
+        self.assertIn('path', self.cmd.validate({}))
+        self.assertFalse(self.cmd.validate({'path': '/some/path'}))
+
+    def test_nonexistant_source(self):
+        path = self.tmppath / 'nonexisting'
+        task = self.cmd.run({'path': str(path)})
+        ok = self.loop.run_until_complete(task)
+
+        self.assertTrue(ok)
+        self.assertFalse(path.exists())
+
+    def test_source_file(self):
+        path = self.tmppath / 'existing'
+        path.touch()
+        task = self.cmd.run({'path': str(path)})
+        ok = self.loop.run_until_complete(task)
+
+        self.assertTrue(ok)
+        self.assertFalse(path.exists())
+
+    def test_soure_dir_with_files(self):
+        path = self.tmppath / 'dir'
+        path.mkdir()
+        (path / 'a.file').touch()
+        (path / 'b.file').touch()
+        (path / 'c.file').touch()
+
+        task = self.cmd.run({'path': str(path)})
+        ok = self.loop.run_until_complete(task)
+
+        self.assertFalse(ok)
+        self.assertTrue(path.exists())
diff --git a/tests/test_frames/chunk-0001-0004.mkv b/tests/test_frames/chunk-0001-0004.mkv
new file mode 100644
index 0000000000000000000000000000000000000000..ac4041c96e3efbed0013923658ca9ce40f27ab2e
GIT binary patch
literal 4597
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
RM1qk-IB$VOYLP^ElmNp}-FN^1

literal 0
HcmV?d00001

diff --git a/tests/test_frames/chunk-0005-0007.mkv b/tests/test_frames/chunk-0005-0007.mkv
new file mode 100644
index 0000000000000000000000000000000000000000..56fe49524f6497e5dae3fe0a5a9151e968ed2ac7
GIT binary patch
literal 4029
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

literal 0
HcmV?d00001

-- 
GitLab