From 4f5dc708283ac9703c617de81cea3bc93e3abf1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Mon, 20 Jul 2015 16:42:28 +0200 Subject: [PATCH] Refactored timelapse core Capturing is now queue based, rendering will not start until all images have been captured, and timed postroll does not depend on system time anymore. Also refactored some of the names to be python naming compliant while at it. --- src/octoprint/events.py | 3 + src/octoprint/server/api/timelapse.py | 10 +- src/octoprint/static/js/app/dataupdater.js | 19 +- src/octoprint/timelapse.py | 332 ++++++++++++--------- 4 files changed, 225 insertions(+), 139 deletions(-) diff --git a/src/octoprint/events.py b/src/octoprint/events.py index 82886233..2cc765d9 100644 --- a/src/octoprint/events.py +++ b/src/octoprint/events.py @@ -74,6 +74,9 @@ class Events(object): # Timelapse CAPTURE_START = "CaptureStart" CAPTURE_DONE = "CaptureDone" + CAPTURE_FAILED = "CaptureFailed" + POSTROLL_START = "PostRollStart" + POSTROLL_END = "PostRollEnd" MOVIE_RENDERING = "MovieRendering" MOVIE_DONE = "MovieDone" MOVIE_FAILED = "MovieFailed" diff --git a/src/octoprint/server/api/timelapse.py b/src/octoprint/server/api/timelapse.py index bef85536..63b5be34 100644 --- a/src/octoprint/server/api/timelapse.py +++ b/src/octoprint/server/api/timelapse.py @@ -29,14 +29,14 @@ def getTimelapseData(): config = {"type": "off"} if timelapse is not None and isinstance(timelapse, octoprint.timelapse.ZTimelapse): config["type"] = "zchange" - config["postRoll"] = timelapse.postRoll() - config["fps"] = timelapse.fps() + config["postRoll"] = timelapse.post_roll + config["fps"] = timelapse.fps elif timelapse is not None and isinstance(timelapse, octoprint.timelapse.TimedTimelapse): config["type"] = "timed" - config["postRoll"] = timelapse.postRoll() - config["fps"] = timelapse.fps() + config["postRoll"] = timelapse.post_roll + config["fps"] = timelapse.fps config.update({ - "interval": timelapse.interval() + "interval": timelapse.interval }) files = octoprint.timelapse.getFinishedTimelapses() diff --git a/src/octoprint/static/js/app/dataupdater.js b/src/octoprint/static/js/app/dataupdater.js index 788edc86..169786ea 100644 --- a/src/octoprint/static/js/app/dataupdater.js +++ b/src/octoprint/static/js/app/dataupdater.js @@ -176,6 +176,7 @@ function DataUpdater(allViewModels) { var type = data["type"]; var payload = data["payload"]; var html = ""; + var format = {}; log.debug("Got event " + type + " with payload: " + JSON.stringify(payload)); @@ -186,7 +187,23 @@ function DataUpdater(allViewModels) { } else if (type == "MovieFailed") { html = "

" + _.sprintf(gettext("Rendering of timelapse %(movie_basename)s failed with return code %(returncode)s"), payload) + "

