Skip to content

Commit

Permalink
✨ Add timelapse thumbnails (#4370)
Browse files Browse the repository at this point in the history
* Generate thumbnails by taking the last frame

* Allow thumbnails to be downloaded

* Update thumbnail UI

* Clean up code

* Ensure thumbnails get deleted with the timelapse

* Clean up code

* Update CSS for timelpase thumbnails

* Add timestamp and show formatted date

* Update thumbnail size in UI
  • Loading branch information
crysxd committed Jan 24, 2022
1 parent 19072b4 commit cac56cf
Show file tree
Hide file tree
Showing 8 changed files with 3,104 additions and 11 deletions.
7 changes: 4 additions & 3 deletions src/octoprint/server/__init__.py
Expand Up @@ -754,9 +754,10 @@ def download_name_generator(path):
)
}

valid_timelapse = lambda path: not octoprint.util.is_hidden_path(
path
) and octoprint.timelapse.valid_timelapse(path)
valid_timelapse = lambda path: not octoprint.util.is_hidden_path(path) and (
octoprint.timelapse.valid_timelapse(path)
or octoprint.timelapse.valid_timelapse_thumbnail(path)
)
timelapse_path_validator = {
"path_validation": util.tornado.path_validation_factory(
valid_timelapse,
Expand Down
22 changes: 22 additions & 0 deletions src/octoprint/server/api/timelapse.py
Expand Up @@ -146,6 +146,13 @@ def getTimelapseData():
for f in files:
output = dict(f)
output["url"] = url_for("index") + "downloads/timelapse/" + urlquote(f["name"])
if output["thumbnail"] is not None:
output["thumbnail"] = (
url_for("index") + "downloads/timelapse/" + urlquote(f["thumbnail"])
)
else:
output.pop("thumbnail", None)

finished_list.append(output)

result = {
Expand Down Expand Up @@ -175,6 +182,7 @@ def downloadTimelapse(filename):
def deleteTimelapse(filename):
timelapse_folder = settings().getBaseFolder("timelapse")
full_path = os.path.realpath(os.path.join(timelapse_folder, filename))
thumb_path = octoprint.timelapse.create_thumbnail_path(full_path)
if (
octoprint.timelapse.valid_timelapse(full_path)
and full_path.startswith(timelapse_folder)
Expand All @@ -189,6 +197,20 @@ def deleteTimelapse(filename):
)
abort(500, description="Unexpected error: {}".format(ex))

if (
octoprint.timelapse.valid_timelapse_thumbnail(thumb_path)
and thumb_path.startswith(timelapse_folder)
and os.path.exists(thumb_path)
and not util.is_hidden_path(thumb_path)
):
try:
os.remove(thumb_path)
except Exception as ex:
# Do not treat this as an error, log and ignore
logging.getLogger(__file__).warning(
"Unable to delete thumbnail {} ({})".format(thumb_path, ex)
)

return getTimelapseData()


Expand Down
1 change: 1 addition & 0 deletions src/octoprint/settings.py
Expand Up @@ -263,6 +263,7 @@ def settings(init=False, basedir=None, configfile=None, overlays=None):
"flipV": False,
"rotate90": False,
"ffmpegCommandline": '{ffmpeg} -framerate {fps} -i "{input}" -vcodec {videocodec} -threads {threads} -b:v {bitrate} -f {containerformat} -y {filters} "{output}"',
"ffmpegThumbnailCommandline": '{ffmpeg} -sseof -1 -i "{input}" -update 1 -q:v 0.7 "{output}"',
"timelapse": {
"type": "off",
"options": {},
Expand Down
2,927 changes: 2,926 additions & 1 deletion src/octoprint/static/css/octoprint.css

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/octoprint/static/img/play.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 62 additions & 1 deletion src/octoprint/static/less/octoprint.less
Expand Up @@ -318,19 +318,73 @@ table {
&.timelapse_files_checkbox,
&.timelapse_unrendered_checkbox {
text-align: center;
vertical-align: middle;
width: 10px;

input[type="checkbox"] {
margin-top: 0;
}
}

&.timelapse_files_name,
&.timelapse_unrendered_name {
text-overflow: ellipsis;
text-align: left;
}

&.timelapse_files_details {
.name {
text-overflow: ellipsis;
text-align: left;
font-weight: 800;
margin: 0 0 0 0;
}
.detail {
text-overflow: ellipsis;
text-align: left;
margin: 0 0 0 0;
font-size: 85%;
color: #999;
}
}

&.timelapse_files_thumb {
@thumb-size: 110px;
@thumb-coner: 3px;
width: @thumb-size;
position: relative;

img {
max-width: 100%;
max-height: @thumb-size;
border-radius: @thumb-coner;
}

div {
width: @thumb-size;
height: calc(@thumb-size * 9 / 16);
border-radius: @thumb-coner;
background-color: rgb(238, 238, 238);
}

a {
background-image: url(/static/img/play.svg);
background-position: center;
background-repeat: no-repeat;
background-size: 32px 32px;
width: 100%;
height: 100%;
opacity: 0.8;
position: absolute;
top: 0;
left: 0;
content: "";

&:hover {
opacity: 1;
}
}
}

&.timelapse_files_size {
text-align: right;
width: 55px;
Expand All @@ -349,7 +403,14 @@ table {
&.timelapse_files_action,
&.timelapse_unrendered_action {
width: 60px;
position: relative;

.actioncol;
.btn-group {
position: absolute;
bottom: 5px;
right: 5px;
}
}

// user settings
Expand Down
29 changes: 23 additions & 6 deletions src/octoprint/templates/tabs/timelapse.jinja2
Expand Up @@ -133,21 +133,38 @@
<button class="btn btn-small" data-bind="click: removeMarkedFiles, enable: markedForFileDeletion().length > 0">{{ _('Delete selected') }}</button>
<a class="btn btn-small" data-bind="css: { disabled: !enableBulkDownload() }, attr: { href: bulkDownloadButtonUrl }">{{_('Download selected')}}</a>
</div>
<table class="table table-striped table-hover table-condensed table-hover" id="timelapse_files">
<table class="table table-hover table-condensed table-hover" id="timelapse_files">
<thead>
<tr>
<th class="timelapse_files_checkbox"></th>
<th class="timelapse_files_name">{{ _('Name') }}</th>
<th class="timelapse_files_size">{{ _('Size') }}</th>
<th class="timelapse_files_thumb">{{ _('Preview') }}</th>
<th class="timelapse_files_details">{{ _('Details') }}</th>
<th class="timelapse_files_action">{{ _('Action') }}</th>
</tr>
</thead>
<tbody data-bind="foreach: listHelper.paginatedItems">
<tr data-bind="attr: {title: name}">
<td class="timelapse_files_checkbox"><input type="checkbox" data-bind="value: name, checked: $root.markedForFileDeletion, invisible: !$root.loginState.hasPermissionKo($root.access.permissions.TIMELAPSE_DELETE)()"></td>
<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="javascript:void(0)" class="far fa-trash-alt" data-bind="click: function() { $parent.removeFile($data.name); }, css: {disabled: !$root.loginState.hasPermissionKo($root.access.permissions.TIMELAPSE_DELETE)() }"></a>&nbsp;|&nbsp;<a href="javascript:void(0)" class="fas fa-download" data-bind="css: {disabled: !$root.loginState.hasPermissionKo($root.access.permissions.TIMELAPSE_DOWNLOAD)()}, attr: { href: ($root.loginState.hasPermission($root.access.permissions.TIMELAPSE_DOWNLOAD)) ? $data.url : 'javascript:void(0)' }"></a>&nbsp;|&nbsp;<a href="javascript:void(0)" class="fas fa-camera" data-bind="css: {disabled: !$root.isTimelapseViewable($data)}, click: $root.showTimelapsePreview"></a></td>
<td class="timelapse_files_thumb">
<!-- ko if: $data.thumbnail -->
<img data-bind="attr:{src: thumbnail}" loading="lazy" />
<!-- /ko -->
<!-- ko ifnot: $data.thumbnail -->
<div></div>
<!-- /ko -->
<a href="javascript:void(0)" data-bind="css: {disabled: !$root.isTimelapseViewable($data)}, click: $root.showTimelapsePreview"></a>
</td>
<td class="timelapse_files_details">
<p class="name" data-bind="text: name"></p>
<p class="detail">{{ _('Recorded:') }} <span data-bind="text: formatTimeAgo(timestamp)"/></p>
<p class="detail">{{ _('Size:') }} <span data-bind="text: size"/></p>
</td>
<td class="timelapse_files_action">
<div class="btn-group action-buttons">
<div href="javascript:void(0)" class="btn btn-mini" data-bind="click: function() { $parent.removeFile($data.name); }, css: {disabled: !$root.loginState.hasPermissionKo($root.access.permissions.TIMELAPSE_DELETE)() }"><i class="far fa-trash-alt"></i></div>
<a href="javascript:void(0)" class="btn btn-mini" data-bind="css: {disabled: !$root.loginState.hasPermissionKo($root.access.permissions.TIMELAPSE_DOWNLOAD)()}, attr: { href: ($root.loginState.hasPermission($root.access.permissions.TIMELAPSE_DOWNLOAD)) ? $data.url : 'javascript:void(0)' }"><i class="fas fa-download"></i></a>
</div>
</td>
</tr>
</tbody>
</table>
Expand Down
61 changes: 61 additions & 0 deletions src/octoprint/timelapse.py
Expand Up @@ -51,6 +51,11 @@
_capture_glob = "{prefix}-*.jpg"
_output_format = "{prefix}{postfix}.{extension}"

# thumbnails
_thumbnail_extension = ".thumb.jpg"
_thumbnail_format = "{}.thumb.jpg"


# ffmpeg progress regexes
_ffmpeg_duration_regex = re.compile(r"Duration: (\d{2}):(\d{2}):(\d{2})\.\d{2}")
_ffmpeg_current_regex = re.compile(r"time=(\d{2}):(\d{2}):(\d{2})\.\d{2}")
Expand All @@ -75,6 +80,10 @@
_extensions = None


def create_thumbnail_path(movie_path):
return _thumbnail_format.format(movie_path)


def valid_timelapse(path):
global _extensions

Expand All @@ -101,6 +110,15 @@ def valid_timelapse(path):
return util.is_allowed_file(path, _extensions)


def valid_timelapse_thumbnail(path):
global _thumbnail_extensions
# Thumbnail path is valid if it ends with thumbnail extension and path without extension is valid timelpase
if path.endswith(_thumbnail_extension):
return valid_timelapse(path[: -len(_thumbnail_extension)])
else:
return False


def _extract_prefix(filename):
"""
>>> _extract_prefix("some_long_filename_without_hyphen.jpg")
Expand Down Expand Up @@ -130,11 +148,20 @@ def get_finished_timelapses():
for entry in scandir(basedir):
if util.is_hidden_path(entry.path) or not valid_timelapse(entry.path):
continue

thumb = create_thumbnail_path(entry.path)
if os.path.isfile(thumb) is True:
thumb = os.path.basename(thumb)
else:
thumb = None

files.append(
{
"name": entry.name,
"size": util.get_formatted_size(entry.stat().st_size),
"bytes": entry.stat().st_size,
"thumbnail": thumb,
"timestamp": entry.stat().st_mtime,
"date": util.get_formatted_datetime(
datetime.datetime.fromtimestamp(entry.stat().st_mtime)
),
Expand Down Expand Up @@ -1066,6 +1093,10 @@ def _render(self):

if returncode == 0:
shutil.move(temporary, output)
self._try_generate_thumbnail(
ffmpeg=ffmpeg,
movie_path=output,
)
self._notify_callback("success", output)
else:
self._logger.warning(
Expand Down Expand Up @@ -1113,6 +1144,36 @@ def _process_ffmpeg_output(self, *lines):
if duration is not None:
self._parsed_duration = self._convert_time(*duration.groups())

def _try_generate_thumbnail(self, ffmpeg, movie_path):
try:
thumb_path = create_thumbnail_path(movie_path)
commandline = settings().get(["webcam", "ffmpegThumbnailCommandline"])
thumb_command_str = self._create_ffmpeg_command_string(
commandline=commandline,
ffmpeg=ffmpeg,
input=movie_path,
output=thumb_path,
fps=None,
videocodec=None,
threads=None,
bitrate=None,
)
c = CommandlineCaller()
returncode, stdout_text, stderr_text = c.call(
thumb_command_str, delimiter=b"\r", buffer_size=512
)
if returncode != 0:
self._logger.warning(
"Failed to generate optional thumbnail %r: %s"
% (returncode, stderr_text)
)
except Exception as ex:
self._logger.warning(
"Failed to generate thumbnail from {} to {} ({})".format(
movie_path, thumb_path, ex
)
)

@staticmethod
def _convert_time(hours, minutes, seconds):
return (int(hours) * 60 + int(minutes)) * 60 + int(seconds)
Expand Down

0 comments on commit cac56cf

Please sign in to comment.