From c0c9e56fb9f163634082538956ed831cee82327e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 00:45:08 -0400 Subject: [PATCH] =?UTF-8?q?fix(importer):=20skip=20transparency=20check=20?= =?UTF-8?q?on=20animated=20images=20(operator-flagged=202026-05-26:=20anim?= =?UTF-8?q?ated=20WebP=20triggered=205+=20min=20PIL=20multi-frame=20decode?= =?UTF-8?q?=20=E2=86=92=20Celery=20hard-timeout=20SIGKILL);=20compute=5Fph?= =?UTF-8?q?ash=20seeks=20frame=200=20defensively=20=E2=80=94=20Co-Authored?= =?UTF-8?q?-By:=20Claude=20Opus=204.7=20(1M=20context)=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/importer.py | 20 +++++++++++++++++++- backend/app/utils/phash.py | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) 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