From a4ea05a0e47df3b35588a5459bba94204ccc8d64 Mon Sep 17 00:00:00 2001 From: skybldev Date: Sat, 1 Jan 2022 17:38:23 -0500 Subject: [PATCH] server: fix swapped dimensions on some images - [overview] this commit fixes an issue where some images would be rendered stretched in a browser due to a swapped width and height because of intentional EXIF orientation mechanics. - [server] `func/metadata.py`: added function to resolve the image's dimensions after taking the EXIF orientation into consideration. normally, this would only be done on the client side; however, since the server takes those values into consideration for its own operations, it could also be considered a "client". for example, thumbnail generation also depends on the post's dimensions. - [server] `func/posts.py`: refactored `update_post_content`. `func/images.py` will no longer be used to determine the dimensions of a file since `func/metadata.py` is now responsible for that. - [server] refactored `func/metadata.py` - [TODO] create migration to correct post dimensions - [TODO] merge `func/metadata.py` with `func/images.py` and refactor - [TODO] fix thumbnail generation --- server/szurubooru/func/metadata.py | 57 ++++++++++++++++++++++++++---- server/szurubooru/func/posts.py | 45 +++++++++++------------ 2 files changed, 71 insertions(+), 31 deletions(-) diff --git a/server/szurubooru/func/metadata.py b/server/szurubooru/func/metadata.py index 2fd56f68..a44e32e2 100644 --- a/server/szurubooru/func/metadata.py +++ b/server/szurubooru/func/metadata.py @@ -2,20 +2,21 @@ import json import logging from datetime import datetime from subprocess import PIPE, Popen -from typing import Optional, Union +from typing import Optional, Tuple, Union from exif import Image logger = logging.getLogger(__name__) -BASE_FFMPEG_COMMAND = [ +BASE_FFPROBE_COMMAND = [ "ffprobe", "-loglevel", "8", "-print_format", "json", "-show_format", + "-show_streams", ] @@ -28,10 +29,10 @@ def _open_image(content: bytes) -> Image: return tags -def _run_ffmpeg(content: Union[bytes, str]) -> Image: +def _run_ffprobe(content: Union[bytes, str]) -> Image: if isinstance(content, bytes): proc = Popen( - BASE_FFMPEG_COMMAND + ["-"], + BASE_FFPROBE_COMMAND + ["-"], stdin=PIPE, stdout=PIPE, stderr=PIPE, @@ -40,7 +41,7 @@ def _run_ffmpeg(content: Union[bytes, str]) -> Image: output = proc.communicate(input=content)[0] else: proc = Popen( - BASE_FFMPEG_COMMAND + [content], + BASE_FFPROBE_COMMAND + [content], stdout=PIPE, stderr=PIPE, ) @@ -81,7 +82,7 @@ def resolve_video_date_taken( if isinstance(content, dict): tags = content else: - tags = _run_ffmpeg(content) + tags = _run_ffprobe(content) creation_time = tags["creation_time"] except Exception: @@ -116,7 +117,7 @@ def resolve_video_camera(content: Union[bytes, str, dict]) -> Optional[str]: if isinstance(content, dict): tags = content else: - tags = _run_ffmpeg(content) + tags = _run_ffprobe(content) # List of tuples where only one value can be valid option_tuples = ( @@ -138,3 +139,45 @@ def resolve_video_camera(content: Union[bytes, str, dict]) -> Optional[str]: return None else: return " ".join(camera_string) + + +def resolve_real_image_dimensions( + content: Union[bytes, Image] +) -> Optional[Tuple[int, int]]: + try: + if isinstance(content, Image): + tags = content + else: + tags = _open_image(content) + + orig_w = tags["pixel_x_dimension"] + orig_h = tags["pixel_y_dimension"] + + # read: https://jdhao.github.io/2019/07/31/image_rotation_exif_info/ + # 8, 6, 5, 7 are orientation values where the image is rotated 90 + # degrees CW or CCW. in this case, we swap the two dimensions. + if tags["orientation"] in (8, 6, 5, 7): + dimensions = (orig_h, orig_w) + else: + dimensions = (orig_w, orig_h) + except Exception: + return (0, 0) + else: + return dimensions + + +def resolve_video_dimensions( + content: Union[bytes, str, dict] +) -> Optional[Tuple[int, int]]: + try: + if isinstance(content, dict): + tags = content + else: + tags = _run_ffprobe(content) + + stream = tags["format"]["streams"][0] + dimensions = (stream["width"], stream["height"]) + except Exception: + return (0, 0) + else: + return dimensions diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index cf37ed66..f50ccead 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -655,17 +655,33 @@ def update_post_content(post: model.Post, content: Optional[bytes]) -> None: post.signature = generate_post_signature(post, content) post.file_size = len(content) + post.date_taken = None + post.camera = None + try: - image = images.Image(content) - post.canvas_width = image.width - post.canvas_height = image.height - except errors.ProcessingError as ex: + if post.type == model.Post.TYPE_IMAGE: + media = metadata._open_image(content) + elif post.type == model.Post.TYPE_VIDEO: + media = metadata._run_ffprobe(content) + except Exception as ex: logger.exception(ex) if not config.config["allow_broken_uploads"]: raise InvalidPostContentError("Unable to process image metadata") else: post.canvas_width = None post.canvas_height = None + else: + if post.type == model.Post.TYPE_IMAGE: + dimensions = metadata.resolve_real_image_dimensions(media) + (post.canvas_width, post.canvas_height) = dimensions + post.date_taken = metadata.resolve_image_date_taken(media) + post.camera = metadata.resolve_image_camera(media) + elif post.type == model.Post.TYPE_VIDEO: + dimensions = metadata.resolve_video_dimensions(media) + (post.canvas_width, post.canvas_height) = dimensions + post.date_taken = metadata.resolve_video_date_taken(media) + post.camera = metadata.resolve_video_camera(media) + if (post.canvas_width is not None and post.canvas_width <= 0) or ( post.canvas_height is not None and post.canvas_height <= 0 ): @@ -676,28 +692,9 @@ def update_post_content(post: model.Post, content: Optional[bytes]) -> None: else: post.canvas_width = None post.canvas_height = None + setattr(post, "__content", content) - post.date_taken = None - post.camera = None - - if post.type == model.Post.TYPE_IMAGE: - try: - image_tags = metadata._open_image(content) - except Exception: - pass - - post.date_taken = metadata.resolve_image_date_taken(image_tags) - post.camera = metadata.resolve_image_camera(image_tags) - elif post.type == model.Post.TYPE_VIDEO: - try: - video_tags = metadata._run_ffmpeg(content) - except Exception: - pass - - post.date_taken = metadata.resolve_video_date_taken(video_tags) - post.camera = metadata.resolve_video_camera(video_tags) - def update_post_thumbnail( post: model.Post, content: Optional[bytes] = None