This commit is contained in:
amras0000 2024-11-22 05:22:35 +00:00 committed by GitHub
commit 1a21698bec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 155 additions and 36 deletions

View file

@ -13,6 +13,7 @@ RUN apk --no-cache add \
libheif-dev \ libheif-dev \
libavif \ libavif \
libavif-dev \ libavif-dev \
exiftool \
ffmpeg \ ffmpeg \
# from requirements.txt: # from requirements.txt:
py3-yaml \ py3-yaml \

View file

@ -7,7 +7,7 @@ from typing import Any, Callable, List, Optional, Set, Tuple
import HeifImagePlugin import HeifImagePlugin
import numpy as np import numpy as np
import pillow_avif import pillow_avif
from PIL import Image from PIL import Image, ImageOps
from szurubooru import config, errors from szurubooru import config, errors
@ -40,7 +40,7 @@ NpMatrix = np.ndarray
def _preprocess_image(content: bytes) -> NpMatrix: def _preprocess_image(content: bytes) -> NpMatrix:
try: try:
img = Image.open(BytesIO(content)) img = ImageOps.exif_transpose(Image.open(BytesIO(content)))
return np.asarray(img.convert("L"), dtype=np.uint8) return np.asarray(img.convert("L"), dtype=np.uint8)
except (IOError, ValueError): except (IOError, ValueError):
raise errors.ProcessingError( raise errors.ProcessingError(

View file

@ -5,7 +5,8 @@ import re
import shlex import shlex
import subprocess import subprocess
from io import BytesIO from io import BytesIO
from typing import List from typing import List, Optional
import datetime
import HeifImagePlugin import HeifImagePlugin
import pillow_avif import pillow_avif
@ -16,6 +17,29 @@ from szurubooru.func import mime, util
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Refer to:
# https://exiftool.org/TagNames/EXIF.html
# https://ffmpeg.org/ffmpeg-filters.html#transpose-1
# https://www.impulseadventure.com/photo/images/orient_flag.gif
ORIENTATION_FILTER = {
"Horizontal (normal)": "null",
"Mirror Horizontal": "transpose=clock_flip,transpose=cclock",
"Rotate 180": "transpose=clock,transpose=clock",
"Mirror vertical": "transpose=clock_flip,transpose=clock",
"Mirror horizontal and rotate 270 CW": "transpose=cclock_flip,transpose=clock,transpose=clock",
"Rotate 90 CW": "transpose=clock",
"Mirror horizontal and rotate 90 CW": "transpose=clock_flip,transpose=clock,transpose=clock",
"Rotate 270 CW": "transpose=cclock",
}
ORTHOGONAL_ORIENTATIONS = (
"Mirror horizontal and rotate 270 CW",
"Rotate 90 CW",
"Mirror horizontal and rotate 90 CW",
"Rotate 270 CW",
)
def convert_heif_to_png(content: bytes) -> bytes: def convert_heif_to_png(content: bytes) -> bytes:
img = PILImage.open(BytesIO(content)) img = PILImage.open(BytesIO(content))
@ -31,27 +55,72 @@ class Image:
@property @property
def width(self) -> int: def width(self) -> int:
return self.info["streams"][0]["width"] if self._is_orthogonal():
return self.info["ImageHeight"]
return self.info["ImageWidth"]
@property @property
def height(self) -> int: def height(self) -> int:
return self.info["streams"][0]["height"] if self._is_orthogonal():
return self.info["ImageWidth"]
return self.info["ImageHeight"]
@property @property
def frames(self) -> int: def duration(self) -> Optional[datetime.timedelta]:
return self.info["streams"][0]["nb_read_frames"] try:
duration_data = self.info["Duration"]
except KeyError:
return None
time_formats = [
"%H:%M:%S",
"%H:%M:%S.%f",
"%M:%S",
"%M:%S.%f",
"%S.%f s",
]
for time_format in time_formats:
try:
duration = datetime.datetime.strptime(
duration_data, time_format).time()
return datetime.timedelta(
hours=duration.hour,
minutes=duration.minute,
seconds = duration.second,
microseconds=duration.microsecond)
except ValueError:
pass
logger.warning("Unexpected time format(duration=%r)", duration_data)
return None
def _orientation_filter(self) -> str:
# This filter should be omitted in ffmpeg>=6.0,
# where it is automatically applied.
try:
return ORIENTATION_FILTER[self.info["Orientation"]]
except KeyError:
return "null"
def _is_orthogonal(self) -> bool:
try:
return self.info["Orientation"] in ORTHOGONAL_ORIENTATIONS
except KeyError:
return False
def resize_fill(self, width: int, height: int) -> None: def resize_fill(self, width: int, height: int) -> 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)
filters = "{orientation},scale='{width}:{height}'".format(
orientation=self._orientation_filter(), width=width, height=height)
cli = [ cli = [
"-i", "-i",
"{path}", "{path}",
"-f", "-f",
"image2", "image2",
"-filter:v", "-filter:v",
"scale='{width}:{height}'".format(width=width, height=height), filters,
"-map", "-map",
"0:v:0", "0:v:0",
"-vframes", "-vframes",
@ -60,15 +129,13 @@ class Image:
"png", "png",
"-", "-",
] ]
if ( duration = self.duration
"duration" in self.info["format"] if duration is not None and self.info["FileType"] != "SWF":
and self.info["format"]["format_name"] != "swf" total_seconds = duration.total_seconds()
): if total_seconds > 3:
duration = float(self.info["format"]["duration"])
if duration > 3:
cli = [ cli = [
"-ss", "-ss",
"%d" % math.floor(duration * 0.3), "%d" % math.floor(total_seconds * 0.3),
] + cli ] + cli
content = self._execute(cli, ignore_error_if_data=True) content = self._execute(cli, ignore_error_if_data=True)
if not content: if not content:
@ -83,6 +150,8 @@ class Image:
"{path}", "{path}",
"-f", "-f",
"image2", "image2",
"-filter:v",
self._orientation_filter(),
"-map", "-map",
"0:v:0", "0:v:0",
"-vframes", "-vframes",
@ -105,7 +174,7 @@ class Image:
"-f", "-f",
"image2", "image2",
"-filter_complex", "-filter_complex",
"overlay", "overlay," + self._orientation_filter(),
"-map", "-map",
"0:v:0", "0:v:0",
"-vframes", "-vframes",
@ -117,6 +186,7 @@ class Image:
) )
def to_webm(self) -> bytes: def to_webm(self) -> bytes:
filters = self._orientation_filter()
with util.create_temp_file_path(suffix=".log") as phase_log_path: with util.create_temp_file_path(suffix=".log") as phase_log_path:
# Pass 1 # Pass 1
self._execute( self._execute(
@ -127,6 +197,8 @@ class Image:
"1", "1",
"-passlogfile", "-passlogfile",
phase_log_path, phase_log_path,
"-filter:v",
filters,
"-vcodec", "-vcodec",
"libvpx-vp9", "libvpx-vp9",
"-crf", "-crf",
@ -151,6 +223,8 @@ class Image:
"2", "2",
"-passlogfile", "-passlogfile",
phase_log_path, phase_log_path,
"-filter:v",
filters,
"-vcodec", "-vcodec",
"libvpx-vp9", "libvpx-vp9",
"-crf", "-crf",
@ -179,6 +253,10 @@ class Image:
height = self.height - 1 height = self.height - 1
altered_dimensions = True altered_dimensions = True
filters = self._orientation_filter()
if altered_dimensions:
filters += ",scale='%d:%d'" % (width, height)
args = [ args = [
"-i", "-i",
"{path}", "{path}",
@ -198,11 +276,10 @@ class Image:
"aac", "aac",
"-f", "-f",
"mp4", "mp4",
"-filter:v",
filters,
] ]
if altered_dimensions:
args += ["-filter:v", "scale='%d:%d'" % (width, height)]
self._execute(args + ["-y", mp4_temp_path]) self._execute(args + ["-y", mp4_temp_path])
with open(mp4_temp_path, "rb") as mp4_temp: with open(mp4_temp_path, "rb") as mp4_temp:
@ -274,8 +351,10 @@ class Image:
with util.create_temp_file(suffix="." + extension) as handle: with util.create_temp_file(suffix="." + extension) as handle:
handle.write(self.content) handle.write(self.content)
handle.flush() handle.flush()
cli = [program, "-loglevel", "32" if get_logs else "24"] + cli
cli = [part.format(path=handle.name) for part in cli] cli = [part.format(path=handle.name) for part in cli]
if program in ("ffmpeg", "ffprobe"):
cli = ["-loglevel", "32" if get_logs else "24"] + cli
cli = [program] + cli
proc = subprocess.Popen( proc = subprocess.Popen(
cli, cli,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@ -285,7 +364,7 @@ class Image:
out, err = proc.communicate() out, err = proc.communicate()
if proc.returncode != 0: if proc.returncode != 0:
logger.warning( logger.warning(
"Failed to execute ffmpeg command (cli=%r, err=%r)", "Failed to execute command (cli=%r, err=%r)",
" ".join(shlex.quote(arg) for arg in cli), " ".join(shlex.quote(arg) for arg in cli),
err, err,
) )
@ -298,25 +377,25 @@ class Image:
return err if get_logs else out return err if get_logs else out
def _reload_info(self) -> None: def _reload_info(self) -> None:
self.info = json.loads( exiftool_data = json.loads(
self._execute( self._execute(
[ [
"-i",
"{path}", "{path}",
"-of", "-json",
"json",
"-select_streams",
"v",
"-show_format",
"-show_streams",
], ],
program="ffprobe", program="exiftool",
).decode("utf-8") ).decode("utf-8")
) )
assert "format" in self.info
assert "streams" in self.info if len(exiftool_data) != 1:
if len(self.info["streams"]) < 1: logger.warning("Unexpected output from exiftool")
logger.warning("The video contains no video streams.")
self.info = exiftool_data[0]
if "Error" in self.info:
raise errors.ProcessingError( raise errors.ProcessingError(
"The video contains no video streams." "Error in metadata:" + str(self.info["Error"]))
)
if "Warning" in self.info:
raise errors.ProcessingError(
"Warning in metadata:" + str(self.info["Warning"]))

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -773,6 +773,45 @@ def test_update_post_content_convert_heif_to_png_when_processing(
assert os.path.exists(generated_path) assert os.path.exists(generated_path)
def test_update_post_content_with_exif_orientation(
tmpdir, config_injector, read_asset, post_factory):
# exif.jpg is a copy of jpeg.jpg,
# rotated counter-clockwise by 90 degrees,
# and assigned the EXIF Orientation tag "Rotate 90 CW"
config_injector(
{
"data_dir": str(tmpdir.mkdir("data")),
"thumbnails": {
"post_width": 300,
"post_height": 300,
},
"secret": "test",
"allow_broken_uploads": False,
}
)
post = post_factory(id=1)
db.session.add(post)
posts.update_post_content(post, read_asset("exif.jpg"))
thumbnail_path = (
"{}/data/generated-thumbnails/1_244c8840887984c4.jpg".format(tmpdir))
db.session.flush()
assert post.canvas_width == 100
assert post.canvas_height == 75
with open(thumbnail_path, "rb") as handle:
thumbnail = images.Image(handle.read())
assert thumbnail.width == 400
assert thumbnail.height == 300
search_result = posts.search_by_image(read_asset("jpeg.jpg"))
assert len(search_result) == 1
search_distance, search_post = search_result[0]
assert search_post.post_id == post.post_id
assert abs(search_distance) < 0.15
def test_update_post_tags(tag_factory): def test_update_post_tags(tag_factory):
post = model.Post() post = model.Post()
with patch("szurubooru.func.tags.get_or_create_tags_by_names"): with patch("szurubooru.func.tags.get_or_create_tags_by_names"):