Merge branch 'maintenance' into devel

This commit is contained in:
Gina Häußge 2017-01-20 16:29:08 +01:00
commit d430fe35c5
11 changed files with 216 additions and 110 deletions

View file

@ -1,5 +1,13 @@
# OctoPrint Changelog
## 1.3.1rc2 (2017-01-20)
### Bug fixes
* [#1641](https://github.com/foosel/OctoPrint/issues/1641) - Fix issue with `octoprint --daemon` not working. (2nd try)
([Commits](https://github.com/foosel/OctoPrint/compare/1.3.1rc1...1.3.1rc2))
## 1.3.1rc1 (2017-01-13)
### Note for upgraders

View file

@ -55,6 +55,8 @@ def set_ctx_obj_option(ctx, param, value):
ctx.obj = OctoPrintContext()
if value != param.default:
setattr(ctx.obj, param.name, value)
elif param.default is not None:
setattr(ctx.obj, param.name, param.default)
#~~ helper for retrieving context options
@ -139,7 +141,7 @@ from .config import config_commands
@legacy_options
@click.version_option(version=octoprint.__version__, allow_from_autoenv=False)
@click.pass_context
def octo(ctx, debug, host, port, logging, daemon, pid, allow_root):
def octo(ctx, **kwargs):
if ctx.invoked_subcommand is None:
# We have to support calling the octoprint command without any
@ -148,16 +150,20 @@ def octo(ctx, debug, host, port, logging, daemon, pid, allow_root):
# But better print a message to inform people that they should
# use the sub commands instead.
def get_value(key):
return get_ctx_obj_option(ctx, key, kwargs.get(key))
daemon = get_value("daemon")
if daemon:
click.echo("Daemon operation via \"octoprint --daemon "
"start|stop|restart\" is deprecated, please use "
"\"octoprint daemon start|stop|restart\" from now on")
from octoprint.cli.server import daemon_command
ctx.invoke(daemon_command, debug=debug, host=host, port=port, logging=logging, allow_root=allow_root, command=daemon, pid=pid)
ctx.invoke(daemon_command, command=daemon, **kwargs)
else:
click.echo("Starting the server via \"octoprint\" is deprecated, "
"please use \"octoprint serve\" from now on.")
from octoprint.cli.server import serve_command
ctx.invoke(serve_command, debug=debug, host=host, port=port, logging=logging, allow_root=allow_root)
ctx.invoke(serve_command, **kwargs)

View file

@ -34,6 +34,10 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
ARCHIVE_EXTENSIONS = (".zip", ".tar.gz", ".tgz", ".tar")
OPERATING_SYSTEMS = dict(windows=["win32"],
linux=["linux2"],
macos=["darwin"])
pip_inapplicable_arguments = dict(uninstall=["--user"])
def __init__(self):
@ -609,10 +613,10 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
)
if "compatibility" in entry:
if "octoprint" in entry["compatibility"] and entry["compatibility"]["octoprint"] is not None and len(entry["compatibility"]["octoprint"]):
if "octoprint" in entry["compatibility"] and entry["compatibility"]["octoprint"] is not None and isinstance(entry["compatibility"]["octoprint"], (list, tuple)) and len(entry["compatibility"]["octoprint"]):
result["is_compatible"]["octoprint"] = self._is_octoprint_compatible(octoprint_version, entry["compatibility"]["octoprint"])
if "os" in entry["compatibility"] and entry["compatibility"]["os"] is not None and len(entry["compatibility"]["os"]):
if "os" in entry["compatibility"] and entry["compatibility"]["os"] is not None and isinstance(entry["compatibility"]["os"], (list, tuple)) and len(entry["compatibility"]["os"]):
result["is_compatible"]["os"] = self._is_os_compatible(current_os, entry["compatibility"]["os"])
return result
@ -626,12 +630,15 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
"""
for octo_compat in compatibility_entries:
if not any(octo_compat.startswith(c) for c in ("<", "<=", "!=", "==", ">=", ">", "~=", "===")):
octo_compat = ">={}".format(octo_compat)
try:
if not any(octo_compat.startswith(c) for c in ("<", "<=", "!=", "==", ">=", ">", "~=", "===")):
octo_compat = ">={}".format(octo_compat)
s = next(pkg_resources.parse_requirements("OctoPrint" + octo_compat))
if octoprint_version in s:
break
s = next(pkg_resources.parse_requirements("OctoPrint" + octo_compat))
if octoprint_version in s:
break
except:
self._logger.exception("Something is wrong with this compatibility string for OctoPrint: {}".format(octo_compat))
else:
return False
@ -641,15 +648,12 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
"""
Tests if the ``current_os`` matches any of the provided ``compatibility_entries``.
"""
return current_os in compatibility_entries
return current_os in filter(lambda x: x in self.__class__.OPERATING_SYSTEMS.keys(), compatibility_entries)
def _get_os(self):
if sys.platform == "win32":
return "windows"
elif sys.platform == "linux2":
return "linux"
elif sys.platform == "darwin":
return "macos"
for identifier, platforms in self.__class__.OPERATING_SYSTEMS.items():
if sys.platform in platforms:
return identifier
else:
return "unknown"

View file

@ -83,11 +83,29 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin,
except:
self._logger.exception("Error while retrieving update information from plugin {name}".format(**locals()))
else:
for key, data in hook_checks.items():
for key, default_config in hook_checks.items():
check_providers[key] = name
yaml_config = dict()
effective_config = default_config
if key in self._configured_checks:
data = dict_merge(data, self._configured_checks[key])
self._configured_checks[key] = data
yaml_config = self._configured_checks[key]
effective_config = dict_merge(default_config, yaml_config)
# Make sure there's nothing persisted in that check that shouldn't be persisted
#
# This used to be part of the settings migration (version 2) due to a bug - it can't
# stay there though since it interferes with manual entries to the checks not
# originating from within a plugin. Hence we do that step now here.
if "type" not in effective_config or effective_config["type"] != "github_commit":
deletables = ["current", "displayVersion"]
else:
deletables = []
self._clean_settings_check(key, yaml_config, default_config, delete=deletables, save=False)
# finally set our internal representation to our processed result
self._configured_checks[key] = effective_config
self._settings.set(["check_providers"], check_providers)
self._settings.save()
@ -287,11 +305,18 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin,
self._refresh_configured_checks = True
def get_settings_version(self):
return 4
return 5
def on_settings_migrate(self, target, current=None):
if current is None or current < 4:
if current == 4:
# config version 4 didn't correctly remove the old settings for octoprint_restart_command
# and environment_restart_command
self._settings.set(["environment_restart_command"], None)
self._settings.set(["octoprint_restart_command"], None)
if current is None or current < 5:
# config version 4 and higher moves octoprint_restart_command and
# environment_restart_command to the core configuration
@ -315,50 +340,27 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin,
if current is None or current == 2:
# No config version and config version 2 need the same fix, stripping
# accidentally persisted data off the checks
# accidentally persisted data off the checks.
#
# We used to do the same processing for the plugin entries too here, but that interfered
# with manual configuration entries. Stuff got deleted that wasn't supposed to be deleted.
#
# The problem is that we don't know if an entry we are looking at and which didn't come through
# a plugin hook is simply an entry from a now uninstalled/unactive plugin, or if it was something
# manually configured by the user. So instead of just blindly removing anything that doesn't
# come from a plugin here we instead clean up anything that indeed comes from a plugin
# during run time and leave everything else as is in the hopes that will not cause trouble.
#
# We still handle the "octoprint" entry here though.
configured_checks = self._settings.get(["checks"], incl_defaults=False)
if configured_checks is None:
configured_checks = dict()
check_keys = configured_checks.keys()
# take care of the octoprint entry
if "octoprint" in configured_checks:
if configured_checks is not None and "octoprint" in configured_checks:
octoprint_check = dict(configured_checks["octoprint"])
if "type" not in octoprint_check or octoprint_check["type"] != "github_commit":
deletables=["current", "displayName", "displayVersion"]
else:
deletables=[]
octoprint_check = self._clean_settings_check("octoprint", octoprint_check, self.get_settings_defaults()["checks"]["octoprint"], delete=deletables, save=False)
check_keys.remove("octoprint")
# and the hooks
update_check_hooks = self._plugin_manager.get_hooks("octoprint.plugin.softwareupdate.check_config")
for name, hook in update_check_hooks.items():
try:
hook_checks = hook()
except:
self._logger.exception("Error while retrieving update information from plugin {name}".format(**locals()))
else:
for key, data in hook_checks.items():
if key in configured_checks:
settings_check = dict(configured_checks[key])
merged = dict_merge(data, settings_check)
if "type" not in merged or merged["type"] != "github_commit":
deletables = ["current", "displayVersion"]
else:
deletables = []
self._clean_settings_check(key, settings_check, data, delete=deletables, save=False)
check_keys.remove(key)
# and anything that's left over we'll just remove now
for key in check_keys:
dummy_defaults = dict(plugins=dict())
dummy_defaults["plugins"][self._identifier] = dict(checks=dict())
dummy_defaults["plugins"][self._identifier]["checks"][key] = None
self._settings.set(["checks", key], None, defaults=dummy_defaults)
self._clean_settings_check("octoprint", octoprint_check, self.get_settings_defaults()["checks"]["octoprint"], delete=deletables, save=False)
elif current == 1:
# config version 1 had the error that the octoprint check got accidentally
@ -416,6 +418,15 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin,
def view():
try:
information, update_available, update_possible = self.get_current_versions(check_targets=check_targets, force=force)
# we don't want to transfer python_checker or python_updater values through json - replace with True
for key, data in information.items():
if "check" in data:
if "python_checker" in data["check"]:
data["check"]["python_checker"] = True
if "python_updater" in data["check"]:
data["check"]["python_updater"] = True
return flask.jsonify(dict(status="updatePossible" if update_available and update_possible else "updateAvailable" if update_available else "current",
information=information))
except exceptions.ConfigurationInvalid as e:
@ -677,6 +688,8 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin,
target_restart_type = check["restart"]
elif "pip" in check:
target_restart_type = "octoprint"
else:
target_restart_type = None
# if our update requires a restart we have to determine which type
if restart_type is None or (restart_type == "octoprint" and target_restart_type == "environment"):
@ -930,7 +943,7 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin,
elif "pip" in check:
method = "pip"
elif "python_updater" in check:
method = "python_updated"
method = "python_updater"
if method is None or (valid_methods and not method in valid_methods):
raise exceptions.UnknownUpdateType()

View file

@ -11,4 +11,4 @@ def can_perform_update(target, check):
def perform_update(target, check, target_version, log_cb=None):
return check["python_updater"].perform_update(target, check, target_version, log_cb=None)
return check["python_updater"].perform_update(target, check, target_version, log_cb=log_cb)

View file

@ -8,8 +8,6 @@ __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms
import requests
import logging
from ..exceptions import ConfigurationInvalid
RELEASE_URL = "https://api.github.com/repos/{user}/{repo}/releases"
logger = logging.getLogger("octoprint.plugins.softwareupdate.version_checks.github_release")
@ -207,18 +205,20 @@ def _is_current(release_information, compare_type, custom=None, force_base=True)
Tests:
>>> _is_current(dict(remote=dict(value=None))
>>> _is_current(dict(remote=dict(value=None)), "python")
True
>>> _is_current(dict(local=dict(value="1.2.15"), remote=dict(value="1.2.16")))
>>> _is_current(dict(local=dict(value="1.2.15"), remote=dict(value="1.2.16")), "python")
False
>>> _is_current(dict(local=dict(value="1.2.16dev1"), remote=dict(value="1.2.16dev2")))
>>> _is_current(dict(local=dict(value="1.2.16dev1"), remote=dict(value="1.2.16dev2")), "python")
True
>>> _is_current(dict(local=dict(value="1.2.16dev1"), remote=dict(value="1.2.16dev2")), force_base=False)
>>> _is_current(dict(local=dict(value="1.2.16dev1"), remote=dict(value="1.2.16dev2")), "python", force_base=False)
False
>>> _is_current(dict(local=dict(value="1.2.16dev3"), remote=dict(value="1.2.16dev2")), force_base=False)
>>> _is_current(dict(local=dict(value="1.2.16dev3"), remote=dict(value="1.2.16dev2")), "python", force_base=False)
True
>>> _is_current(dict(local=dict(value="1.2.16dev3"), remote=dict(value="1.2.16dev2")), force_base=False, compare_type="python_unequal")
>>> _is_current(dict(local=dict(value="1.2.16dev3"), remote=dict(value="1.2.16dev2")), "python_unequal", force_base=False)
False
>>> _is_current(dict(local=dict(value="1.3.0.post1+g1014712"), remote=dict(value="1.3.0")), "python")
True
"""
@ -241,6 +241,8 @@ def _is_current(release_information, compare_type, custom=None, force_base=True)
def get_latest(target, check, custom_compare=None):
from ..exceptions import ConfigurationInvalid
if not "user" in check or not "repo" in check:
raise ConfigurationInvalid("github_release update configuration for %s needs user and repo set" % target)

View file

@ -263,7 +263,7 @@ class Server(object):
return dict(settings=plugin_settings)
def settings_plugin_config_migration_and_cleanup(name, implementation):
def settings_plugin_config_migration_and_cleanup(identifier, implementation):
"""Take care of migrating and cleaning up any old settings"""
if not isinstance(implementation, octoprint.plugin.SettingsPlugin):
@ -276,7 +276,7 @@ class Server(object):
stored_version = implementation._settings.get_int([octoprint.plugin.SettingsPlugin.config_version_key])
if stored_version is None or stored_version < settings_version:
settings_migrator(settings_version, stored_version)
implementation._settings.set_int([octoprint.plugin.SettingsPlugin.config_version_key], settings_version)
implementation._settings.set_int([octoprint.plugin.SettingsPlugin.config_version_key], settings_version, force=True)
implementation.on_settings_cleanup()
implementation._settings.save()

View file

@ -72,9 +72,19 @@ def executeSystemCommand(source, command):
if not "command" in command_spec:
return make_response("Command {}:{} does not define a command to execute, can't proceed".format(source, command), 500)
async = command_spec["async"] if "async" in command_spec else False
ignore = command_spec["ignore"] if "ignore" in command_spec else False
do_async = command_spec.get("async", False)
do_ignore = command_spec.get("ignore", False)
logger.info("Performing command for {}:{}: {}".format(source, command, command_spec["command"]))
try:
if "before" in command_spec and callable(command_spec["before"]):
command_spec["before"]()
except Exception as e:
if not do_ignore:
error = "Command \"before\" failed: {}".format(str(e))
logger.warn(error)
return make_response(error, 500)
try:
# we run this with shell=True since we have to trust whatever
# our admin configured as command and since we want to allow
@ -83,9 +93,9 @@ def executeSystemCommand(source, command):
stdout=sarge.Capture(),
stderr=sarge.Capture(),
shell=True,
async=async)
if not async:
if not ignore and p.returncode != 0:
async=do_async)
if not do_async:
if not do_ignore and p.returncode != 0:
returncode = p.returncode
stdout_text = p.stdout.text
stderr_text = p.stderr.text
@ -94,7 +104,7 @@ def executeSystemCommand(source, command):
logger.warn(error)
return make_response(error, 500)
except Exception as e:
if not ignore:
if not do_ignore:
error = "Command failed: {}".format(str(e))
logger.warn(error)
return make_response(error, 500)
@ -126,6 +136,10 @@ def _get_command_spec(source, action):
def _get_core_command_specs():
def enable_safe_mode():
s().set(["server", "startOnceInSafeMode"], True)
s().save()
commands = collections.OrderedDict(
shutdown=dict(
command=s().get(["server", "commands", "systemShutdownCommand"]),
@ -138,7 +152,12 @@ def _get_core_command_specs():
restart=dict(
command=s().get(["server", "commands", "serverRestartCommand"]),
name=gettext("Restart OctoPrint"),
confirm=gettext("You are about to restart the OctoPrint server."))
confirm=gettext("You are about to restart the OctoPrint server.")),
restart_safe=dict(
command=s().get(["server", "commands", "serverRestartCommand"]),
name=gettext("Restart OctoPrint in safe mode"),
confirm=gettext("You are about to restart the OctoPrint server in safe mode."),
before=enable_safe_mode)
)
available_commands = collections.OrderedDict()

