diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index 50fe479..fb9d0e3 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -860,8 +860,26 @@ class Importer: pass def _transparency_pct(self, source: Path) -> float: - """Fraction of fully-transparent pixels in the image. 0.0 if no alpha.""" + """Fraction of fully-transparent pixels in the image. 0.0 if no alpha. + + For animated formats (multi-frame WebP / GIF / APNG), short-circuit + to 0.0 instead of decoding every frame. PIL's `getchannel("A")` + forces a full decode of all frames in an animated image, which for + a large animated WebP takes 5+ minutes and blows past the Celery + soft+hard time limits (300s/360s → SIGKILL). Operator-flagged + 2026-05-26. Transparency analysis on a multi-frame image isn't + meaningful for art-curation purposes anyway — different frames + have different alpha — so the existing too_transparent skip rule + is bypassed entirely for animated content. + """ with Image.open(source) as im: + if getattr(im, "is_animated", False): + log.info( + "skipping transparency check for animated image %s " + "(n_frames=%d) — avoids multi-frame decode timeout", + source, getattr(im, "n_frames", 0), + ) + return 0.0 if im.mode not in ("RGBA", "LA") and not ( im.mode == "P" and "transparency" in im.info ): diff --git a/backend/app/utils/phash.py b/backend/app/utils/phash.py index 8d00cb3..a610e96 100644 --- a/backend/app/utils/phash.py +++ b/backend/app/utils/phash.py @@ -13,8 +13,21 @@ HASH_SIZE = 8 def compute_phash(pil_image) -> str | None: """Perceptual hash of an opened PIL image, as a hex string. None on any - failure (videos/unreadable/non-image).""" + failure (videos/unreadable/non-image). + + For animated images (multi-frame WebP/GIF/APNG), explicitly seek to + frame 0 first. Without this, some PIL operations downstream of + imagehash.phash (convert("L"), resize) can iterate all frames and + blow past Celery's hard time limit on large animations + (operator-flagged 2026-05-26 against animated WebPs). The pHash of + frame 0 is the conventional choice for animated content. + """ try: + if getattr(pil_image, "is_animated", False): + try: + pil_image.seek(0) + except Exception: + pass return str(imagehash.phash(pil_image, hash_size=HASH_SIZE)) except Exception: return None