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