# 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