<% } 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"
)