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:
parent
6ce82ceb00
commit
4e31ccf4c5
10 changed files with 620 additions and 158 deletions
|
|
@ -283,7 +283,7 @@ class Server():
|
|||
self._setup_assets()
|
||||
|
||||
# configure timelapse
|
||||
octoprint.timelapse.configureTimelapse()
|
||||
octoprint.timelapse.configure_timelapse()
|
||||
|
||||
# setup command triggers
|
||||
events.CommandTrigger(printer)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> | <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> | <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> | <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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue