diff --git a/docs/events/index.rst b/docs/events/index.rst index d4f0328f..0b0139c9 100644 --- a/docs/events/index.rst +++ b/docs/events/index.rst @@ -388,18 +388,25 @@ Timelapses ---------- CaptureStart - A timelapse image has started to be captured. + A timelapse frame has started to be captured. Payload: * ``file``: the name of the image file to be saved CaptureDone - A timelapse image has completed being captured. + A timelapse frame has completed being captured. Payload: * ``file``: the name of the image file that was saved +CaptureFailed + A timelapse frame could not be captured. + + Payload: + * ``file``: the name of the image file that should have been saved + * ``error``: the error that was caught + MovieRendering The timelapse movie has started rendering. @@ -427,6 +434,9 @@ MovieFailed * ``movie``: the movie file that would have been created (full path) * ``movie_basename``: the movie file that would have been created (only the file name without the path) * ``returncode``: the return code of ``ffmpeg`` that indicates the error that occurred + * ``reason``: additional machine processable reason string - can be ``returncode`` if ffmpeg + returned a non-0 return code, ``no_frames`` if no frames were captured that could be rendered + to a timelapse, or ``unknown`` for any other reason of failure to render. Slicing ------- diff --git a/src/octoprint/static/js/app/viewmodels/timelapse.js b/src/octoprint/static/js/app/viewmodels/timelapse.js index fb1c3604..a7230d89 100644 --- a/src/octoprint/static/js/app/viewmodels/timelapse.js +++ b/src/octoprint/static/js/app/viewmodels/timelapse.js @@ -273,6 +273,35 @@ $(function() { }); }; + // 3 consecutive capture fails trigger error popup + self._warnAboutCaptureFailThreshold = 3; + self._warnAboutCaptureFailCounter = 0; + self._warnedAboutCaptureFail = false; + self.onEventPrintStarted = function(payload) { + self._warnAboutCaptureFailCounter = 0; + self._warnedAboutCaptureFail = false; + }; + self.onEventCaptureDone = function(payload) { + self._warnAboutCaptureFailCounter = 0; + self._warnedAboutCaptureFail = false; + }; + self.onEventCaptureFailed = function(payload) { + self._warnAboutCaptureFailCounter++; + if (self._warnedAboutCaptureFail || self._warnAboutCaptureFailCounter <= self._warnAboutCaptureFailThreshold) { + return; + } + self._warnedAboutCaptureFail = true; + + var html = "

" + gettext("Failed repeatedly to capture timelapse frame from webcam - is the snapshot URL configured correctly and the camera on?"); + html += pnotifyAdditionalInfo('Snapshot URL:

' + payload.url + '
Error:
' + payload.error + '
'); + new PNotify({ + title: gettext("Could not capture snapshots"), + text: html, + type: "error", + hide: false + }); + }; + self.onEventMovieRendering = function(payload) { self.displayTimelapsePopup({ title: gettext("Rendering timelapse"), @@ -282,11 +311,22 @@ $(function() { }; self.onEventMovieFailed = function(payload) { - var html = "

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

"; - html += pnotifyAdditionalInfo('
' + payload.error + '
'); + var title, html; + + if (payload.reason == "no_frames") { + title = gettext("Cannot render timelapse"); + html = "

" + _.sprintf(gettext("Rendering of timelapse %(movie_prefix)s is not possible since no frames were captured. Is the snapshot URL configured correctly?"), payload) + "

"; + } else if (payload.reason = "returncode") { + title = gettext("Rendering timelapse failed"); + html = "

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

"; + html += pnotifyAdditionalInfo('
' + payload.error + '
'); + } else { + title = gettext("Rendering timelapse failed"); + html = "

" + _.sprintf(gettext("Rendering of timelapse %(movie_prefix)s failed due to an unknown error, please consult the log file"), payload) + "

