<% if (['image', 'animation'].includes(ctx.post.type)) { %> - + <% } else if (ctx.post.type === 'flash') { %> - + +
Your browser does not support Flash.
<% } else if (ctx.post.type === 'video') { %> @@ -19,6 +20,8 @@ loop: (ctx.post.flags || []).includes('loop'), playsinline: true, autoplay: ctx.autoplay, + preload: 'auto', + poster: ctx.post.originalThumbnailUrl, }, ctx.makeElement('source', { type: ctx.post.mimeType, diff --git a/client/html/post_upload_row.tpl b/client/html/post_upload_row.tpl index 2885e3d77..1318ab2fd 100644 --- a/client/html/post_upload_row.tpl +++ b/client/html/post_upload_row.tpl @@ -10,7 +10,7 @@
- diff --git a/client/js/api.js b/client/js/api.js index 5bde6d810..d3f74c267 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -294,11 +294,16 @@ class Api extends events.EventTarget { // transform the request: upload each file, then make the request use // its tokens. data = Object.assign({}, data); + let fileData = {}; let abortFunction = () => {}; let promise = Promise.resolve(); if (files) { for (let key of Object.keys(files)) { const file = files[key]; + if (file === null) { + fileData[key] = null; + continue; + } const fileId = this._getFileId(file); if (fileTokens[fileId]) { data[key + "Token"] = fileTokens[fileId]; @@ -324,7 +329,7 @@ class Api extends events.EventTarget { url, requestFactory, data, - {}, + fileData, options ); abortFunction = () => requestPromise.abort(); @@ -388,7 +393,7 @@ class Api extends events.EventTarget { if (files) { for (let key of Object.keys(files)) { const value = files[key]; - if (value.constructor === String) { + if (value !== null && value.constructor === String) { data[key + "Url"] = value; } else { req.attach(key, value || new Blob()); diff --git a/client/js/controllers/comments_controller.js b/client/js/controllers/comments_controller.js index d54059e8d..9e9d12029 100644 --- a/client/js/controllers/comments_controller.js +++ b/client/js/controllers/comments_controller.js @@ -8,7 +8,7 @@ const PageController = require("../controllers/page_controller.js"); const CommentsPageView = require("../views/comments_page_view.js"); const EmptyView = require("../views/empty_view.js"); -const fields = ["id", "comments", "commentCount", "thumbnailUrl"]; +const fields = ["id", "comments", "commentCount", "thumbnailUrl", "customThumbnailUrl"]; class CommentsController { constructor(ctx) { diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js index fdb7b844b..ca1a411c5 100644 --- a/client/js/controllers/post_list_controller.js +++ b/client/js/controllers/post_list_controller.js @@ -14,6 +14,7 @@ const EmptyView = require("../views/empty_view.js"); const fields = [ "id", "thumbnailUrl", + "customThumbnailUrl", "type", "safety", "score", diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js index 95cfdb52f..0634069ac 100644 --- a/client/js/controllers/post_main_controller.js +++ b/client/js/controllers/post_main_controller.js @@ -178,15 +178,11 @@ class PostMainController extends BasePostController { if (e.detail.relations !== undefined) { post.relations = e.detail.relations; } - if (e.detail.content !== undefined) { - post.newContent = e.detail.content; - } - if (e.detail.thumbnail !== undefined) { - post.newThumbnail = e.detail.thumbnail; - } if (e.detail.source !== undefined) { post.source = e.detail.source; } + post.newContent = e.detail.content; + post.newThumbnail = e.detail.thumbnail; post.save().then( () => { this._view.sidebarControl.showSuccess("Post saved."); diff --git a/client/js/controls/post_content_control.js b/client/js/controls/post_content_control.js index 55daca763..db85a2be6 100644 --- a/client/js/controls/post_content_control.js +++ b/client/js/controls/post_content_control.js @@ -119,9 +119,28 @@ class PostContentControl { post: this._post, autoplay: settings.get().autoplayVideos, }); - if (settings.get().transparencyGrid) { + function load(argument) { + if (settings.get().transparencyGrid) { + newNode.classList.add("transparency-grid"); + } + newNode.firstElementChild.style.backgroundImage = ""; + } + if (["image", "flash"].includes(this._post.type)) { + newNode.firstElementChild.style.backgroundImage = "url("+this._post.originalThumbnailUrl+")"; + } + if (this._post.type == "image") { + newNode.firstElementChild.addEventListener("load", load); + } else if (settings.get().transparencyGrid) { newNode.classList.add("transparency-grid"); } + newNode.firstElementChild.addEventListener("error", (e) => { + newNode.classList.add("post-error"); + if (["image", "animation"].includes(this._post.type)) { + newNode.firstElementChild.removeEventListener("load", load); + newNode.firstElementChild.style.backgroundImage = "url("+this._post.originalThumbnailUrl+")"; + newNode.firstElementChild.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + } + }); if (this._postContentNode) { this._hostNode.replaceChild(newNode, this._postContentNode); } else { diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js index eabb98ae1..9f2d37da2 100644 --- a/client/js/controls/post_edit_sidebar_control.js +++ b/client/js/controls/post_edit_sidebar_control.js @@ -138,10 +138,7 @@ class PostEditSidebarControl extends events.EventTarget { this._thumbnailRemovalLinkNode.addEventListener("click", (e) => this._evtRemoveThumbnailClick(e) ); - this._thumbnailRemovalLinkNode.style.display = this._post - .hasCustomThumbnail - ? "block" - : "none"; + this._thumbnailRemovalLinkUpdate(this._post); } if (this._addNoteLinkNode) { @@ -249,12 +246,25 @@ class PostEditSidebarControl extends events.EventTarget { this._poolsExpander.title = `Pools (${this._post.pools.length})`; } + _thumbnailRemovalLinkUpdate(post) { + if (this._thumbnailRemovalLinkNode) { + this._thumbnailRemovalLinkNode.style.display = post + .customThumbnailUrl + ? "block" + : "none"; + } + } + _evtPostContentChange(e) { this._contentFileDropper.reset(); + this._thumbnailRemovalLinkUpdate(e.detail.post); + this._newPostContent = null; } _evtPostThumbnailChange(e) { this._thumbnailFileDropper.reset(); + this._thumbnailRemovalLinkUpdate(e.detail.post); + this._newPostThumbnail = undefined; } _evtRemoveThumbnailClick(e) { @@ -427,9 +437,7 @@ class PostEditSidebarControl extends events.EventTarget { : undefined, thumbnail: - this._newPostThumbnail !== undefined - ? this._newPostThumbnail - : undefined, + this._newPostThumbnail, source: this._sourceInputNode ? this._sourceInputNode.value diff --git a/client/js/models/post.js b/client/js/models/post.js index 2fb3d34cf..45f859ea7 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -70,6 +70,14 @@ class Post extends events.EventTarget { return this._thumbnailUrl; } + get customThumbnailUrl() { + return this._customThumbnailUrl; + } + + get originalThumbnailUrl() { + return this._originalThumbnailUrl; + } + get source() { return this._source; } @@ -146,10 +154,6 @@ class Post extends events.EventTarget { return this._ownScore; } - get hasCustomThumbnail() { - return this._hasCustomThumbnail; - } - set flags(value) { this._flags = value; } @@ -477,7 +481,9 @@ class Post extends events.EventTarget { response.contentUrl, document.getElementsByTagName("base")[0].href ).href, - _thumbnailUrl: response.thumbnailUrl, + _thumbnailUrl: response.customThumbnailUrl ? response.customThumbnailUrl : response.thumbnailUrl, + _customThumbnailUrl: response.customThumbnailUrl, + _originalThumbnailUrl: response.thumbnailUrl, _source: response.source, _canvasWidth: response.canvasWidth, _canvasHeight: response.canvasHeight, @@ -491,7 +497,6 @@ class Post extends events.EventTarget { _favoriteCount: response.favoriteCount, _ownScore: response.ownScore, _ownFavorite: response.ownFavorite, - _hasCustomThumbnail: response.hasCustomThumbnail, }); for (let obj of [this, this._orig]) { diff --git a/client/js/util/views.js b/client/js/util/views.js index 38c98a137..41d2fa379 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -49,7 +49,7 @@ function makeThumbnail(url) { style: `background-image: url(\'${url}\')`, } : { class: "thumbnail empty" }, - makeElement("img", { alt: "thumbnail", src: url }) + makeElement("img", { alt: "thumbnail", src: url, draggable: "false" }) ); } diff --git a/client/js/views/post_upload_view.js b/client/js/views/post_upload_view.js index 4ef4c1ad3..55b554276 100644 --- a/client/js/views/post_upload_view.js +++ b/client/js/views/post_upload_view.js @@ -401,6 +401,14 @@ class PostUploadView extends events.EventTarget { .addEventListener("click", (e) => this._evtMoveClick(e, uploadable, 1) ); + if (uploadable.type == "video") { + const video = rowNode.querySelector("video"); + if (video) { + video.addEventListener("loadedmetadata", (e) => { + if (!isNaN(video.duration)) video.currentTime = Math.floor(video.duration * 0.3) + }); + } + } } _updateThumbnailNode(uploadable) { diff --git a/server/szurubooru/func/files.py b/server/szurubooru/func/files.py index 6a8982698..4d764f52c 100644 --- a/server/szurubooru/func/files.py +++ b/server/szurubooru/func/files.py @@ -1,4 +1,5 @@ import os +import glob from typing import Any, List, Optional from szurubooru import config @@ -24,6 +25,10 @@ def scan(path: str) -> List[Any]: return [] +def find(path: str, pattern: str) -> List[Any]: + return glob.glob(glob.escape(_get_full_path(path) + "/") + pattern) + + def move(source_path: str, target_path: str) -> None: os.rename(_get_full_path(source_path), _get_full_path(target_path)) diff --git a/server/szurubooru/func/images.py b/server/szurubooru/func/images.py index e135d182e..f3bc5fdc2 100644 --- a/server/szurubooru/func/images.py +++ b/server/szurubooru/func/images.py @@ -24,10 +24,18 @@ def convert_heif_to_png(content: bytes) -> bytes: return img_byte_arr.getvalue() +def check_for_loop(content: bytes) -> bytes: + img = PILImage.open(BytesIO(content)) + return "loop" in img.info + + class Image: def __init__(self, content: bytes) -> None: self.content = content self._reload_info() + if self.info["format"]["format_name"] == "swf": + self.content = self.swf_to_png() + self._reload_info() @property def width(self) -> int: @@ -41,7 +49,7 @@ def height(self) -> int: def frames(self) -> int: return self.info["streams"][0]["nb_read_frames"] - def resize_fill(self, width: int, height: int) -> None: + def resize_fill(self, width: int, height: int, keep_transparency: bool = True, seek=True) -> None: width_greater = self.width > self.height width, height = (-1, height) if width_greater else (width, -1) @@ -50,8 +58,12 @@ def resize_fill(self, width: int, height: int) -> None: "{path}", "-f", "image2", - "-filter:v", - "scale='{width}:{height}'".format(width=width, height=height), + "-filter_complex", + ( + "format=rgb32,scale={width}:{height}:flags=bicubic" + if keep_transparency else + "[0:v]format=rgb32,scale={width}:{height}:flags=bicubic[a];color=white[b];[b][a]scale2ref[b][a];[b][a]overlay" + ).format(width=width, height=height), "-map", "0:v:0", "-vframes", @@ -60,10 +72,7 @@ def resize_fill(self, width: int, height: int) -> None: "png", "-", ] - if ( - "duration" in self.info["format"] - and self.info["format"]["format_name"] != "swf" - ): + if seek and "duration" in self.info["format"]: duration = float(self.info["format"]["duration"]) if duration > 3: cli = [ @@ -76,6 +85,19 @@ def resize_fill(self, width: int, height: int) -> None: self.content = content self._reload_info() + def swf_to_png(self) -> bytes: + return self._execute( + [ + "--silent", + "-g", + "gl", + "--", + "{path}", + "-", + ], + program="exporter", + ) + def to_png(self) -> bytes: return self._execute( [ @@ -96,24 +118,13 @@ def to_png(self) -> bytes: def to_jpeg(self) -> bytes: return self._execute( [ - "-f", - "lavfi", - "-i", - "color=white:s=%dx%d" % (self.width, self.height), - "-i", + "-quality", + "85", + "-sample", + "1x1", "{path}", - "-f", - "image2", - "-filter_complex", - "overlay", - "-map", - "0:v:0", - "-vframes", - "1", - "-vcodec", - "mjpeg", - "-", - ] + ], + program="cjpeg", ) def to_webm(self) -> bytes: @@ -274,7 +285,10 @@ def _execute( with util.create_temp_file(suffix="." + extension) as handle: handle.write(self.content) handle.flush() - cli = [program, "-loglevel", "32" if get_logs else "24"] + cli + if program in ["ffmpeg", "ffprobe"]: + cli = [program, "-loglevel", "32" if get_logs else "24"] + cli + else: + cli = [program] + cli cli = [part.format(path=handle.name) for part in cli] proc = subprocess.Popen( cli, @@ -285,7 +299,7 @@ def _execute( out, err = proc.communicate() if proc.returncode != 0: logger.warning( - "Failed to execute ffmpeg command (cli=%r, err=%r)", + "Failed to execute {program} command (cli=%r, err=%r)".format(program=program), " ".join(shlex.quote(arg) for arg in cli), err, ) @@ -315,7 +329,7 @@ def _reload_info(self) -> None: ) assert "format" in self.info assert "streams" in self.info - if len(self.info["streams"]) < 1: + if len(self.info["streams"]) < 1 and self.info["format"]["format_name"] != "swf": logger.warning("The video contains no video streams.") raise errors.ProcessingError( "The video contains no video streams." diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index be2259cf4..4066907d2 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -124,6 +124,15 @@ def get_post_thumbnail_url(post: model.Post) -> str: ) +def get_post_custom_thumbnail_url(post: model.Post) -> str: + assert post + return "%s/generated-thumbnails/custom-thumbnails/sample_%d_%s.jpg" % ( + config.config["data_url"].rstrip("/"), + post.post_id, + post.image_key, + ) + + def get_post_content_path(post: model.Post) -> str: assert post assert post.post_id @@ -134,6 +143,15 @@ def get_post_content_path(post: model.Post) -> str: ) +def get_post_custom_content_path(post: model.Post) -> str: + assert post + assert post.post_id + return "posts/custom-thumbnails/%d_%s.dat" % ( + post.post_id, + post.image_key, + ) + + def get_post_thumbnail_path(post: model.Post) -> str: assert post return "generated-thumbnails/%d_%s.jpg" % ( @@ -142,9 +160,9 @@ def get_post_thumbnail_path(post: model.Post) -> str: ) -def get_post_thumbnail_backup_path(post: model.Post) -> str: +def get_post_custom_thumbnail_path(post: model.Post) -> str: assert post - return "posts/custom-thumbnails/%d_%s.dat" % ( + return "generated-thumbnails/custom-thumbnails/sample_%d_%s.jpg" % ( post.post_id, get_post_security_hash(post.post_id), ) @@ -180,6 +198,7 @@ def _serializers(self) -> Dict[str, Callable[[], Any]]: "canvasHeight": self.serialize_canvas_height, "contentUrl": self.serialize_content_url, "thumbnailUrl": self.serialize_thumbnail_url, + "customThumbnailUrl": self.serialize_custom_thumbnail_url, "flags": self.serialize_flags, "tags": self.serialize_tags, "relations": self.serialize_relations, @@ -195,7 +214,6 @@ def _serializers(self) -> Dict[str, Callable[[], Any]]: "featureCount": self.serialize_feature_count, "lastFeatureTime": self.serialize_last_feature_time, "favoritedBy": self.serialize_favorited_by, - "hasCustomThumbnail": self.serialize_has_custom_thumbnail, "notes": self.serialize_notes, "comments": self.serialize_comments, "pools": self.serialize_pools, @@ -319,8 +337,9 @@ def serialize_favorited_by(self) -> Any: for rel in self.post.favorited_by ] - def serialize_has_custom_thumbnail(self) -> Any: - return files.has(get_post_thumbnail_backup_path(self.post)) + def serialize_custom_thumbnail_url(self) -> Any: + if files.has(get_post_custom_thumbnail_path(self.post)): + return get_post_custom_thumbnail_url(self.post) def serialize_notes(self) -> Any: return sorted( @@ -357,7 +376,7 @@ def serialize_micro_post( post: model.Post, auth_user: model.User ) -> Optional[rest.Response]: return serialize_post( - post, auth_user=auth_user, options=["id", "thumbnailUrl"] + post, auth_user=auth_user, options=["id", "thumbnailUrl", "customThumbnailUrl"] ) @@ -462,32 +481,28 @@ def _before_post_delete( ) -> None: if post.post_id: if config.config["delete_source_files"]: - files.delete(get_post_content_path(post)) - files.delete(get_post_thumbnail_path(post)) + pattern = post.post_id + "_*" + for file in files.find("posts", "**/" + pattern, recursive=True) + files.find("generated-thumbnails", "**/sample_" + pattern, recursive=True): + files.delete(file) def _sync_post_content(post: model.Post) -> None: - regenerate_thumb = False - if hasattr(post, "__content"): content = getattr(post, "__content") files.save(get_post_content_path(post), content) + generate_post_thumbnail(get_post_thumbnail_path(post), content, seek=False) + if mime.is_video(post.mime_type): + generate_post_thumbnail(get_post_custom_thumbnail_path(post), content, seek=True) delattr(post, "__content") - regenerate_thumb = True if hasattr(post, "__thumbnail"): if getattr(post, "__thumbnail"): - files.save( - get_post_thumbnail_backup_path(post), - getattr(post, "__thumbnail"), - ) + thumbnail = getattr(post, "__thumbnail") + files.save(get_post_custom_content_path(post), thumbnail) + generate_post_thumbnail(get_post_custom_thumbnail_path(post), thumbnail, seek=True) else: - files.delete(get_post_thumbnail_backup_path(post)) + files.delete(get_post_custom_thumbnail_path(post)) delattr(post, "__thumbnail") - regenerate_thumb = True - - if regenerate_thumb: - generate_post_thumbnail(post) def generate_alternate_formats( @@ -677,22 +692,19 @@ def update_post_thumbnail( setattr(post, "__thumbnail", content) -def generate_post_thumbnail(post: model.Post) -> None: - assert post - if files.has(get_post_thumbnail_backup_path(post)): - content = files.get(get_post_thumbnail_backup_path(post)) - else: - content = files.get(get_post_content_path(post)) +def generate_post_thumbnail(path: str, content: bytes, seek=True) -> None: try: assert content image = images.Image(content) image.resize_fill( int(config.config["thumbnails"]["post_width"]), int(config.config["thumbnails"]["post_height"]), + keep_transparency=False, + seek=seek, ) - files.save(get_post_thumbnail_path(post), image.to_jpeg()) + files.save(path, image.to_jpeg()) except errors.ProcessingError: - files.save(get_post_thumbnail_path(post), EMPTY_PIXEL) + files.save(path, EMPTY_PIXEL) def update_post_tags( diff --git a/server/szurubooru/rest/context.py b/server/szurubooru/rest/context.py index 40ba0bcb5..393a37e2e 100644 --- a/server/szurubooru/rest/context.py +++ b/server/szurubooru/rest/context.py @@ -51,7 +51,7 @@ def get_file( use_video_downloader: bool = False, allow_tokens: bool = True, ) -> bytes: - if name in self._files and self._files[name]: + if name in self._files: return self._files[name] if name + "Url" in self._params: diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py index fa1b3bb62..5e0095fdb 100644 --- a/server/szurubooru/tests/func/test_posts.py +++ b/server/szurubooru/tests/func/test_posts.py @@ -72,12 +72,12 @@ def test_get_post_thumbnail_path(input_mime_type): @pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"]) -def test_get_post_thumbnail_backup_path(input_mime_type): +def test_get_post_custom_thumbnail_path(input_mime_type): post = model.Post() post.post_id = 1 post.mime_type = input_mime_type assert ( - posts.get_post_thumbnail_backup_path(post) + posts.get_post_custom_thumbnail_path(post) == "posts/custom-thumbnails/1_244c8840887984c4.dat" )