View file

@ -1396,13 +1396,18 @@ class Settings(object):
try:
current = chain.get_by_path(path)
except KeyError:
current = None
try:
default_value = chain.get_by_path(path, only_defaults=True)
in_local = chain.has_path(path, only_local=True)
in_defaults = chain.has_path(path, only_defaults=True)
except KeyError:
if error_on_path:
raise NoSuchSettingsPath()
return
default_value = None
in_local = chain.has_path(path, only_local=True)
in_defaults = chain.has_path(path, only_defaults=True)
if not force and in_defaults and in_local and default_value == value:
try:

View file

@ -809,17 +809,10 @@ class TimelapseRenderJob(object):
@classmethod
def _create_ffmpeg_command_string(cls, ffmpeg, fps, bitrate, threads, input, output, hflip=False, vflip=False,
rotate=False, watermark=None):
rotate=False, watermark=None, pixfmt="yuv420p"):
"""
Create ffmpeg command string based on input parameters.
Examples:
>>> TimelapseRenderJob._create_ffmpeg_command_string("/path/to/ffmpeg", 25, "10000k", 1, "/path/to/input/files_%d.jpg", "/path/to/output.mpg")
'/path/to/ffmpeg -framerate 25 -loglevel error -i "/path/to/input/files_%d.jpg" -vcodec mpeg2video -threads 1 -pix_fmt yuv420p -r 25 -y -b 10000k -f vob "/path/to/output.mpg"'
>>> TimelapseRenderJob._create_ffmpeg_command_string("/path/to/ffmpeg", 25, "10000k", 1, "/path/to/input/files_%d.jpg", "/path/to/output.mpg", hflip=True)
'/path/to/ffmpeg -framerate 25 -loglevel error -i "/path/to/input/files_%d.jpg" -vcodec mpeg2video -threads 1 -pix_fmt yuv420p -r 25 -y -b 10000k -f vob -vf \\'[in] hflip [out]\\' "/path/to/output.mpg"'
Arguments:
ffmpeg (str): Path to ffmpeg
fps (int): Frames per second for output
@ -831,16 +824,19 @@ class TimelapseRenderJob(object):
vflip (bool): Perform vertical flip on input material.
rotate (bool): Perform 90° CCW rotation on input material.
watermark (str): Path to watermark to apply to lower left corner.
pixfmt (str): Pixel format to use for output. Default of yuv420p should usually fit the bill.
Returns:
(str): Prepared command string to render `input` to `output` using ffmpeg.
"""
### See unit tests in test/timelapse/test_timelapse_renderjob.py
logger = logging.getLogger(__name__)
command = [
ffmpeg, '-framerate', str(fps), '-loglevel', 'error', '-i', '"{}"'.format(input), '-vcodec', 'mpeg2video',
'-threads', str(threads), '-pix_fmt', 'yuv420p', '-r', "25", '-y', '-b', str(bitrate),
'-threads', str(threads), '-r', "25", '-y', '-b', str(bitrate),
'-f', 'vob']
filter_string = cls._create_filter_string(hflip=hflip,
@ -859,38 +855,25 @@ class TimelapseRenderJob(object):
return " ".join(command)
@classmethod
def _create_filter_string(cls, hflip=False, vflip=False, rotate=False, watermark=None):
def _create_filter_string(cls, hflip=False, vflip=False, rotate=False, watermark=None, pixfmt="yuv420p"):
"""
Creates an ffmpeg filter string based on input parameters.
Examples:
>>> TimelapseRenderJob._create_filter_string()
>>> TimelapseRenderJob._create_filter_string(hflip=True)
'[in] hflip [out]'
>>> TimelapseRenderJob._create_filter_string(vflip=True)
'[in] vflip [out]'
>>> TimelapseRenderJob._create_filter_string(rotate=True)
'[in] transpose=2 [out]'
>>> TimelapseRenderJob._create_filter_string(vflip=True, rotate=True)
'[in] vflip,transpose=2 [out]'
>>> TimelapseRenderJob._create_filter_string(vflip=True, hflip=True, rotate=True)
'[in] hflip,vflip,transpose=2 [out]'
>>> TimelapseRenderJob._create_filter_string(watermark="/path/to/watermark.png")
'movie=/path/to/watermark.png [wm]; [in][wm] overlay=10:main_h-overlay_h-10 [out]'
>>> TimelapseRenderJob._create_filter_string(hflip=True, watermark="/path/to/watermark.png")
'[in] hflip [postprocessed]; movie=/path/to/watermark.png [wm]; [postprocessed][wm] overlay=10:main_h-overlay_h-10 [out]'
Arguments:
hflip (bool): Perform horizontal flip on input material.
vflip (bool): Perform vertical flip on input material.
rotate (bool): Perform 90° CCW rotation on input material.
watermark (str): Path to watermark to apply to lower left corner.
pixfmt (str): Pixel format to use, defaults to "yuv420p" which should usually fit the bill
Returns:
(str or None): filter string or None if no filters are required
"""
filters = []
### See unit tests in test/timelapse/test_timelapse_renderjob.py
# apply pixel format
filters = ["format={}".format(pixfmt)]
# flip video if configured
if hflip:

View file

@ -0,0 +1,66 @@
# coding=utf-8
from __future__ import absolute_import
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
__copyright__ = "Copyright (C) 2016 The OctoPrint Project - Released under terms of the AGPLv3 License"
import unittest
from ddt import ddt, data, unpack
from octoprint.timelapse import TimelapseRenderJob
@ddt
class TimelapseRenderJobTest(unittest.TestCase):
@data(
(("/path/to/ffmpeg", 25, "10000k", 1, "/path/to/input/files_%d.jpg", "/path/to/output.mpg"),
dict(),
'/path/to/ffmpeg -framerate 25 -loglevel error -i "/path/to/input/files_%d.jpg" -vcodec mpeg2video -threads 1 -r 25 -y -b 10000k -f vob -vf \'[in] format=yuv420p [out]\' "/path/to/output.mpg"'),
(("/path/to/ffmpeg", 25, "10000k", 1, "/path/to/input/files_%d.jpg", "/path/to/output.mpg"),
dict(hflip=True),
'/path/to/ffmpeg -framerate 25 -loglevel error -i "/path/to/input/files_%d.jpg" -vcodec mpeg2video -threads 1 -r 25 -y -b 10000k -f vob -vf \'[in] format=yuv420p,hflip [out]\' "/path/to/output.mpg"'),
(("/path/to/ffmpeg", 25, "20000k", 4, "/path/to/input/files_%d.jpg", "/path/to/output.mpg"),
dict(rotate=True, watermark="/path/to/watermark.png"),
'/path/to/ffmpeg -framerate 25 -loglevel error -i "/path/to/input/files_%d.jpg" -vcodec mpeg2video -threads 4 -r 25 -y -b 20000k -f vob -vf \'[in] format=yuv420p,transpose=2 [postprocessed]; movie=/path/to/watermark.png [wm]; [postprocessed][wm] overlay=10:main_h-overlay_h-10 [out]\' "/path/to/output.mpg"')
)
@unpack
def test_create_ffmpeg_command_string(self, args, kwargs, expected):
actual = TimelapseRenderJob._create_ffmpeg_command_string(*args, **kwargs)
self.assertEquals(actual, expected)
@data(
(dict(),
'[in] format=yuv420p [out]'),
(dict(pixfmt="test"),
'[in] format=test [out]'),
(dict(hflip=True),
'[in] format=yuv420p,hflip [out]'),
(dict(vflip=True),
'[in] format=yuv420p,vflip [out]'),
(dict(rotate=True),
'[in] format=yuv420p,transpose=2 [out]'),
(dict(vflip=True, rotate=True),
'[in] format=yuv420p,vflip,transpose=2 [out]'),
(dict(vflip=True, hflip=True, rotate=True),
'[in] format=yuv420p,hflip,vflip,transpose=2 [out]'),
(dict(watermark="/path/to/watermark.png"),
'[in] format=yuv420p [postprocessed]; movie=/path/to/watermark.png [wm]; [postprocessed][wm] overlay=10:main_h-overlay_h-10 [out]'),
(dict(hflip=True, watermark="/path/to/watermark.png"),
'[in] format=yuv420p,hflip [postprocessed]; movie=/path/to/watermark.png [wm]; [postprocessed][wm] overlay=10:main_h-overlay_h-10 [out]'),
)
@unpack
def test_create_filter_string(self, kwargs, expected):
actual = TimelapseRenderJob._create_filter_string(**kwargs)
self.assertEquals(actual, expected)