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:
parent
ad622c4d99
commit
74eaa22662
10 changed files with 87 additions and 47 deletions
|
@ -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.')
|
||||||
%>
|
%>
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]) {
|
||||||
|
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Reference in a new issue