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