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
This commit is contained in:
skybldev 2022-01-01 17:38:23 -05:00
parent 2c1e25c158
commit a4ea05a0e4
2 changed files with 71 additions and 31 deletions

View file

@ -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

View file

@ -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