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