"; html += pnotifyAdditionalInfo('
' + payload.error + '
'); - new PNotify({title: gettext("Rendering failed"), text: html, type: "error", hide: false}); + new PNotify({ + title: gettext("Rendering failed"), + text: html, + type: "error", + hide: false + }); + } else if (type == "PostRollStart") { + if (payload.postroll_duration > 60) { + format = {duration: _.sprintf(gettext("%(minutes)d min"), {minutes: payload.postroll_duration / 60})}; + } else { + format = {duration: _.sprintf(gettext("%(seconds)d sec"), {seconds: payload.postroll_duration})}; + } + + new PNotify({ + title: gettext("Capturing timelapse postroll"), + text: _.sprintf(gettext("Now capturing timelapse post roll, this will take approximately %(duration)s..."), format) + }); } else if (type == "SlicingStarted") { gcodeUploadProgress.addClass("progress-striped").addClass("active"); gcodeUploadProgressBar.css("width", "100%"); diff --git a/src/octoprint/timelapse.py b/src/octoprint/timelapse.py index e28ec2ff..51e50c58 100644 --- a/src/octoprint/timelapse.py +++ b/src/octoprint/timelapse.py @@ -6,13 +6,13 @@ __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agp import logging import os import threading -import urllib import time -import subprocess import fnmatch import datetime import sys import shutil +import Queue +import requests import octoprint.util as util @@ -58,7 +58,7 @@ def notifyCallbacks(timelapse): if timelapse is None: config = None else: - config = timelapse.configData() + config = timelapse.config_data() for callback in updateCallbacks: try: callback.sendTimelapseConfig(config) except: logging.getLogger(__name__).exception("Exception while pushing timelapse configuration") @@ -86,12 +86,12 @@ def configureTimelapse(config=None, persist=False): if type is None or "off" == type: current = None elif "zchange" == type: - current = ZTimelapse(postRoll=postRoll, fps=fps) + current = ZTimelapse(post_roll=postRoll, fps=fps) elif "timed" == type: interval = 10 if "options" in config and "interval" in config["options"] and config["options"]["interval"] > 0: interval = config["options"]["interval"] - current = TimedTimelapse(postRoll=postRoll, interval=interval, fps=fps) + current = TimedTimelapse(post_roll=postRoll, interval=interval, fps=fps) notifyCallbacks(current) @@ -101,72 +101,83 @@ def configureTimelapse(config=None, persist=False): class Timelapse(object): - def __init__(self, postRoll=0, fps=25): + QUEUE_ENTRY_TYPE_CAPTURE = "capture" + QUEUE_ENTRY_TYPE_CALLBACK = "callback" + + def __init__(self, post_roll=0, fps=25): self._logger = logging.getLogger(__name__) - self._imageNumber = None - self._inTimelapse = False - self._gcodeFile = None + self._image_number = None + self._in_timelapse = False + self._gcode_file = None - self._postRoll = postRoll - self._postRollStart = None - self._onPostRollDone = None + self._post_roll = post_roll + self._on_post_roll_done = None - self._captureDir = settings().getBaseFolder("timelapse_tmp") - self._movieDir = settings().getBaseFolder("timelapse") - self._snapshotUrl = settings().get(["webcam", "snapshot"]) - self._ffmpegThreads = settings().get(["webcam", "ffmpegThreads"]) + 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._renderThread = None - self._captureMutex = threading.Lock() + self._render_thread = None + + self._capture_mutex = threading.Lock() + self._capture_queue = Queue.Queue() + self._capture_queue_active = True + + self._capture_queue_thread = threading.Thread(target=self._capture_queue_worker) + self._capture_queue_thread.daemon = True + self._capture_queue_thread.start() # subscribe events - eventManager().subscribe(Events.PRINT_STARTED, self.onPrintStarted) - eventManager().subscribe(Events.PRINT_FAILED, self.onPrintDone) - eventManager().subscribe(Events.PRINT_DONE, self.onPrintDone) - eventManager().subscribe(Events.PRINT_RESUMED, self.onPrintResumed) - for (event, callback) in self.eventSubscriptions(): + eventManager().subscribe(Events.PRINT_STARTED, self.on_print_started) + eventManager().subscribe(Events.PRINT_FAILED, self.on_print_done) + eventManager().subscribe(Events.PRINT_DONE, self.on_print_done) + eventManager().subscribe(Events.PRINT_RESUMED, self.on_print_resumed) + for (event, callback) in self.event_subscriptions(): eventManager().subscribe(event, callback) - def postRoll(self): - return self._postRoll + @property + def post_roll(self): + return self._post_roll + @property def fps(self): return self._fps def unload(self): - if self._inTimelapse: - self.stopTimelapse(doCreateMovie=False) + if self._in_timelapse: + self.stop_timelapse(doCreateMovie=False) # unsubscribe events - eventManager().unsubscribe(Events.PRINT_STARTED, self.onPrintStarted) - eventManager().unsubscribe(Events.PRINT_FAILED, self.onPrintDone) - eventManager().unsubscribe(Events.PRINT_DONE, self.onPrintDone) - eventManager().unsubscribe(Events.PRINT_RESUMED, self.onPrintResumed) - for (event, callback) in self.eventSubscriptions(): + eventManager().unsubscribe(Events.PRINT_STARTED, self.on_print_started) + eventManager().unsubscribe(Events.PRINT_FAILED, self.on_print_done) + eventManager().unsubscribe(Events.PRINT_DONE, self.on_print_done) + eventManager().unsubscribe(Events.PRINT_RESUMED, self.on_print_resumed) + for (event, callback) in self.event_subscriptions(): eventManager().unsubscribe(event, callback) - def onPrintStarted(self, event, payload): + def on_print_started(self, event, payload): """ Override this to perform additional actions upon start of a print job. """ - self.startTimelapse(payload["file"]) + self.start_timelapse(payload["file"]) - def onPrintDone(self, event, payload): + def on_print_done(self, event, payload): """ Override this to perform additional actions upon the stop of a print job. """ - self.stopTimelapse(success=(event==Events.PRINT_DONE)) + self.stop_timelapse(success=(event==Events.PRINT_DONE)) - def onPrintResumed(self, event, payload): + def on_print_resumed(self, event, payload): """ Override this to perform additional actions upon the pausing of a print job. """ - if not self._inTimelapse: - self.startTimelapse(payload["file"]) + if not self._in_timelapse: + self.start_timelapse(payload["file"]) - def eventSubscriptions(self): + def event_subscriptions(self): """ Override this method to subscribe to additional events by returning an array of (event, callback) tuples. @@ -178,7 +189,7 @@ class Timelapse(object): """ return [] - def configData(self): + def config_data(self): """ Override this method to return the current timelapse configuration data. The data should have the following form: @@ -188,94 +199,139 @@ class Timelapse(object): """ return None - def startTimelapse(self, gcodeFile): + def start_timelapse(self, gcodeFile): self._logger.debug("Starting timelapse for %s" % gcodeFile) - self.cleanCaptureDir() + self.clean_capture_dir() - self._imageNumber = 0 - self._inTimelapse = True - self._gcodeFile = os.path.basename(gcodeFile) + self._image_number = 0 + self._in_timelapse = True + self._gcode_file = os.path.basename(gcodeFile) - def stopTimelapse(self, doCreateMovie=True, success=True): + def stop_timelapse(self, doCreateMovie=True, success=True): self._logger.debug("Stopping timelapse") - self._inTimelapse = False + self._in_timelapse = False def resetImageNumber(): - self._imageNumber = None + self._image_number = None def createMovie(): - self._renderThread = threading.Thread(target=self._createMovie, kwargs={"success": success}) - self._renderThread.daemon = True - self._renderThread.start() + self._render_thread = threading.Thread(target=self._create_movie, kwargs={"success": success}) + self._render_thread.daemon = True + self._render_thread.start() def resetAndCreate(): resetImageNumber() createMovie() - if self._postRoll > 0: - self._postRollStart = time.time() + def waitForCaptures(callback): + self._capture_queue.put(dict(type=self.__class__.QUEUE_ENTRY_TYPE_CALLBACK, callback=callback)) + + def getWaitForCaptures(callback): + def f(): + waitForCaptures(callback) + return f + + if self._post_roll > 0: + eventManager().fire(Events.POSTROLL_START, dict(postroll_duration=self.post_roll * self.fps, postroll_length=self.post_roll, postroll_fps=self.fps)) + self._post_roll_start = time.time() if doCreateMovie: - self._onPostRollDone = resetAndCreate + self._on_post_roll_done = getWaitForCaptures(resetAndCreate) else: - self._onPostRollDone = resetImageNumber - self.processPostRoll() + self._on_post_roll_done = resetImageNumber + self.process_post_roll() else: - self._postRollStart = None + self._post_roll_start = None if doCreateMovie: - resetAndCreate() + waitForCaptures(resetAndCreate) else: resetImageNumber() - def processPostRoll(self): - pass + def process_post_roll(self): + self.post_roll_finished() + + def post_roll_finished(self): + if self.post_roll: + eventManager().fire(Events.POSTROLL_END) + if self._on_post_roll_done is not None: + self._on_post_roll_done() def captureImage(self): - if self._captureDir is None: + if self._capture_dir is None: self._logger.warn("Cannot capture image, capture directory is unset") return - if self._imageNumber is None: - self._logger.warn("Cannot capture image, image number is unset") - return + with self._capture_mutex: + if self._image_number is None: + self._logger.warn("Cannot capture image, image number is unset") + return + + filename = os.path.join(self._capture_dir, "tmp_%05d.jpg" % self._image_number) + self._image_number += 1 - with self._captureMutex: - filename = os.path.join(self._captureDir, "tmp_%05d.jpg" % self._imageNumber) - self._imageNumber += 1 self._logger.debug("Capturing image to %s" % filename) - captureThread = threading.Thread(target=self._captureWorker, kwargs={"filename": filename}) - captureThread.daemon = True - captureThread.start() + entry = dict(type=self.__class__.QUEUE_ENTRY_TYPE_CAPTURE, + filename=filename, + onerror=self._on_capture_error) + self._capture_queue.put(entry) return filename - def _captureWorker(self, filename): + def _on_capture_error(self): + with self._capture_mutex: + if self._image_number is not None and self._image_number > 0: + self._image_number -= 1 + + def _capture_queue_worker(self): + while self._capture_queue_active: + entry = self._capture_queue.get(block=True) + + if entry["type"] == self.__class__.QUEUE_ENTRY_TYPE_CAPTURE and "filename" in entry: + filename = entry["filename"] + onerror = entry.pop("onerror", None) + self._perform_capture(filename, onerror=onerror) + + elif entry["type"] == self.__class__.QUEUE_ENTRY_TYPE_CALLBACK and "callback" in entry: + args = entry.pop("args", []) + kwargs = entry.pop("kwargs", dict()) + entry["callback"](*args, **kwargs) + + def _perform_capture(self, filename, onerror=None): eventManager().fire(Events.CAPTURE_START, {"file": filename}) try: - urllib.urlretrieve(self._snapshotUrl, filename) - self._logger.debug("Image %s captured from %s" % (filename, self._snapshotUrl)) + self._logger.debug("Going to capture %s from %s" % (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)) except: - self._logger.exception("Could not capture image %s from %s, decreasing image counter again" % (filename, self._snapshotUrl)) - with self._captureMutex: - if self._imageNumber is not None and self._imageNumber > 0: - self._imageNumber -= 1 - eventManager().fire(Events.CAPTURE_DONE, {"file": filename}) + self._logger.exception("Could not capture image %s from %s" % (filename, self._snapshot_url)) + if callable(onerror): + onerror() + eventManager().fire(Events.CAPTURE_FAILED, {"file": filename}) + return False + else: + eventManager().fire(Events.CAPTURE_DONE, {"file": filename}) + return True - def _createMovie(self, success=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._captureDir, "tmp_%05d.jpg") + input = os.path.join(self._capture_dir, "tmp_%05d.jpg") if success: - output = os.path.join(self._movieDir, "%s_%s.mpg" % (os.path.splitext(self._gcodeFile)[0], time.strftime("%Y%m%d%H%M%S"))) + 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._movieDir, "%s_%s-failed.mpg" % (os.path.splitext(self._gcodeFile)[0], time.strftime("%Y%m%d%H%M%S"))) + 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._ffmpegThreads), '-pix_fmt', 'yuv420p', '-r', str(self._fps), '-y', '-b', bitrate, + 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 = [] @@ -315,7 +371,7 @@ class Timelapse(object): # finalize command with output file self._logger.debug("Rendering movie to %s" % output) command.append("\"" + output + "\"") - eventManager().fire(Events.MOVIE_RENDERING, {"gcode": self._gcodeFile, "movie": output, "movie_basename": os.path.basename(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) @@ -323,75 +379,74 @@ class Timelapse(object): try: p = sarge.run(command_str, stderr=sarge.Capture()) if p.returncode == 0: - eventManager().fire(Events.MOVIE_DONE, {"gcode": self._gcodeFile, "movie": output, "movie_basename": os.path.basename(output)}) + 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._gcodeFile, "movie": output, "movie_basename": os.path.basename(output), "returncode": returncode, "error": 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._gcodeFile, "movie": output, "movie_basename": os.path.basename(output), "returncode": 255, "error": "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 cleanCaptureDir(self): - if not os.path.isdir(self._captureDir): + def clean_capture_dir(self): + if not os.path.isdir(self._capture_dir): self._logger.warn("Cannot clean capture directory, it is unset") return - for filename in os.listdir(self._captureDir): + for filename in os.listdir(self._capture_dir): if not fnmatch.fnmatch(filename, "*.jpg"): continue - os.remove(os.path.join(self._captureDir, filename)) + os.remove(os.path.join(self._capture_dir, filename)) class ZTimelapse(Timelapse): - def __init__(self, postRoll=0, fps=25): - Timelapse.__init__(self, postRoll=postRoll, fps=fps) + def __init__(self, post_roll=0, fps=25): + Timelapse.__init__(self, post_roll=post_roll, fps=fps) self._logger.debug("ZTimelapse initialized") - def eventSubscriptions(self): + def event_subscriptions(self): return [ - (Events.Z_CHANGE, self._onZChange) + (Events.Z_CHANGE, self._on_z_change) ] - def configData(self): + def config_data(self): return { "type": "zchange" } - def processPostRoll(self): - Timelapse.processPostRoll(self) + def process_post_roll(self): + with self._capture_mutex: + filename = os.path.join(self._capture_dir, "tmp_%05d.jpg" % self._image_number) + self._image_number += 1 - filename = os.path.join(self._captureDir, "tmp_%05d.jpg" % self._imageNumber) - self._imageNumber += 1 - with self._captureMutex: - self._captureWorker(filename) + if self._perform_capture(filename): + for _ in range(self._post_roll * self._fps): + newFile = os.path.join(self._capture_dir, "tmp_%05d.jpg" % self._image_number) + self._image_number += 1 + shutil.copyfile(filename, newFile) - for i in range(self._postRoll * self._fps): - newFile = os.path.join(self._captureDir, "tmp_%05d.jpg" % (self._imageNumber)) - self._imageNumber += 1 - shutil.copyfile(filename, newFile) + Timelapse.process_post_roll(self) - if self._onPostRollDone is not None: - self._onPostRollDone() - - def _onZChange(self, event, payload): + def _on_z_change(self, event, payload): self.captureImage() class TimedTimelapse(Timelapse): - def __init__(self, postRoll=0, interval=1, fps=25): - Timelapse.__init__(self, postRoll=postRoll, fps=fps) + def __init__(self, post_roll=0, interval=1, fps=25): + Timelapse.__init__(self, post_roll=post_roll, fps=fps) self._interval = interval if self._interval < 1: self._interval = 1 # force minimum interval of 1s - self._timerThread = None + self._postroll_captures = 0 + self._timer = None self._logger.debug("TimedTimelapse initialized") + @property def interval(self): return self._interval - def configData(self): + def config_data(self): return { "type": "timed", "options": { @@ -399,25 +454,36 @@ class TimedTimelapse(Timelapse): } } - def onPrintStarted(self, event, payload): - Timelapse.onPrintStarted(self, event, payload) - if self._timerThread is not None: + def on_print_started(self, event, payload): + Timelapse.on_print_started(self, event, payload) + if self._timer is not None: return - self._timerThread = threading.Thread(target=self._timerWorker) - self._timerThread.daemon = True - self._timerThread.start() - - def onPrintDone(self, event, payload): - Timelapse.onPrintDone(self, event, payload) - self._timerThread = None - - def _timerWorker(self): self._logger.debug("Starting timer for interval based timelapse") - while self._inTimelapse or (self._postRollStart and time.time() - self._postRollStart <= self._postRoll * self._fps): - self.captureImage() - time.sleep(self._interval) + from octoprint.util import RepeatedTimer + self._timer = RepeatedTimer(self._interval, self._timer_task, + run_first=True, condition=self._timer_active, + on_finish=self._on_timer_finished) + self._timer.start() - if self._postRollStart is not None and self._onPostRollDone is not None: - self._onPostRollDone() - self._postRollStart = None + def on_print_done(self, event, payload): + self._postroll_captures = self.post_roll * self.fps + Timelapse.on_print_done(self, event, payload) + + def process_post_roll(self): + pass + + def post_roll_finished(self): + Timelapse.post_roll_finished(self) + self._timer = None + + def _timer_active(self): + return self._in_timelapse or self._postroll_captures > 0 + + def _timer_task(self): + self.captureImage() + if self._postroll_captures > 0: + self._postroll_captures -= 1 + + def _on_timer_finished(self): + self.post_roll_finished()