154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
# This file is part of photo21
|
|
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
import logging
|
|
import os
|
|
|
|
from datetime import datetime, timezone as dt_timezone
|
|
|
|
from django.conf import settings
|
|
from django.core.files.base import ContentFile
|
|
from django.utils import timezone
|
|
from django.utils.dateparse import parse_datetime
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
import av
|
|
|
|
logger = logging.getLogger("photologue.utils")
|
|
|
|
|
|
def is_photo(file_obj):
|
|
"""Return True if file_obj is a valid image readable by Pillow."""
|
|
try:
|
|
Image.open(file_obj).verify()
|
|
file_obj.seek(0)
|
|
return True
|
|
except Exception:
|
|
file_obj.seek(0)
|
|
return False
|
|
|
|
|
|
def is_video(file_obj):
|
|
"""Return True if file_obj is a valid video container (has at least one video stream)."""
|
|
try:
|
|
container = av.open(file_obj)
|
|
ok = len(container.streams.video) > 0
|
|
container.close()
|
|
file_obj.seek(0)
|
|
return ok
|
|
except Exception:
|
|
file_obj.seek(0)
|
|
return False
|
|
|
|
|
|
def generate_video_thumbnail(video):
|
|
"""Extract the first frame of a video file and save it as the video thumbnail."""
|
|
try:
|
|
container = av.open(video.file.path)
|
|
frame = next(container.decode(video=0))
|
|
img = frame.to_image()
|
|
rotation = frame.rotation
|
|
container.close()
|
|
if rotation:
|
|
img = img.rotate(rotation, expand=True)
|
|
except Exception:
|
|
logger.error("Failed to extract video frame for thumbnail", exc_info=True)
|
|
return
|
|
|
|
try:
|
|
buffer = BytesIO()
|
|
img.save(buffer, "JPEG", quality=70, optimize=True)
|
|
# Preserve directory structure: strip the "videos/" storage prefix so that
|
|
# get_video_storage_path places the thumb alongside the video file.
|
|
rel = video.file.name[len("videos/"):] if video.file.name.startswith("videos/") else video.file.name
|
|
thumb_name = os.path.splitext(rel)[0] + "_thumb.jpg"
|
|
video.thumbnail.save(thumb_name, ContentFile(buffer.getvalue()), save=True)
|
|
except Exception:
|
|
logger.error("Failed to save video thumbnail", exc_info=True)
|
|
|
|
|
|
def capture_date_from_exif(exif_value):
|
|
"""Turn an EXIF "YYYY:MM:DD HH:MM:SS" value into an aware datetime.
|
|
|
|
EXIF dates carry no time zone: they are local time as read by the camera, so
|
|
they are interpreted in the site time zone.
|
|
"""
|
|
try:
|
|
day, hour = str(exif_value).split()
|
|
year, month, dayofmonth = (int(part) for part in day.split(":"))
|
|
hours, minutes, seconds = (int(part) for part in hour.split(":"))
|
|
naive = datetime(year, month, dayofmonth, hours, minutes, seconds)
|
|
except (AttributeError, TypeError, ValueError):
|
|
logger.error("Unreadable EXIF date %r", exif_value)
|
|
return None
|
|
return timezone.make_aware(naive) if settings.USE_TZ else naive
|
|
|
|
|
|
def capture_date_from_video(path):
|
|
"""Read the capture date from a video container, or None."""
|
|
try:
|
|
container = av.open(path)
|
|
raw = container.metadata.get("creation_time")
|
|
container.close()
|
|
except Exception:
|
|
logger.error("Failed to read video metadata from %s", path, exc_info=True)
|
|
return None
|
|
if not raw:
|
|
return None
|
|
parsed = parse_datetime(raw)
|
|
if parsed is None:
|
|
return None
|
|
if settings.USE_TZ and timezone.is_naive(parsed):
|
|
# Containers usually store UTC; a naive value is the camera's local time.
|
|
parsed = timezone.make_aware(parsed)
|
|
return parsed
|
|
|
|
|
|
def capture_date_from_upload(last_modified_ms):
|
|
"""Turn a browser File.lastModified value (ms since epoch) into a datetime."""
|
|
try:
|
|
stamp = int(last_modified_ms) / 1000
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not stamp:
|
|
return None
|
|
return datetime.fromtimestamp(stamp, tz=dt_timezone.utc)
|
|
|
|
|
|
def order_media_by_capture(items):
|
|
"""Sort media in capture order, and fill in the dates that are missing.
|
|
|
|
A file name is a reliable chronological clue inside one photographer's batch
|
|
(most cameras number their files in order) and meaningless between two of
|
|
them. So media without a capture date borrow the date of the closest dated
|
|
file of the same owner, which puts them back in the middle of the sequence
|
|
instead of exiling them at the end of the gallery. Only media whose owner has
|
|
no dated file at all end up at the end, in file name order.
|
|
|
|
Every item is annotated with `capture_date` and `capture_date_is_guessed`.
|
|
"""
|
|
by_owner = {}
|
|
for item in items:
|
|
item.capture_date = item.date_taken
|
|
item.capture_date_is_guessed = False
|
|
by_owner.setdefault(item.owner_id, []).append(item)
|
|
|
|
dated, undated = [], []
|
|
for batch in by_owner.values():
|
|
batch.sort(key=lambda item: (item.title, item.pk))
|
|
# Forwards, then backwards for the files that come before the first date.
|
|
for sequence in (batch, batch[::-1]):
|
|
known = None
|
|
for item in sequence:
|
|
if item.capture_date is not None:
|
|
known = item.capture_date
|
|
elif known is not None:
|
|
item.capture_date = known
|
|
item.capture_date_is_guessed = True
|
|
for item in batch:
|
|
(dated if item.capture_date is not None else undated).append(item)
|
|
|
|
dated.sort(key=lambda item: (item.capture_date, item.owner_id, item.title, item.pk))
|
|
undated.sort(key=lambda item: (item.owner_id, item.title, item.pk))
|
|
return dated + undated
|