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 import logging
from datetime import datetime from datetime import datetime
from subprocess import PIPE, Popen from subprocess import PIPE, Popen
from typing import Optional, Union from typing import Optional, Tuple, Union
from exif import Image from exif import Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BASE_FFMPEG_COMMAND = [ BASE_FFPROBE_COMMAND = [
"ffprobe", "ffprobe",
"-loglevel", "-loglevel",
"8", "8",
"-print_format", "-print_format",
"json", "json",
"-show_format", "-show_format",
"-show_streams",
] ]
@ -28,10 +29,10 @@ def _open_image(content: bytes) -> Image:
return tags return tags
def _run_ffmpeg(content: Union[bytes, str]) -> Image: def _run_ffprobe(content: Union[bytes, str]) -> Image:
if isinstance(content, bytes): if isinstance(content, bytes):
proc = Popen( proc = Popen(
BASE_FFMPEG_COMMAND + ["-"], BASE_FFPROBE_COMMAND + ["-"],
stdin=PIPE, stdin=PIPE,
stdout=PIPE, stdout=PIPE,
stderr=PIPE, stderr=PIPE,
@ -40,7 +41,7 @@ def _run_ffmpeg(content: Union[bytes, str]) -> Image:
output = proc.communicate(input=content)[0] output = proc.communicate(input=content)[0]
else: else:
proc = Popen( proc = Popen(
BASE_FFMPEG_COMMAND + [content], BASE_FFPROBE_COMMAND + [content],
stdout=PIPE, stdout=PIPE,
stderr=PIPE, stderr=PIPE,
) )
@ -81,7 +82,7 @@ def resolve_video_date_taken(
if isinstance(content, dict): if isinstance(content, dict):
tags = content tags = content
else: else:
tags = _run_ffmpeg(content) tags = _run_ffprobe(content)
creation_time = tags["creation_time"] creation_time = tags["creation_time"]
except Exception: except Exception:
@ -116,7 +117,7 @@ def resolve_video_camera(content: Union[bytes, str, dict]) -> Optional[str]:
if isinstance(content, dict): if isinstance(content, dict):
tags = content tags = content
else: else:
tags = _run_ffmpeg(content) tags = _run_ffprobe(content)
# List of tuples where only one value can be valid # List of tuples where only one value can be valid
option_tuples = ( option_tuples = (
@ -138,3 +139,45 @@ def resolve_video_camera(content: Union[bytes, str, dict]) -> Optional[str]:
return None return None
else: else:
return " ".join(camera_string) 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.signature = generate_post_signature(post, content)
post.file_size = len(content) post.file_size = len(content)
post.date_taken = None
post.camera = None
try: try:
image = images.Image(content) if post.type == model.Post.TYPE_IMAGE:
post.canvas_width = image.width media = metadata._open_image(content)
post.canvas_height = image.height elif post.type == model.Post.TYPE_VIDEO:
except errors.ProcessingError as ex: media = metadata._run_ffprobe(content)
except Exception as ex:
logger.exception(ex) logger.exception(ex)
if not config.config["allow_broken_uploads"]: if not config.config["allow_broken_uploads"]:
raise InvalidPostContentError("Unable to process image metadata") raise InvalidPostContentError("Unable to process image metadata")
else: else:
post.canvas_width = None post.canvas_width = None
post.canvas_height = 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 ( if (post.canvas_width is not None and post.canvas_width <= 0) or (
post.canvas_height is not None and post.canvas_height <= 0 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: else:
post.canvas_width = None post.canvas_width = None
post.canvas_height = None post.canvas_height = None
setattr(post, "__content", content) 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( def update_post_thumbnail(
post: model.Post, content: Optional[bytes] = None post: model.Post, content: Optional[bytes] = None