diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed198f3..55b11ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/octoprint/cli/__init__.py b/src/octoprint/cli/__init__.py index a6156b46..9b7d182b 100644 --- a/src/octoprint/cli/__init__.py +++ b/src/octoprint/cli/__init__.py @@ -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) diff --git a/src/octoprint/plugins/pluginmanager/__init__.py b/src/octoprint/plugins/pluginmanager/__init__.py index 7e6b96f5..bcd70bb4 100644 --- a/src/octoprint/plugins/pluginmanager/__init__.py +++ b/src/octoprint/plugins/pluginmanager/__init__.py @@ -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" diff --git a/src/octoprint/plugins/softwareupdate/__init__.py b/src/octoprint/plugins/softwareupdate/__init__.py index 4098366a..fbb38630 100644 --- a/src/octoprint/plugins/softwareupdate/__init__.py +++ b/src/octoprint/plugins/softwareupdate/__init__.py @@ -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() diff --git a/src/octoprint/plugins/softwareupdate/updaters/python_updater.py b/src/octoprint/plugins/softwareupdate/updaters/python_updater.py index f4df545b..72a4bb20 100644 --- a/src/octoprint/plugins/softwareupdate/updaters/python_updater.py +++ b/src/octoprint/plugins/softwareupdate/updaters/python_updater.py @@ -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) diff --git a/src/octoprint/plugins/softwareupdate/version_checks/github_release.py b/src/octoprint/plugins/softwareupdate/version_checks/github_release.py index 773abcc4..a8867105 100644 --- a/src/octoprint/plugins/softwareupdate/version_checks/github_release.py +++ b/src/octoprint/plugins/softwareupdate/version_checks/github_release.py @@ -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) diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index 5d5ad98a..6d2c9a9d 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -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() diff --git a/src/octoprint/server/api/system.py b/src/octoprint/server/api/system.py index 97ec21fd..058476d3 100644 --- a/src/octoprint/server/api/system.py +++ b/src/octoprint/server/api/system.py @@ -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() diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index e52bac7a..9a80273b 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -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: diff --git a/src/octoprint/timelapse.py b/src/octoprint/timelapse.py index 5e913bf6..67c9407f 100644 --- a/src/octoprint/timelapse.py +++ b/src/octoprint/timelapse.py @@ -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: diff --git a/tests/timelapse/test_timelapse_renderjob.py b/tests/timelapse/test_timelapse_renderjob.py new file mode 100644 index 00000000..e8663e38 --- /dev/null +++ b/tests/timelapse/test_timelapse_renderjob.py @@ -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)