client, server: rework custom thumbnails

Saving custom thumbnails separately allows us to display them in search
results etc while also displaying a thumbnail of the final content
during loading.
This commit is contained in:
Eva 2024-03-22 00:04:31 +01:00
parent ad622c4d99
commit 74eaa22662
10 changed files with 87 additions and 47 deletions

View file

@ -24,7 +24,7 @@
}, },
ctx.makeElement('source', { ctx.makeElement('source', {
type: ctx.post.mimeType, type: ctx.post.mimeType,
src: ctx.post.contentUrl + '#t=0.001', src: ctx.post.contentUrl,
}), }),
'Your browser doesn\'t support HTML5 videos.') 'Your browser doesn\'t support HTML5 videos.')
%> %>

View file

@ -8,7 +8,7 @@ const PageController = require("../controllers/page_controller.js");
const CommentsPageView = require("../views/comments_page_view.js"); const CommentsPageView = require("../views/comments_page_view.js");
const EmptyView = require("../views/empty_view.js"); const EmptyView = require("../views/empty_view.js");
const fields = ["id", "comments", "commentCount", "thumbnailUrl"]; const fields = ["id", "comments", "commentCount", "thumbnailUrl", "customThumbnailUrl"];
class CommentsController { class CommentsController {
constructor(ctx) { constructor(ctx) {

View file

@ -14,6 +14,7 @@ const EmptyView = require("../views/empty_view.js");
const fields = [ const fields = [
"id", "id",
"thumbnailUrl", "thumbnailUrl",
"customThumbnailUrl",
"type", "type",
"safety", "safety",
"score", "score",

View file

@ -126,7 +126,7 @@ class PostContentControl {
newNode.firstElementChild.style.backgroundImage = ""; newNode.firstElementChild.style.backgroundImage = "";
} }
if (["image", "flash"].includes(this._post.type)) { if (["image", "flash"].includes(this._post.type)) {
newNode.firstElementChild.style.backgroundImage = "url("+this._post.thumbnailUrl+")"; newNode.firstElementChild.style.backgroundImage = "url("+this._post.originalThumbnailUrl+")";
} }
if (this._post.type == "image") { if (this._post.type == "image") {
newNode.firstElementChild.addEventListener("load", load); newNode.firstElementChild.addEventListener("load", load);
@ -137,7 +137,7 @@ class PostContentControl {
newNode.classList.add("post-error"); newNode.classList.add("post-error");
if (["image", "animation"].includes(this._post.type)) { if (["image", "animation"].includes(this._post.type)) {
newNode.firstElementChild.removeEventListener("load", load); newNode.firstElementChild.removeEventListener("load", load);
newNode.firstElementChild.style.backgroundImage = "url("+this._post.thumbnailUrl+")"; newNode.firstElementChild.style.backgroundImage = "url("+this._post.originalThumbnailUrl+")";
newNode.firstElementChild.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; newNode.firstElementChild.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
} }
}); });

View file

@ -139,7 +139,7 @@ class PostEditSidebarControl extends events.EventTarget {
this._evtRemoveThumbnailClick(e) this._evtRemoveThumbnailClick(e)
); );
this._thumbnailRemovalLinkNode.style.display = this._post this._thumbnailRemovalLinkNode.style.display = this._post
.hasCustomThumbnail .customThumbnailUrl
? "block" ? "block"
: "none"; : "none";
} }

View file

@ -70,6 +70,14 @@ class Post extends events.EventTarget {
return this._thumbnailUrl; return this._thumbnailUrl;
} }
get customThumbnailUrl() {
return this._customThumbnailUrl;
}
get originalThumbnailUrl() {
return this._originalThumbnailUrl;
}
get source() { get source() {
return this._source; return this._source;
} }
@ -146,10 +154,6 @@ class Post extends events.EventTarget {
return this._ownScore; return this._ownScore;
} }
get hasCustomThumbnail() {
return this._hasCustomThumbnail;
}
set flags(value) { set flags(value) {
this._flags = value; this._flags = value;
} }
@ -477,7 +481,9 @@ class Post extends events.EventTarget {
response.contentUrl, response.contentUrl,
document.getElementsByTagName("base")[0].href document.getElementsByTagName("base")[0].href
).href, ).href,
_thumbnailUrl: response.thumbnailUrl, _thumbnailUrl: response.customThumbnailUrl ? response.customThumbnailUrl : response.thumbnailUrl,
_customThumbnailUrl: response.customThumbnailUrl,
_originalThumbnailUrl: response.thumbnailUrl,
_source: response.source, _source: response.source,
_canvasWidth: response.canvasWidth, _canvasWidth: response.canvasWidth,
_canvasHeight: response.canvasHeight, _canvasHeight: response.canvasHeight,
@ -491,7 +497,6 @@ class Post extends events.EventTarget {
_favoriteCount: response.favoriteCount, _favoriteCount: response.favoriteCount,
_ownScore: response.ownScore, _ownScore: response.ownScore,
_ownFavorite: response.ownFavorite, _ownFavorite: response.ownFavorite,
_hasCustomThumbnail: response.hasCustomThumbnail,
}); });
for (let obj of [this, this._orig]) { for (let obj of [this, this._orig]) {

View file

@ -1,4 +1,5 @@
import os import os
import glob
from typing import Any, List, Optional from typing import Any, List, Optional
from szurubooru import config from szurubooru import config
@ -24,6 +25,10 @@ def scan(path: str) -> List[Any]:
return [] 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: def move(source_path: str, target_path: str) -> None:
os.rename(_get_full_path(source_path), _get_full_path(target_path)) os.rename(_get_full_path(source_path), _get_full_path(target_path))

View file

@ -24,10 +24,18 @@ def convert_heif_to_png(content: bytes) -> bytes:
return img_byte_arr.getvalue() return img_byte_arr.getvalue()
def check_for_loop(content: bytes) -> bytes:
img = PILImage.open(BytesIO(content))
return "loop" in img.info
class Image: class Image:
def __init__(self, content: bytes) -> None: def __init__(self, content: bytes) -> None:
self.content = content self.content = content
self._reload_info() self._reload_info()
if self.info["format"]["format_name"] == "swf":
self.content = self.swf_to_png()
self._reload_info()
@property @property
def width(self) -> int: def width(self) -> int:
@ -41,7 +49,7 @@ class Image:
def frames(self) -> int: def frames(self) -> int:
return self.info["streams"][0]["nb_read_frames"] return self.info["streams"][0]["nb_read_frames"]
def resize_fill(self, width: int, height: int, keep_transparency: bool = True) -> None: def resize_fill(self, width: int, height: int, keep_transparency: bool = True, seek=True) -> None:
width_greater = self.width > self.height width_greater = self.width > self.height
width, height = (-1, height) if width_greater else (width, -1) width, height = (-1, height) if width_greater else (width, -1)
@ -64,10 +72,7 @@ class Image:
"png", "png",
"-", "-",
] ]
if ( if seek and "duration" in self.info["format"]:
"duration" in self.info["format"]
and self.info["format"]["format_name"] != "swf"
):
duration = float(self.info["format"]["duration"]) duration = float(self.info["format"]["duration"])
if duration > 3: if duration > 3:
cli = [ cli = [
@ -80,6 +85,19 @@ class Image:
self.content = content self.content = content
self._reload_info() self._reload_info()
def swf_to_png(self) -> bytes:
return self._execute(
[
"--silent",
"-g",
"gl",
"--",
"{path}",
"-",
],
program="exporter",
)
def to_png(self) -> bytes: def to_png(self) -> bytes:
return self._execute( return self._execute(
[ [
@ -311,7 +329,7 @@ class Image:
) )
assert "format" in self.info assert "format" in self.info
assert "streams" 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.") logger.warning("The video contains no video streams.")
raise errors.ProcessingError( raise errors.ProcessingError(
"The video contains no video streams." "The video contains no video streams."

View file

@ -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: def get_post_content_path(post: model.Post) -> str:
assert post assert post
assert post.post_id 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: def get_post_thumbnail_path(post: model.Post) -> str:
assert post assert post
return "generated-thumbnails/%d_%s.jpg" % ( 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 assert post
return "posts/custom-thumbnails/%d_%s.dat" % ( return "generated-thumbnails/custom-thumbnails/sample_%d_%s.jpg" % (
post.post_id, post.post_id,
get_post_security_hash(post.post_id), get_post_security_hash(post.post_id),
) )
@ -180,6 +198,7 @@ class PostSerializer(serialization.BaseSerializer):
"canvasHeight": self.serialize_canvas_height, "canvasHeight": self.serialize_canvas_height,
"contentUrl": self.serialize_content_url, "contentUrl": self.serialize_content_url,
"thumbnailUrl": self.serialize_thumbnail_url, "thumbnailUrl": self.serialize_thumbnail_url,
"customThumbnailUrl": self.serialize_custom_thumbnail_url,
"flags": self.serialize_flags, "flags": self.serialize_flags,
"tags": self.serialize_tags, "tags": self.serialize_tags,
"relations": self.serialize_relations, "relations": self.serialize_relations,
@ -195,7 +214,6 @@ class PostSerializer(serialization.BaseSerializer):
"featureCount": self.serialize_feature_count, "featureCount": self.serialize_feature_count,
"lastFeatureTime": self.serialize_last_feature_time, "lastFeatureTime": self.serialize_last_feature_time,
"favoritedBy": self.serialize_favorited_by, "favoritedBy": self.serialize_favorited_by,
"hasCustomThumbnail": self.serialize_has_custom_thumbnail,
"notes": self.serialize_notes, "notes": self.serialize_notes,
"comments": self.serialize_comments, "comments": self.serialize_comments,
"pools": self.serialize_pools, "pools": self.serialize_pools,
@ -319,8 +337,9 @@ class PostSerializer(serialization.BaseSerializer):
for rel in self.post.favorited_by for rel in self.post.favorited_by
] ]
def serialize_has_custom_thumbnail(self) -> Any: def serialize_custom_thumbnail_url(self) -> Any:
return files.has(get_post_thumbnail_backup_path(self.post)) if files.has(get_post_custom_thumbnail_path(self.post)):
return get_post_custom_thumbnail_url(self.post)
def serialize_notes(self) -> Any: def serialize_notes(self) -> Any:
return sorted( return sorted(
@ -357,7 +376,7 @@ def serialize_micro_post(
post: model.Post, auth_user: model.User post: model.Post, auth_user: model.User
) -> Optional[rest.Response]: ) -> Optional[rest.Response]:
return serialize_post( 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: ) -> None:
if post.post_id: if post.post_id:
if config.config["delete_source_files"]: if config.config["delete_source_files"]:
files.delete(get_post_content_path(post)) pattern = post.post_id + "_*"
files.delete(get_post_thumbnail_path(post)) 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: def _sync_post_content(post: model.Post) -> None:
regenerate_thumb = False
if hasattr(post, "__content"): if hasattr(post, "__content"):
content = getattr(post, "__content") content = getattr(post, "__content")
files.save(get_post_content_path(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") delattr(post, "__content")
regenerate_thumb = True
if hasattr(post, "__thumbnail"): if hasattr(post, "__thumbnail"):
if getattr(post, "__thumbnail"): if getattr(post, "__thumbnail"):
files.save( thumbnail = getattr(post, "__thumbnail")
get_post_thumbnail_backup_path(post), files.save(get_post_custom_content_path(post), thumbnail)
getattr(post, "__thumbnail"), generate_post_thumbnail(get_post_custom_thumbnail_path(post), thumbnail, seek=True)
)
else: else:
files.delete(get_post_thumbnail_backup_path(post)) files.delete(get_post_custom_thumbnail_path(post))
delattr(post, "__thumbnail") delattr(post, "__thumbnail")
regenerate_thumb = True
if regenerate_thumb:
generate_post_thumbnail(post)
def generate_alternate_formats( def generate_alternate_formats(
@ -677,12 +692,7 @@ def update_post_thumbnail(
setattr(post, "__thumbnail", content) setattr(post, "__thumbnail", content)
def generate_post_thumbnail(post: model.Post) -> None: def generate_post_thumbnail(path: str, content: bytes, seek=True) -> 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))
try: try:
assert content assert content
image = images.Image(content) image = images.Image(content)
@ -690,10 +700,11 @@ def generate_post_thumbnail(post: model.Post) -> None:
int(config.config["thumbnails"]["post_width"]), int(config.config["thumbnails"]["post_width"]),
int(config.config["thumbnails"]["post_height"]), int(config.config["thumbnails"]["post_height"]),
keep_transparency=False, keep_transparency=False,
seek=seek,
) )
files.save(get_post_thumbnail_path(post), image.to_jpeg()) files.save(path, image.to_jpeg())
except errors.ProcessingError: except errors.ProcessingError:
files.save(get_post_thumbnail_path(post), EMPTY_PIXEL) files.save(path, EMPTY_PIXEL)
def update_post_tags( def update_post_tags(

View file

@ -72,12 +72,12 @@ def test_get_post_thumbnail_path(input_mime_type):
@pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"]) @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 = model.Post()
post.post_id = 1 post.post_id = 1
post.mime_type = input_mime_type post.mime_type = input_mime_type
assert ( assert (
posts.get_post_thumbnail_backup_path(post) posts.get_post_custom_thumbnail_path(post)
== "posts/custom-thumbnails/1_244c8840887984c4.dat" == "posts/custom-thumbnails/1_244c8840887984c4.dat"
) )