Big overhaul of timelapse handling

* persistent notification on ongoing timelapse render job (#485)
  * non-colliding timelapse snapshot name generation to not delete
     existing snapshots when new print starts and timelapse has not
     yet been rendered, also only delete snapshots if timelapse rendered (#318)
  * list of unrendered timelapses, with option to delete files
    or to render timelapse
This commit is contained in:
Gina Häußge 2016-02-02 18:03:56 +01:00
parent 6ce82ceb00
commit 4e31ccf4c5
10 changed files with 620 additions and 158 deletions

View file

@ -283,7 +283,7 @@ class Server():
self._setup_assets()
# configure timelapse
octoprint.timelapse.configureTimelapse()
octoprint.timelapse.configure_timelapse()
# setup command triggers
events.CommandTrigger(printer)

View file

@ -14,10 +14,12 @@ import octoprint.timelapse
import octoprint.util as util
from octoprint.settings import settings, valid_boolean_trues
from octoprint.server import admin_permission
from octoprint.server.util.flask import redirect_to_tornado, restricted_access
from octoprint.server import admin_permission, printer
from octoprint.server.util.flask import redirect_to_tornado, restricted_access, get_json_command_from_request
from octoprint.server.api import api
from octoprint.server import NO_CONTENT
#~~ timelapse handling
@ -39,14 +41,17 @@ def getTimelapseData():
"interval": timelapse.interval
})
files = octoprint.timelapse.getFinishedTimelapses()
files = octoprint.timelapse.get_finished_timelapses()
for file in files:
file["url"] = url_for("index") + "downloads/timelapse/" + file["name"]
return jsonify({
"config": config,
"files": files
})
result = dict(config=config,
files=files)
if "unrendered" in request.values and request.values["unrendered"] in valid_boolean_trues:
result.update(unrendered=octoprint.timelapse.get_unrendered_timelapses())
return jsonify(result)
@api.route("/timelapse/<filename>", methods=["GET"])
@ -57,7 +62,7 @@ def downloadTimelapse(filename):
@api.route("/timelapse/<filename>", methods=["DELETE"])
@restricted_access
def deleteTimelapse(filename):
if util.is_allowed_file(filename, {"mpg"}):
if util.is_allowed_file(filename, ["mpg"]):
timelapse_folder = settings().getBaseFolder("timelapse")
full_path = os.path.realpath(os.path.join(timelapse_folder, filename))
if full_path.startswith(timelapse_folder) and os.path.exists(full_path):
@ -65,6 +70,33 @@ def deleteTimelapse(filename):
return getTimelapseData()
@api.route("/timelapse/unrendered/<name>", methods=["DELETE"])
@restricted_access
def deleteUnrenderedTimelapse(name):
octoprint.timelapse.delete_unrendered_timelapse(name)
return NO_CONTENT
@api.route("/timelapse/unrendered/<name>", methods=["POST"])
@restricted_access
def processUnrenderedTimelapseCommand(name):
# valid file commands, dict mapping command name to mandatory parameters
valid_commands = {
"render": []
}
command, data, response = get_json_command_from_request(request, valid_commands)
if response is not None:
return response
if command == "render":
if printer.is_printing() or printer.is_paused():
return make_response("Printer is currently printing, cannot render timelapse", 409)
octoprint.timelapse.render_unrendered_timelapse(name)
return NO_CONTENT
@api.route("/timelapse", methods=["POST"])
@restricted_access
def setTimelapseConfig():
@ -114,9 +146,9 @@ def setTimelapseConfig():
return make_response("Invalid value for interval: %d" % interval)
if admin_permission.can() and "save" in request.values and request.values["save"] in valid_boolean_trues:
octoprint.timelapse.configureTimelapse(config, True)
octoprint.timelapse.configure_timelapse(config, True)
else:
octoprint.timelapse.configureTimelapse(config)
octoprint.timelapse.configure_timelapse(config)
return getTimelapseData()

View file

@ -73,20 +73,31 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection, octoprint.printer.
self._printer.register_callback(self)
self._fileManager.register_slicingprogress_callback(self)
octoprint.timelapse.registerCallback(self)
octoprint.timelapse.register_callback(self)
self._pluginManager.register_message_receiver(self.on_plugin_message)
self._eventManager.fire(Events.CLIENT_OPENED, {"remoteAddress": self._remoteAddress})
for event in octoprint.events.all_events():
self._eventManager.subscribe(event, self._onEvent)
octoprint.timelapse.notifyCallbacks(octoprint.timelapse.current)
octoprint.timelapse.notify_callbacks(octoprint.timelapse.current)
# This is a horrible hack for now to allow displaying a notification that a render job is still
# active in the backend on a fresh connect of a client. This needs to be substituted with a proper
# job management for timelapse rendering, analysis stuff etc that also gets cancelled when prints
# start and so on.
#
# For now this is the easiest way though to at least inform the user that a timelapse is still ongoing.
#
# TODO remove when central job management becomes available and takes care of this for us
if octoprint.timelapse.current_render_job is not None:
self._emit("event", {"type": Events.MOVIE_RENDERING, "payload": octoprint.timelapse.current_render_job})
def on_close(self):
self._logger.info("Client connection closed: %s" % self._remoteAddress)
self._printer.unregister_callback(self)
self._fileManager.unregister_slicingprogress_callback(self)
octoprint.timelapse.unregisterCallback(self)
octoprint.timelapse.unregister_callback(self)
self._pluginManager.unregister_message_receiver(self.on_plugin_message)
self._eventManager.fire(Events.CLIENT_CLOSED, {"remoteAddress": self._remoteAddress})

