diff --git a/CHANGELOG.md b/CHANGELOG.md index ac84d1fefda30c60351a7a760aee575f96f9cdbb..e3de65cf6916fcde7c36cb35695674d390d80f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ changed functionality, fixed bugs). - Log lines produced by subprocesses are now prefixed with 'PID=nnn'. - Moved from pip-installing requirements.txt to Pipenv. - Upgraded Python from 3.5 to 3.7 +- Added a new command `create_video` which uses FFmpeg to create a video after rendering an image + sequence. It's up to Flamenco Server to include (or not) this command in a render job. ## Version 2.1.0 (2018-01-04) diff --git a/README.md b/README.md index 9c6942b9a8e5b27414786dd7ca38fe0419780c2b..560d5a5548c1202c0fbdc4a80c8e6be81834214b 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,14 @@ Author: Sybren A. Stüvel <sybren@blender.studio> ## Installation -Before you begin, make sure you have Flamenco Manager up and running. - -There are two ways to install Flamenco Worker: - -- If you have a distributable zip file (see [Packaging for distribution](#packaging-for-distribution)) - unzip it, `cd` into it, then run `./flamenco-worker` (or `flamenco-worker.exe` on Windows). - -- If you have a copy of the source files, run `pipenv install` then run `flamenco-worker`. This - requires Python 3.7 or newer. +- Make sure you have Flamenco Manager up and running. +- Install [FFmpeg](https://ffmpeg.org/) and make sure the `ffmpeg` binary is on `$PATH`. +- Install Flamenco Worker in one of two ways: + - If you have a distributable zip file (see + [Packaging for distribution](#packaging-for-distribution)) unzip it, `cd` into it, + then run `./flamenco-worker` (or `flamenco-worker.exe` on Windows). + - If you have a copy of the source files, run `pipenv install` then run `flamenco-worker`. + This requires Python 3.7 or newer. ## Upgrading diff --git a/flamenco-worker.cfg b/flamenco-worker.cfg index 488286eec0031f3846249ab91c3720ba78556cbd..c95bbe379827895e4e42c2b8232b7bd6fd3d0bc0 100644 --- a/flamenco-worker.cfg +++ b/flamenco-worker.cfg @@ -3,6 +3,7 @@ # The URL of the Flamenco Manager. Leave empty for auto-discovery via UPnP/SSDP. manager_url = +# Add the 'video-encoding' task type if you have ffmpeg installed. task_types = sleep blender-render file-management exr-merge debug task_update_queue_db = flamenco-worker.db diff --git a/flamenco_worker/commands.py b/flamenco_worker/commands.py index b72d8ba8b5b854fdff55e6af7ba254821b20e48a..7bf3beffe07eec0d5e82b216b8879d7601245d31 100644 --- a/flamenco_worker/commands.py +++ b/flamenco_worker/commands.py @@ -11,6 +11,7 @@ import shlex import shutil import subprocess import tempfile + import time import typing from pathlib import Path @@ -183,7 +184,8 @@ class AbstractCommand(metaclass=abc.ABCMeta): return None def _setting(self, settings: Settings, key: str, is_required: bool, - valtype: InstanceOfType = str) \ + valtype: InstanceOfType = str, + default: typing.Any = None) \ -> typing.Tuple[typing.Any, typing.Optional[str]]: """Parses a setting, returns either (value, None) or (None, errormsg)""" @@ -192,10 +194,12 @@ class AbstractCommand(metaclass=abc.ABCMeta): except KeyError: if is_required: return None, 'Missing "%s"' % key - return None, None + settings.setdefault(key, default) + return default, None if value is None and not is_required: - return None, None + settings.setdefault(key, default) + return default, None if not isinstance(value, valtype): return None, '"%s" must be a %s, not a %s' % (key, valtype, type(value)) @@ -884,3 +888,65 @@ class MergeProgressiveRendersCommand(AbstractSubprocessCommand): await self.worker.register_log('Moving %s to %s', src, dst) shutil.move(str(src), str(dst)) + + +@command_executor('create_video') +class CreateVideoCommand(AbstractSubprocessCommand): + """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]: + # Check that FFmpeg can be found. + ffmpeg, err = self._setting(settings, 'ffmpeg', is_required=False, default='ffmpeg') + if err: + return err + executable_path: typing.Optional[str] = shutil.which(ffmpeg) + if not executable_path: + return f'FFmpeg command {ffmpeg!r} not found on $PATH' + self._log.debug('Found FFmpeg command at %r', executable_path) + + # 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) + + fps, err = self._setting(settings, 'fps', is_required=True, valtype=(int, float)) + if err: + return err + 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]: + cmd = [ + settings['ffmpeg'], + '-pattern_type', 'glob', + '-i', settings['input_files'], + '-c:v', self.codec_video, + '-crf', str(self.constant_rate_factor), + '-g', str(self.keyframe_interval), + '-r', str(settings['fps']), + ] + if self.max_b_frames is not None: + cmd.extend(['-bf', str(self.max_b_frames)]) + cmd += [ + settings['output_file'] + ] + return cmd diff --git a/flamenco_worker/config.py b/flamenco_worker/config.py index 6b75d9fad29b85e2643327f997a4aab43152531c..6c09d16b89068d4a8eb0e62f0cc67ee81e5ecb51 100644 --- a/flamenco_worker/config.py +++ b/flamenco_worker/config.py @@ -16,7 +16,8 @@ CONFIG_SECTION = 'flamenco-worker' DEFAULT_CONFIG = { 'flamenco-worker': collections.OrderedDict([ ('manager_url', ''), - ('task_types', 'unknown sleep blender-render'), + # The 'video-encoding' tasks require ffmpeg to be installed, so it's not enabled by default. + ('task_types', 'sleep blender-render file-management exr-merge'), ('task_update_queue_db', 'flamenco-worker.db'), ('subprocess_pid_file', 'flamenco-worker-subprocess.pid'), ('may_i_run_interval_seconds', '5'), diff --git a/tests/test_commands_create_video.py b/tests/test_commands_create_video.py new file mode 100644 index 0000000000000000000000000000000000000000..214f6253775ddcfa49c9366fe32d0d31bbe35635 --- /dev/null +++ b/tests/test_commands_create_video.py @@ -0,0 +1,81 @@ +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__) + + +class BlenderRenderTest(AbstractCommandTest): + settings: typing.Dict[str, typing.Any] = { + 'ffmpeg': sys.executable, + 'input_files': '/tmp/*.png', + 'output_file': '/tmp/merged.mkv', + 'fps': 24, + } + + def setUp(self): + super().setUp() + + from flamenco_worker.commands import CreateVideoCommand + + self.cmd = CreateVideoCommand( + worker=self.fworker, + task_id='12345', + command_idx=0, + ) + + def test_validate(self): + self.assertIn('not found on $PATH', self.cmd.validate({'ffmpeg': '/does/not/exist'})) + self.assertIsNone(self.cmd.validate(self.settings)) + + def test_validate_without_ffmpeg(self): + settings = self.settings.copy() + del settings['ffmpeg'] + + self.assertIsNone(self.cmd.validate(settings)) + self.assertEqual('ffmpeg', settings['ffmpeg'], + 'The default setting should be stored in the dict after validation') + + def test_build_ffmpeg_cmd(self): + self.assertEqual([ + sys.executable, + '-pattern_type', 'glob', + '-i', '/tmp/*.png', + '-c:v', 'h264', + '-crf', '17', + '-g', '1', + '-r', '24', + '-bf', '0', + '/tmp/merged.mkv', + ], self.cmd._build_ffmpeg_command(self.settings)) + + def test_run_ffmpeg(self): + with tempfile.TemporaryDirectory() as tempdir: + outfile = Path(tempdir) / 'merged.mkv' + frame_dir = Path(__file__).with_name('test_frames') + settings: typing.Dict[str, typing.Any] = { + **self.settings, + 'ffmpeg': 'ffmpeg', # use the real FFmpeg for this test. + 'input_files': f'{frame_dir}/*.png', + '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) + expect_duration = len(list(frame_dir.glob('*.png'))) / settings['fps'] + self.assertAlmostEqual(expect_duration, probed_duration, places=3) diff --git a/tests/test_frames/000108.png b/tests/test_frames/000108.png new file mode 100644 index 0000000000000000000000000000000000000000..72ca4998b4b899f2ca2f23702a255d987a05b6b2 Binary files /dev/null and b/tests/test_frames/000108.png differ diff --git a/tests/test_frames/000109.png b/tests/test_frames/000109.png new file mode 100644 index 0000000000000000000000000000000000000000..c7b1bb57c612ceb28ae1d39c8340592f5f430768 Binary files /dev/null and b/tests/test_frames/000109.png differ diff --git a/tests/test_frames/000110.png b/tests/test_frames/000110.png new file mode 100644 index 0000000000000000000000000000000000000000..fc109200bca92a1328aef9b9716bb6d5d750c053 Binary files /dev/null and b/tests/test_frames/000110.png differ diff --git a/tests/test_frames/000111.png b/tests/test_frames/000111.png new file mode 100644 index 0000000000000000000000000000000000000000..6ffea987b1b3670e8bb590445a3e074bdea6e116 Binary files /dev/null and b/tests/test_frames/000111.png differ diff --git a/tests/test_frames/000112.png b/tests/test_frames/000112.png new file mode 100644 index 0000000000000000000000000000000000000000..f5238025d1dd730ff450b2d74dd05d0d97166fce Binary files /dev/null and b/tests/test_frames/000112.png differ diff --git a/tests/test_frames/000113.png b/tests/test_frames/000113.png new file mode 100644 index 0000000000000000000000000000000000000000..7a7f346d1e337e05de02972dc166229bef063ebf Binary files /dev/null and b/tests/test_frames/000113.png differ diff --git a/tests/test_frames/000114.png b/tests/test_frames/000114.png new file mode 100644 index 0000000000000000000000000000000000000000..a56146da7ed061fec185d190a941e1e4646db5b6 Binary files /dev/null and b/tests/test_frames/000114.png differ