"; + } self.displayTimelapsePopup({ - title: gettext("Rendering failed"), + title: title, text: html, type: "error", hide: false diff --git a/src/octoprint/timelapse.py b/src/octoprint/timelapse.py index 1133fcdb..9e3c5e61 100644 --- a/src/octoprint/timelapse.py +++ b/src/octoprint/timelapse.py @@ -209,35 +209,37 @@ def _create_render_start_handler(name, gcode=None): with _job_lock: global current_render_job - event_payload = {"gcode": gcode if gcode is not None else "unknown", - "movie": movie, - "movie_basename": os.path.basename(movie), - "movie_prefix": name} + payload = dict(gcode=gcode if gcode is not None else "unknown", + movie=movie, + movie_basename=os.path.basename(movie), + movie_prefix=name) current_render_job = dict(prefix=name) - current_render_job.update(event_payload) - eventManager().fire(Events.MOVIE_RENDERING, event_payload) + current_render_job.update(payload) + eventManager().fire(Events.MOVIE_RENDERING, payload) return f def _create_render_success_handler(name, gcode=None): def f(movie): delete_unrendered_timelapse(name) - event_payload = {"gcode": gcode if gcode is not None else "unknown", - "movie": movie, - "movie_basename": os.path.basename(movie), - "movie_prefix": name} - eventManager().fire(Events.MOVIE_DONE, event_payload) + payload = dict(gcode=gcode if gcode is not None else "unknown", + movie=movie, + movie_basename=os.path.basename(movie), + movie_prefix=name) + eventManager().fire(Events.MOVIE_DONE, payload) 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), - "movie_prefix": name} - payload = dict(event_payload) - payload.update(dict(returncode=returncode, error=stderr)) + def f(movie, returncode=255, stdout="Unknown error", stderr="Unknown error", reason="unknown"): + payload = dict(gcode=gcode if gcode is not None else "unknown", + movie=movie, + movie_basename=os.path.basename(movie), + movie_prefix=name, + returncode=returncode, + out=stdout, + error=stderr, + reason=reason) eventManager().fire(Events.MOVIE_FAILED, payload) return f @@ -321,7 +323,11 @@ class Timelapse(object): self._gcode_file = None self._file_prefix = None + self._capture_errors = 0 + self._capture_success = 0 + self._post_roll = post_roll + self._post_roll_start = None self._on_post_roll_done = None self._capture_dir = settings().getBaseFolder("timelapse_tmp") @@ -415,6 +421,8 @@ class Timelapse(object): self._logger.debug("Starting timelapse for %s" % gcodeFile) self._image_number = 0 + self._capture_errors = 0 + self._capture_success = 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")) @@ -445,23 +453,45 @@ class Timelapse(object): 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)) - self._post_roll_start = time.time() - if do_create_movie: - self._on_post_roll_done = create_wait_for_captures(reset_and_create) + # wait for everything so far in the queue to be processed, then see if we should process from there + def continue_rendering(): + if self._capture_success == 0: + # no images - either nothing was attempted to be captured or all attempts ran into an error + if self._capture_errors > 0: + # this is the latter case + _create_render_fail_handler(self._file_prefix, + gcode=self._gcode_file)("n/a", + returncode=0, + stdout="", + stderr="", + reason="no_frames") + + # in any case, don't continue + return + + # check if we have post roll configured + if self._post_roll > 0: + # capture post roll, wait for THAT to finish, THEN render + 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 do_create_movie: + self._on_post_roll_done = create_wait_for_captures(reset_and_create) + else: + self._on_post_roll_done = reset_image_number + self.process_post_roll() else: - self._on_post_roll_done = reset_image_number - self.process_post_roll() - else: - self._post_roll_start = None - if do_create_movie: - wait_for_captures(reset_and_create) - else: - reset_image_number() + # no post roll? perfect, render + self._post_roll_start = None + if do_create_movie: + wait_for_captures(reset_and_create) + else: + reset_image_number() + + self._logger.debug("Waiting to process capture queue") + wait_for_captures(continue_rendering) def calculate_post_roll(self): return None @@ -519,20 +549,27 @@ class Timelapse(object): try: self._logger.debug("Going to capture {} from {}".format(filename, self._snapshot_url)) r = requests.get(self._snapshot_url, stream=True) + r.raise_for_status() + 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 {} captured from {}".format(filename, self._snapshot_url)) - except: + except Exception as e: self._logger.exception("Could not capture image {} from {}".format(filename, self._snapshot_url)) if callable(onerror): onerror() - eventManager().fire(Events.CAPTURE_FAILED, dict(file=filename)) + eventManager().fire(Events.CAPTURE_FAILED, dict(file=filename, + error=str(e), + url=self._snapshot_url)) + self._capture_errors += 1 return False else: eventManager().fire(Events.CAPTURE_DONE, dict(file=filename)) + self._capture_success += 1 return True def clean_capture_dir(self): @@ -654,13 +691,14 @@ 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): + def __init__(self, capture_dir, output_dir, prefix, postfix=None, capture_glob="{prefix}-*.jpg", + 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_glob = capture_glob self._capture_format = capture_format self._output_format = output_format self._fps = fps @@ -695,8 +733,16 @@ class TimelapseRenderJob(object): 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 "")) + self._output_format.format(prefix=self._prefix, + postfix=self._postfix if self._postfix is not None else "")) + + for i in range(4): + if os.path.exists(input % i): + break + else: + self._logger.warn("Cannot create a movie, no frames captured") + self._notify_callback("fail", output, returncode=0, stdout="", stderr="", reason="no_frames") + return hflip = settings().getBoolean(["webcam", "flipH"]) vflip = settings().getBoolean(["webcam", "flipV"]) @@ -726,10 +772,10 @@ class TimelapseRenderJob(object): 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) + self._notify_callback("fail", output, returncode=returncode, stdout=stdout_text, stderr=stderr_text, reason="returncode") except: self._logger.exception("Could not render movie due to unknown error") - self._notify_callback("fail", output) + self._notify_callback("fail", output, reason="unknown") finally: self._notify_callback("always", output)