Merge 4ad90007aa
into 61b9f81e39
This commit is contained in:
commit
1a21698bec
5 changed files with 155 additions and 36 deletions
|
@ -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 \
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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"]))
|
BIN
server/szurubooru/tests/assets/exif.jpg
Normal file
BIN
server/szurubooru/tests/assets/exif.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -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"):
|
||||||
|
|
Reference in a new issue