diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ca133971fd19a598c1cf5d82f70e981b40ef2d9..653515a463f1ace4c4a2df3ebfd082554e2ea373 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 4538ada7ad9977cda05c184e26b4878d759caf46..d117a63d8ba6219bfa20208ea5fc1a83cd8817f7 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 0000000000000000000000000000000000000000..dfa3fe27a3285581810492cde6aeb76eca7afb25
--- /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 0000000000000000000000000000000000000000..52383c92c6777739dd4628eae4e7d6e659586d3c
--- /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 6c554174b53e828d85fa517ebe3e90cdeef50936..02f016936702a50eb5f9215e88670957691e2223 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 0000000000000000000000000000000000000000..9f39d1245426446533c5a11c39b526dc016b8656
--- /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 0000000000000000000000000000000000000000..978578826c19c03caaf1702109c4cbf5f5c69c35
--- /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 0000000000000000000000000000000000000000..c5eae85ef1a0b2701bfe9765121fa409d068e366
--- /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 0000000000000000000000000000000000000000..fc4bc83e6854852bc24d6ad6e43e697e5ef3ad98
--- /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
Binary files /dev/null and b/tests/test_frames/chunk-0001-0004.mkv differ
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
Binary files /dev/null and b/tests/test_frames/chunk-0005-0007.mkv differ