View file

@ -137,7 +137,8 @@ default_settings = {
"options": {},
"postRoll": 0,
"fps": 25
}
},
"cleanTmpAfterDays": 7
},
"gcodeViewer": {
"enabled": True,

File diff suppressed because one or more lines are too long

View file

@ -16,6 +16,8 @@ function DataUpdater(allViewModels) {
self._lastProcessingTimes = [];
self._lastProcessingTimesSize = 20;
self._timelapse_popup = undefined;
self.connect = function() {
var options = {};
if (SOCKJS_DEBUG) {
@ -221,17 +223,54 @@ function DataUpdater(allViewModels) {
log.debug("Got event " + type + " with payload: " + JSON.stringify(payload));
if (type == "MovieRendering") {
new PNotify({title: gettext("Rendering timelapse"), text: _.sprintf(gettext("Now rendering timelapse %(movie_basename)s"), payload)});
if (self._timelapse_popup !== undefined) {
self._timelapse_popup.remove();
}
self._timelapse_popup = new PNotify({
title: gettext("Rendering timelapse"),
text: _.sprintf(gettext("Now rendering timelapse %(movie_basename)s. Due to performance reasons it is not recommended to start a print job while a movie is still rendering."), payload),
hide: false,
callbacks: {
before_close: function() {
self._timelapse_popup = undefined;
}
}
});
} else if (type == "MovieDone") {
new PNotify({title: gettext("Timelapse ready"), text: _.sprintf(gettext("New timelapse %(movie_basename)s is done rendering."), payload)});
if (self._timelapse_popup !== undefined) {
self._timelapse_popup.remove();
}
self._timelapse_popup = new PNotify({
title: gettext("Timelapse ready"),
text: _.sprintf(gettext("New timelapse %(movie_basename)s is done rendering."), payload),
type: "success",
callbacks: {
before_close: function(notice) {
if (self._timelapse_popup == notice) {
self._timelapse_popup = undefined;
}
}
}
});
} else if (type == "MovieFailed") {
html = "<p>" + _.sprintf(gettext("Rendering of timelapse %(movie_basename)s failed with return code %(returncode)s"), payload) + "</p>";
html += pnotifyAdditionalInfo('<pre style="overflow: auto">' + payload.error + '</pre>');
new PNotify({
if (self._timelapse_popup !== undefined) {
self._timelapse_popup.remove();
}
self._timelapse_popup = new PNotify({
title: gettext("Rendering failed"),
text: html,
type: "error",
hide: false
hide: false,
callbacks: {
before_close: function(notice) {
if (self._timelapse_popup == notice) {
self._timelapse_popup = undefined;
}
}
}
});
} else if (type == "PostRollStart") {
var title = gettext("Capturing timelapse postroll");
@ -240,17 +279,33 @@ function DataUpdater(allViewModels) {
if (!payload.postroll_duration) {
text = _.sprintf(gettext("Now capturing timelapse post roll, this will take only a moment..."), format);
} else {
format = {
time: moment().add(payload.postroll_duration, "s").format("LT")
};
if (payload.postroll_duration > 60) {
format = {duration: _.sprintf(gettext("%(minutes)d min"), {minutes: payload.postroll_duration / 60})};
format.duration = _.sprintf(gettext("%(minutes)d min"), {minutes: payload.postroll_duration / 60});
text = _.sprintf(gettext("Now capturing timelapse post roll, this will take approximately %(duration)s (so until %(time)s)..."), format);
} else {
format = {duration: _.sprintf(gettext("%(seconds)d sec"), {seconds: payload.postroll_duration})};
format.duration = _.sprintf(gettext("%(seconds)d sec"), {seconds: payload.postroll_duration});
text = _.sprintf(gettext("Now capturing timelapse post roll, this will take approximately %(duration)s..."), format);
}
text = _.sprintf(gettext("Now capturing timelapse post roll, this will take approximately %(duration)s..."), format);
}
new PNotify({
if (self._timelapse_popup !== undefined) {
self._timelapse_popup.remove();
}
self._timelapse_popup = new PNotify({
title: title,
text: text
text: text,
hide: false,
callbacks: {
before_close: function(notice) {
if (self._timelapse_popup == notice) {
self._timelapse_popup = undefined;
}
}
}
});
} else if (type == "SlicingStarted") {
gcodeUploadProgress.addClass("progress-striped").addClass("active");

View file

@ -24,6 +24,10 @@ $(function() {
self.isReady = ko.observable(undefined);
self.isLoading = ko.observable(undefined);
self.isBusy = ko.pureComputed(function() {
return self.isPrinting() || self.isPaused();
});
self.timelapseTypeSelected = ko.pureComputed(function() {
return ("off" != self.timelapseType());
});
@ -82,9 +86,40 @@ $(function() {
CONFIG_TIMELAPSEFILESPERPAGE
);
// initialize list helper for unrendered timelapses
self.unrenderedListHelper = new ItemListHelper(
"unrenderedTimelapseFiles",
{
"name": function(a, b) {
// sorts ascending
if (a["name"].toLocaleLowerCase() < b["name"].toLocaleLowerCase()) return -1;
if (a["name"].toLocaleLowerCase() > b["name"].toLocaleLowerCase()) return 1;
return 0;
},
"creation": function(a, b) {
// sorts descending
if (a["date"] > b["date"]) return -1;
if (a["date"] < b["date"]) return 1;
return 0;
},
"size": function(a, b) {
// sorts descending
if (a["bytes"] > b["bytes"]) return -1;
if (a["bytes"] < b["bytes"]) return 1;
return 0;
}
},
{
},
"name",
[],
[],
CONFIG_TIMELAPSEFILESPERPAGE
);
self.requestData = function() {
$.ajax({
url: API_BASEURL + "timelapse",
url: API_BASEURL + "timelapse?unrendered=true",
type: "GET",
dataType: "json",
success: self.fromResponse
@ -97,6 +132,9 @@ $(function() {
self.timelapseType(config.type);
self.listHelper.updateItems(response.files);
if (response.unrendered) {
self.unrenderedListHelper.updateItems(response.unrendered);
}
if (config.type == "timed") {
if (config.interval != undefined && config.interval > 0) {
@ -149,6 +187,25 @@ $(function() {
});
};
self.removeUnrendered = function(name) {
$.ajax({
url: API_BASEURL + "timelapse/unrendered/" + name,
type: "DELETE",
dataType: "json",
success: self.requestData
});
};
self.renderUnrendered = function(name) {
$.ajax({
url: API_BASEURL + "timelapse/unrendered/" + name,
type: "POST",
dataType: "json",
contentType: "application/json; charset=UTF-8",
data: JSON.stringify({command: "render"})
});
};
self.save = function(data, event) {
var payload = {
"type": self.timelapseType(),

View file

@ -282,7 +282,8 @@ table {
}
// timelapse files
&.timelapse_files_name {
&.timelapse_files_name,
&.timelapse_unrendered_name {
text-overflow: ellipsis;
text-align: left;
}
@ -292,7 +293,18 @@ table {
width: 55px;
}
&.timelapse_files_action {
&.timelapse_unrendered_size {
text-align: right;
width: 55px;
}
&.timelapse_unrendered_count {
text-align: right;
width: 45px;
}
&.timelapse_files_action,
&.timelapse_unrendered_action {
width: 45px;
.actioncol;
}

View file

@ -47,7 +47,7 @@
<h1>{{ _('Finished Timelapses') }}</h1>
<div class="pull-right">
<small>{{ _('Sort by') }}: <a href="#" data-bind="click: function() { listHelper.changeSorting('name'); }">{{ _('Name') }} ({{ _('ascending') }})</a> | <a href="#" data-bind="click: function() { listHelper.changeSorting('creation'); }">{{ _('Creation date') }} ({{ _('descending') }})</a> | <a href="#" data-bind="click: function() { listHelper.changeSorting('size'); }">{{ _('Size') }} ({{ _('descending') }})</a></small>
<small>{{ _('Sort by') }}: <a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('name'); }">{{ _('Name') }} ({{ _('ascending') }})</a> | <a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('creation'); }">{{ _('Creation date') }} ({{ _('descending') }})</a> | <a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('size'); }">{{ _('Size') }} ({{ _('descending') }})</a></small>
</div>
<table class="table table-striped table-hover table-condensed table-hover" id="timelapse_files">
<thead>
@ -61,18 +61,53 @@
<tr data-bind="attr: {title: name}">
<td class="timelapse_files_name" data-bind="text: name"></td>
<td class="timelapse_files_size" data-bind="text: size"></td>
<td class="timelapse_files_action"><a href="#" class="icon-trash" data-bind="click: function() { if ($root.loginState.isUser()) { $parent.removeFile($data.name); } else { return; } }, css: {disabled: !$root.loginState.isUser()}"></a>&nbsp;|&nbsp;<a href="#" class="icon-download" data-bind="attr: {href: url}"></a></td>
<td class="timelapse_files_action"><a href="javascript:void(0)" class="icon-trash" data-bind="click: function() { if ($root.loginState.isUser()) { $parent.removeFile($data.name); } else { return; } }, css: {disabled: !$root.loginState.isUser()}"></a>&nbsp;|&nbsp;<a href="javascript:void(0)" class="icon-download" data-bind="attr: {href: url}"></a></td>
</tr>
</tbody>
</table>
<div class="pagination pagination-mini pagination-centered">
<ul>
<li data-bind="css: {disabled: listHelper.currentPage() === 0}"><a href="#" data-bind="click: listHelper.prevPage">«</a></li>
<li data-bind="css: {disabled: listHelper.currentPage() === 0}"><a href="javascript:void(0)" data-bind="click: listHelper.prevPage">«</a></li>
</ul>
<ul data-bind="foreach: listHelper.pages">
<li data-bind="css: { active: $data.number === $root.listHelper.currentPage(), disabled: $data.number === -1 }"><a href="#" data-bind="text: $data.text, click: function() { $root.listHelper.changePage($data.number); }"></a></li>
<li data-bind="css: { active: $data.number === $root.listHelper.currentPage(), disabled: $data.number === -1 }"><a href="javascript:void(0)" data-bind="text: $data.text, click: function() { $root.listHelper.changePage($data.number); }"></a></li>
</ul>
<ul>
<li data-bind="css: {disabled: listHelper.currentPage() === listHelper.lastPage()}"><a href="#" data-bind="click: listHelper.nextPage">»</a></li>
<li data-bind="css: {disabled: listHelper.currentPage() === listHelper.lastPage()}"><a href="javascript:void(0)" data-bind="click: listHelper.nextPage">»</a></li>
</ul>
</div>
<div data-bind="visible: unrenderedListHelper.allSize">
<div><small><a href="javascript:void(0)" class="muted" onclick="$(this).children().toggleClass('icon-caret-right icon-caret-down').parent().parent().parent().next().slideToggle('fast')"><i class="icon-caret-right"></i> {{ _('Unrendered Timelapses') }}</a></small></div>
<div class="hide">
<table class="table table-striped table-hover table-condensed table-hover" id="timelapse_unrendered">
<thead>
<tr>
<th class="timelapse_unrendered_name">{{ _('Name') }}</th>
<th class="timelapse_unrendered_count">{{ _('Frames') }}</th>
<th class="timelapse_unrendered_size">{{ _('Size') }}</th>
<th class="timelapse_unrendered_action">{{ _('Action') }}</th>
</tr>
</thead>
<tbody data-bind="foreach: unrenderedListHelper.paginatedItems">
<tr data-bind="attr: {title: name}">
<td class="timelapse_unrendered_name" data-bind="text: name"></td>
<td class="timelapse_unrendered_count" data-bind="text: count"></td>
<td class="timelapse_unrendered_size" data-bind="text: size"></td>
<td class="timelapse_unrendered_action"><a href="javascript:void(0)" title="{{ _('Delete unrendered timelapse') }}" class="icon-trash" data-bind="click: function() { if ($root.loginState.isUser()) { $parent.removeUnrendered($data.name); } else { return; } }, css: {disabled: !$root.loginState.isUser()}"></a>&nbsp;|&nbsp;<a href="javascript:void(0)" title="{{ _('Render timelapse') }}" class="icon-facetime-video" data-bind="click: function() { if ($root.loginState.isUser() && !$root.isBusy()) { $parent.renderUnrendered($data.name); } else { return; } }, css: {disabled: !$root.loginState.isUser() || $root.isBusy()}"></a></td>
</tr>
</tbody>
</table>
<div class="pagination pagination-mini pagination-centered">
<ul>
<li data-bind="css: {disabled: unrenderedListHelper.currentPage() === 0}"><a href="javascript:void(0)" data-bind="click: unrenderedListHelper.prevPage">«</a></li>
</ul>
<ul data-bind="foreach: unrenderedListHelper.pages">
<li data-bind="css: { active: $data.number === $root.unrenderedListHelper.currentPage(), disabled: $data.number === -1 }"><a href="javascript:void(0)" data-bind="text: $data.text, click: function() { $root.unrenderedListHelper.changePage($data.number); }"></a></li>
</ul>
<ul>
<li data-bind="css: {disabled: unrenderedListHelper.currentPage() === unrenderedListHelper.lastPage()}"><a href="javascript:void(0)" data-bind="click: unrenderedListHelper.nextPage">»</a></li>
</ul>
</div>
</div>
</div>

View file

@ -18,13 +18,41 @@ import octoprint.util as util
from octoprint.settings import settings
from octoprint.events import eventManager, Events
import sarge
import collections
# currently configured timelapse
current = None
# currently active render job, if any
current_render_job = None
def getFinishedTimelapses():
# filename formats
_capture_format = "{prefix}-%d.jpg"
_output_format = "{prefix}.mpg"
# valid timelapses
_valid_timelapse_types = ["off", "timed", "zchange"]
# callbacks for timelapse config updates
_update_callbacks = []
def _extract_prefix(filename):
"""
>>> _extract_prefix("some_long_filename_without_hyphen.jpg")
>>> _extract_prefix("-first_char_is_hyphen.jpg")
>>> _extract_prefix("some_long_filename_with-stuff.jpg")
'some_long_filename_with'
"""
pos = filename.rfind("-")
if not pos or pos < 0:
return None
return filename[:pos]
def get_finished_timelapses():
files = []
basedir = settings().getBaseFolder("timelapse")
for osFile in os.listdir(basedir):
@ -39,32 +67,137 @@ def getFinishedTimelapses():
})
return files
validTimelapseTypes = ["off", "timed", "zchange"]
updateCallbacks = []
def get_unrendered_timelapses():
delete_old_unrendered_timelapses()
basedir = settings().getBaseFolder("timelapse_tmp")
jobs = collections.defaultdict(lambda: dict(count=0, size=None, bytes=0, date=None, timestamp=None))
for osFile in os.listdir(basedir):
if not fnmatch.fnmatch(osFile, "*.jpg"):
continue
prefix = _extract_prefix(osFile)
if prefix is None:
continue
statResult = os.stat(os.path.join(basedir, osFile))
jobs[prefix]["count"] += 1
jobs[prefix]["bytes"] += statResult.st_size
if jobs[prefix]["timestamp"] is None or statResult.st_ctime < jobs[prefix]["timestamp"]:
jobs[prefix]["timestamp"] = statResult.st_ctime
def finalize_fields(job):
job["size"] = util.get_formatted_size(job["bytes"])
job["date"] = util.get_formatted_datetime(datetime.datetime.fromtimestamp(job["timestamp"]))
del job["timestamp"]
return job
return [util.dict_merge(dict(name=key), finalize_fields(value)) for key, value in jobs.items()]
def registerCallback(callback):
if not callback in updateCallbacks:
updateCallbacks.append(callback)
def delete_unrendered_timelapse(name):
basedir = settings().getBaseFolder("timelapse_tmp")
for filename in os.listdir(basedir):
try:
if fnmatch.fnmatch(filename, "{}*.jpg".format(name)):
os.remove(os.path.join(basedir, filename))
except:
logging.getLogger(__name__).exception("Error while processing file {} during cleanup".format(filename))
def unregisterCallback(callback):
if callback in updateCallbacks:
updateCallbacks.remove(callback)
def render_unrendered_timelapse(name, gcode=None, postfix=None, fps=25):
capture_dir = settings().getBaseFolder("timelapse_tmp")
output_dir = settings().getBaseFolder("timelapse")
threads = settings().get(["webcam", "ffmpegThreads"])
job = TimelapseRenderJob(capture_dir, output_dir, name,
postfix=postfix,
capture_format=_capture_format,
output_format=_output_format,
fps=fps,
threads=threads,
on_start=_create_render_start_handler(name, gcode=gcode),
on_success=_create_render_success_handler(name, gcode=gcode),
on_fail=_create_render_fail_handler(name, gcode=gcode),
on_always=_create_render_always_handler(name, gcode=gcode))
job.process()
def notifyCallbacks(timelapse):
def delete_old_unrendered_timelapses():
basedir = settings().getBaseFolder("timelapse_tmp")
clean_after_days = settings().getInt(["webcam", "cleanTmpAfterDays"])
cutoff = time.time() - clean_after_days * 24 * 60 * 60
for filename in os.listdir(basedir):
try:
path = os.path.join(basedir, filename)
if os.path.getmtime(path) < cutoff:
os.remove(path)
except:
logging.getLogger(__name__).exception("Error while processing file {} during cleanup".format(filename))
def _create_render_start_handler(name, gcode=None):
def f(movie):
global current_render_job
event_payload = {"gcode": gcode if gcode is not None else "unknown",
"movie": movie,
"movie_basename": os.path.basename(movie)}
current_render_job = event_payload
eventManager().fire(Events.MOVIE_RENDERING, event_payload)
return f
def _create_render_success_handler(name, gcode=None):
def f(movie):
event_payload = {"gcode": gcode if gcode is not None else "unknown",
"movie": movie,
"movie_basename": os.path.basename(movie)}
eventManager().fire(Events.MOVIE_DONE, event_payload)
delete_unrendered_timelapse(name)
return f
def _create_render_fail_handler(name, gcode=None):
def f(movie, returncode=255, stdout="Unknown error", stderr="Unknown error"):
event_payload = {"gcode": gcode if gcode is not None else "unknown",
"movie": movie,
"movie_basename": os.path.basename(movie)}
payload = dict(event_payload)
payload.update(dict(returncode=returncode, error=stderr))
eventManager().fire(Events.MOVIE_FAILED, payload)
return f
def _create_render_always_handler(name, gcode=None):
def f(movie):
global current_render_job
current_render_job = None
return f
def register_callback(callback):
if not callback in _update_callbacks:
_update_callbacks.append(callback)
def unregister_callback(callback):
if callback in _update_callbacks:
_update_callbacks.remove(callback)
def notify_callbacks(timelapse):
if timelapse is None:
config = None
else:
config = timelapse.config_data()
for callback in updateCallbacks:
for callback in _update_callbacks:
try: callback.sendTimelapseConfig(config)
except: logging.getLogger(__name__).exception("Exception while pushing timelapse configuration")
def configureTimelapse(config=None, persist=False):
def configure_timelapse(config=None, persist=False):
global current
if config is None:
@ -93,7 +226,7 @@ def configureTimelapse(config=None, persist=False):
interval = config["options"]["interval"]
current = TimedTimelapse(post_roll=postRoll, interval=interval, fps=fps)
notifyCallbacks(current)
notify_callbacks(current)
if persist:
settings().set(["webcam", "timelapse"], config)
@ -116,12 +249,9 @@ class Timelapse(object):
self._capture_dir = settings().getBaseFolder("timelapse_tmp")
self._movie_dir = settings().getBaseFolder("timelapse")
self._snapshot_url = settings().get(["webcam", "snapshot"])
self._ffmpeg_threads = settings().get(["webcam", "ffmpegThreads"])
self._fps = fps
self._render_thread = None
self._capture_mutex = threading.Lock()
self._capture_queue = Queue.Queue()
self._capture_queue_active = True
@ -148,7 +278,7 @@ class Timelapse(object):
def unload(self):
if self._in_timelapse:
self.stop_timelapse(doCreateMovie=False)
self.stop_timelapse(do_create_movie=False)
# unsubscribe events
eventManager().unsubscribe(Events.PRINT_STARTED, self.on_print_started)
@ -201,51 +331,55 @@ class Timelapse(object):
def start_timelapse(self, gcodeFile):
self._logger.debug("Starting timelapse for %s" % gcodeFile)
self.clean_capture_dir()
self._image_number = 0
self._in_timelapse = True
self._gcode_file = os.path.basename(gcodeFile)
self._file_prefix = "{}_{}".format(os.path.splitext(self._gcode_file)[0], time.strftime("%Y%m%d%H%M%S"))
def stop_timelapse(self, doCreateMovie=True, success=True):
def stop_timelapse(self, do_create_movie=True, success=True):
self._logger.debug("Stopping timelapse")
self._in_timelapse = False
def resetImageNumber():
def reset_image_number():
self._image_number = None
def createMovie():
self._render_thread = threading.Thread(target=self._create_movie, kwargs={"success": success})
self._render_thread.daemon = True
self._render_thread.start()
def create_movie():
render_unrendered_timelapse(self._file_prefix,
gcode=self._gcode_file,
postfix=None if success else "-fail",
fps=self._fps)
def resetAndCreate():
resetImageNumber()
createMovie()
def reset_and_create():
reset_image_number()
create_movie()
def waitForCaptures(callback):
def wait_for_captures(callback):
self._capture_queue.put(dict(type=self.__class__.QUEUE_ENTRY_TYPE_CALLBACK, callback=callback))
def getWaitForCaptures(callback):
def create_wait_for_captures(callback):
def f():
waitForCaptures(callback)
wait_for_captures(callback)
return f
if self._post_roll > 0:
eventManager().fire(Events.POSTROLL_START, dict(postroll_duration=self.calculate_post_roll(), postroll_length=self.post_roll, postroll_fps=self.fps))
eventManager().fire(Events.POSTROLL_START,
dict(postroll_duration=self.calculate_post_roll(),
postroll_length=self.post_roll,
postroll_fps=self.fps))
self._post_roll_start = time.time()
if doCreateMovie:
self._on_post_roll_done = getWaitForCaptures(resetAndCreate)
if do_create_movie:
self._on_post_roll_done = create_wait_for_captures(reset_and_create)
else:
self._on_post_roll_done = resetImageNumber
self._on_post_roll_done = reset_image_number
self.process_post_roll()
else:
self._post_roll_start = None
if doCreateMovie:
waitForCaptures(resetAndCreate)
if do_create_movie:
wait_for_captures(reset_and_create)
else:
resetImageNumber()
reset_image_number()
def calculate_post_roll(self):
return None
@ -259,7 +393,7 @@ class Timelapse(object):
if self._on_post_roll_done is not None:
self._on_post_roll_done()
def captureImage(self):
def capture_image(self):
if self._capture_dir is None:
self._logger.warn("Cannot capture image, capture directory is unset")
return
@ -269,10 +403,10 @@ class Timelapse(object):
self._logger.warn("Cannot capture image, image number is unset")
return
filename = os.path.join(self._capture_dir, "tmp_%05d.jpg" % self._image_number)
filename = os.path.join(self._capture_dir, _capture_format.format(prefix=self._file_prefix) % self._image_number)
self._image_number += 1
self._logger.debug("Capturing image to %s" % filename)
self._logger.debug("Capturing image to {}".format(filename))
entry = dict(type=self.__class__.QUEUE_ENTRY_TYPE_CAPTURE,
filename=filename,
onerror=self._on_capture_error)
@ -299,108 +433,32 @@ class Timelapse(object):
entry["callback"](*args, **kwargs)
def _perform_capture(self, filename, onerror=None):
eventManager().fire(Events.CAPTURE_START, {"file": filename})
eventManager().fire(Events.CAPTURE_START, dict(file=filename))
try:
self._logger.debug("Going to capture %s from %s" % (filename, self._snapshot_url))
self._logger.debug("Going to capture {} from {}".format(filename, self._snapshot_url))
r = requests.get(self._snapshot_url, stream=True)
with open (filename, "wb") as f:
for chunk in r.iter_content(chunk_size=1024):
if chunk:
f.write(chunk)
f.flush()
self._logger.debug("Image %s captured from %s" % (filename, self._snapshot_url))
self._logger.debug("Image {} captured from {}".format(filename, self._snapshot_url))
except:
self._logger.exception("Could not capture image %s from %s" % (filename, self._snapshot_url))
self._logger.exception("Could not capture image {} from {}".format(filename, self._snapshot_url))
if callable(onerror):
onerror()
eventManager().fire(Events.CAPTURE_FAILED, {"file": filename})
eventManager().fire(Events.CAPTURE_FAILED, dict(file=filename))
return False
else:
eventManager().fire(Events.CAPTURE_DONE, {"file": filename})
eventManager().fire(Events.CAPTURE_DONE, dict(file=filename))
return True
def _create_movie(self, success=True):
ffmpeg = settings().get(["webcam", "ffmpeg"])
bitrate = settings().get(["webcam", "bitrate"])
if ffmpeg is None or bitrate is None:
self._logger.warn("Cannot create movie, path to ffmpeg or desired bitrate is unset")
return
input = os.path.join(self._capture_dir, "tmp_%05d.jpg")
if success:
output = os.path.join(self._movie_dir, "%s_%s.mpg" % (os.path.splitext(self._gcode_file)[0], time.strftime("%Y%m%d%H%M%S")))
else:
output = os.path.join(self._movie_dir, "%s_%s-failed.mpg" % (os.path.splitext(self._gcode_file)[0], time.strftime("%Y%m%d%H%M%S")))
# prepare ffmpeg command
command = [
ffmpeg, '-framerate', str(self._fps), '-loglevel', 'error', '-i', input, '-vcodec', 'mpeg2video', '-threads', str(self._ffmpeg_threads), '-pix_fmt', 'yuv420p', '-r', str(self._fps), '-y', '-b', bitrate,
'-f', 'vob']
filters = []
# flip video if configured
if settings().getBoolean(["webcam", "flipH"]):
filters.append('hflip')
if settings().getBoolean(["webcam", "flipV"]):
filters.append('vflip')
if settings().getBoolean(["webcam", "rotate90"]):
filters.append('transpose=2')
# add watermark if configured
watermarkFilter = None
if settings().getBoolean(["webcam", "watermark"]):
watermark = os.path.join(os.path.dirname(__file__), "static", "img", "watermark.png")
if sys.platform == "win32":
# Because ffmpeg hiccups on windows' drive letters and backslashes we have to give the watermark
# path a special treatment. Yeah, I couldn't believe it either...
watermark = watermark.replace("\\", "/").replace(":", "\\\\:")
watermarkFilter = "movie=%s [wm]; [%%(inputName)s][wm] overlay=10:main_h-overlay_h-10" % watermark
filterstring = None
if len(filters) > 0:
if watermarkFilter is not None:
filterstring = "[in] %s [postprocessed]; %s [out]" % (",".join(filters), watermarkFilter % {"inputName": "postprocessed"})
else:
filterstring = "[in] %s [out]" % ",".join(filters)
elif watermarkFilter is not None:
filterstring = watermarkFilter % {"inputName": "in"} + " [out]"
if filterstring is not None:
self._logger.debug("Applying videofilter chain: %s" % filterstring)
command.extend(["-vf", sarge.shell_quote(filterstring)])
# finalize command with output file
self._logger.debug("Rendering movie to %s" % output)
command.append("\"" + output + "\"")
eventManager().fire(Events.MOVIE_RENDERING, {"gcode": self._gcode_file, "movie": output, "movie_basename": os.path.basename(output)})
command_str = " ".join(command)
self._logger.debug("Executing command: %s" % command_str)
try:
p = sarge.run(command_str, stderr=sarge.Capture())
if p.returncode == 0:
eventManager().fire(Events.MOVIE_DONE, {"gcode": self._gcode_file, "movie": output, "movie_basename": os.path.basename(output)})
else:
returncode = p.returncode
stderr_text = p.stderr.text
self._logger.warn("Could not render movie, got return code %r: %s" % (returncode, stderr_text))
eventManager().fire(Events.MOVIE_FAILED, {"gcode": self._gcode_file, "movie": output, "movie_basename": os.path.basename(output), "returncode": returncode, "error": stderr_text})
except:
self._logger.exception("Could not render movie due to unknown error")
eventManager().fire(Events.MOVIE_FAILED, {"gcode": self._gcode_file, "movie": output, "movie_basename": os.path.basename(output), "returncode": 255, "error": "Unknown error"})
def clean_capture_dir(self):
if not os.path.isdir(self._capture_dir):
self._logger.warn("Cannot clean capture directory, it is unset")
return
delete_unrendered_timelapse(self._file_prefix)
for filename in os.listdir(self._capture_dir):
if not fnmatch.fnmatch(filename, "*.jpg"):
continue
os.remove(os.path.join(self._capture_dir, filename))
class ZTimelapse(Timelapse):
@ -432,7 +490,7 @@ class ZTimelapse(Timelapse):
Timelapse.process_post_roll(self)
def _on_z_change(self, event, payload):
self.captureImage()
self.capture_image()
class TimedTimelapse(Timelapse):
@ -487,9 +545,210 @@ class TimedTimelapse(Timelapse):
return self._in_timelapse or self._postroll_captures > 0
def _timer_task(self):
self.captureImage()
self.capture_image()
if self._postroll_captures > 0:
self._postroll_captures -= 1
def _on_timer_finished(self):
self.post_roll_finished()
class TimelapseRenderJob(object):
render_job_lock = threading.RLock()
def __init__(self, capture_dir, output_dir, prefix, postfix=None, capture_format="{prefix}-%d.jpg",
output_format="{prefix}{postfix}.mpg", fps=25, threads=1, on_start=None, on_success=None,
on_fail=None, on_always=None):
self._capture_dir = capture_dir
self._output_dir = output_dir
self._prefix = prefix
self._postfix = postfix
self._capture_format = capture_format
self._output_format = output_format
self._fps = fps
self._threads = threads
self._on_start = on_start
self._on_success = on_success
self._on_fail = on_fail
self._on_always = on_always
self._thread = None
self._logger = logging.getLogger(__name__)
def process(self):
"""Processes the job."""
self._thread = threading.Thread(target=self._render,
name="TimelapseRenderJob_{prefix}_{postfix}".format(prefix=self._prefix,
postfix=self._postfix))
self._thread.daemon = True
self._thread.start()
def _render(self):
"""Rendering runnable."""
ffmpeg = settings().get(["webcam", "ffmpeg"])
bitrate = settings().get(["webcam", "bitrate"])
if ffmpeg is None or bitrate is None:
self._logger.warn("Cannot create movie, path to ffmpeg or desired bitrate is unset")
return
input = os.path.join(self._capture_dir,
self._capture_format.format(prefix=self._prefix,
postfix=self._postfix if self._postfix is not None else ""))
output = os.path.join(self._output_dir,
self._output_format.format(prefix=self._prefix,
postfix=self._postfix if self._postfix is not None else ""))
hflip = settings().getBoolean(["webcam", "flipH"])
vflip = settings().getBoolean(["webcam", "flipV"])
rotate = settings().getBoolean(["webcam", "rotate90"])
watermark = None
if settings().getBoolean(["webcam", "watermark"]):
watermark = os.path.join(os.path.dirname(__file__), "static", "img", "watermark.png")
if sys.platform == "win32":
# Because ffmpeg hiccups on windows' drive letters and backslashes we have to give the watermark
# path a special treatment. Yeah, I couldn't believe it either...
watermark = watermark.replace("\\", "/").replace(":", "\\\\:")
# prepare ffmpeg command
command_str = self._create_ffmpeg_command_string(ffmpeg, self._fps, bitrate, self._threads, input, output,
hflip=hflip, vflip=vflip, rotate=rotate, watermark=watermark)
self._logger.debug("Executing command: {}".format(command_str))
with self.render_job_lock:
try:
self._notify_callback("start", output)
p = sarge.run(command_str, stdout=sarge.Capture(), stderr=sarge.Capture())
if p.returncode == 0:
self._notify_callback("success", output)
else:
returncode = p.returncode
stdout_text = p.stdout.text
stderr_text = p.stderr.text
self._logger.warn("Could not render movie, got return code %r: %s" % (returncode, stderr_text))
self._notify_callback("fail", output, returncode=returncode, stdout=stdout_text, stderr=stderr_text)
except:
self._logger.exception("Could not render movie due to unknown error")
self._notify_callback("fail", output)
finally:
self._notify_callback("always", output)
@classmethod
def _create_ffmpeg_command_string(cls, ffmpeg, fps, bitrate, threads, input, output, hflip=False, vflip=False,
rotate=False, watermark=None):
"""
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
bitrate (str): Bitrate of output
threads (int): Number of threads to use for rendering
input (str): Absolute path to input files including file mask
output (str): Absolute path to output file
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.
Returns:
(str): Prepared command string to render `input` to `output` using ffmpeg.
"""
logger = logging.getLogger(__name__)
command = [
ffmpeg, '-framerate', str(fps), '-loglevel', 'error', '-i', '"{}"'.format(input), '-vcodec', 'mpeg2video',
'-threads', str(threads), '-pix_fmt', 'yuv420p', '-r', str(fps), '-y', '-b', str(bitrate),
'-f', 'vob']
filter_string = cls._create_filter_string(hflip=hflip,
vflip=vflip,
rotate=rotate,
watermark=watermark)
if filter_string is not None:
logger.debug("Applying videofilter chain: {}".format(filter_string))
command.extend(["-vf", sarge.shell_quote(filter_string)])
# finalize command with output file
logger.debug("Rendering movie to {}".format(output))
command.append('"{}"'.format(output))
return " ".join(command)
@classmethod
def _create_filter_string(cls, hflip=False, vflip=False, rotate=False, watermark=None):
"""
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.
Returns:
(str or None): filter string or None if no filters are required
"""
filters = []
# flip video if configured
if hflip:
filters.append('hflip')
if vflip:
filters.append('vflip')
if rotate:
filters.append('transpose=2')
# add watermark if configured
watermark_filter = None
if watermark is not None:
watermark_filter = "movie={} [wm]; [{{input_name}}][wm] overlay=10:main_h-overlay_h-10".format(watermark)
filter_string = None
if len(filters) > 0:
if watermark_filter is not None:
filter_string = "[in] {} [postprocessed]; {} [out]".format(",".join(filters),
watermark_filter.format(input_name="postprocessed"))
else:
filter_string = "[in] {} [out]".format(",".join(filters))
elif watermark_filter is not None:
filter_string = watermark_filter.format(input_name="in") + " [out]"
return filter_string
def _notify_callback(self, callback, *args, **kwargs):
"""Notifies registered callbacks of type `callback`."""
name = "_on_{}".format(callback)
method = getattr(self, name, None)
if method is not None and callable(method):
method(*args, **kwargs)