fix: thumbnail-first + megapixel guard to prevent image OOM in PDF/DOCX
Previous draft()+convert()+thumbnail() ordering still caused OOM for
non-JPEG formats (PNG/HEIC) because draft() is a no-op for those formats,
and convert('RGB') forces a full pixel decode regardless.
Replace with thumbnail()-first ordering:
- thumbnail() internally calls draft() for JPEG before decoding
- thumbnail() performs in-place resize without allocating a second
full-resolution buffer
- convert('RGB') is then called on the already-small image (safe for
any format)
Add _COMPRESS_IMAGE_MAX_MEGAPIXELS guard (20 MP): read image dimensions
from headers only (no pixel decode) and return None for images that
exceed the limit. This prevents OOM even for pathologically large files
where draft() provides no benefit (e.g. PNG, TIFF, HEIC).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -7,7 +7,7 @@ Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promje
|
|||||||
```bash
|
```bash
|
||||||
cd /opt/erp/app
|
cd /opt/erp/app
|
||||||
git fetch origin
|
git fetch origin
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main ili git pull --ff-only origin main
|
||||||
|
|
||||||
# rebuild backend + worker + beat + frontend
|
# rebuild backend + worker + beat + frontend
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
|
||||||
|
|||||||
@@ -464,24 +464,29 @@ def _resolve_service_report_tasks(work_order, task_id):
|
|||||||
return [selected_task], selected_task
|
return [selected_task], selected_task
|
||||||
|
|
||||||
|
|
||||||
|
_COMPRESS_IMAGE_MAX_MEGAPIXELS = 20 # preskači slike iznad 20 MP kako bi se izbjegao OOM
|
||||||
|
|
||||||
|
|
||||||
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
||||||
"""
|
"""
|
||||||
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
||||||
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
|
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
|
||||||
|
|
||||||
Koristi Image.draft() + thumbnail() kako bi se izbjeglo učitavanje pune rezolucije
|
Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
|
||||||
velikih fotografija u RAM (sprječava OOM kill Gunicorn workera).
|
za JPEG (smanjuje rezoluciju JPEG dekodera na serveru) te radi in-place resize
|
||||||
|
bez alokacije full-resolution kopije. convert() se poziva tek na već maloj slici.
|
||||||
|
Pixel-count guard preskači slike koje bi i uz draft() prekoračile RAM.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
image_field.open('rb')
|
image_field.open('rb')
|
||||||
with Image.open(image_field) as src:
|
with Image.open(image_field) as img:
|
||||||
# draft() smanjuje dekodiranu rezoluciju za JPEG na serveru (hint, ne garantija)
|
w, h = img.size # čita samo header, bez dekodiranja piksela
|
||||||
src.draft('RGB', (max_width, max_width * 4))
|
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||||
img = src.convert('RGB')
|
return None
|
||||||
# thumbnail() radi in-place i ne alocira novu sliku pune veličine
|
|
||||||
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
|
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
|
||||||
|
rgb = img.convert('RGB')
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
rgb.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return ImageReader(buf)
|
return ImageReader(buf)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -497,17 +502,21 @@ def _compress_image_for_docx(image_field, max_width=1600, quality=80):
|
|||||||
"""
|
"""
|
||||||
Pripremi sliku za python-docx kao JPEG stream razumne veličine.
|
Pripremi sliku za python-docx kao JPEG stream razumne veličine.
|
||||||
|
|
||||||
Koristi Image.draft() + thumbnail() kako bi se izbjeglo učitavanje pune rezolucije
|
Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
|
||||||
velikih fotografija u RAM (sprječava OOM kill Gunicorn workera).
|
za JPEG (smanjuje rezoluciju JPEG dekodera na serveru) te radi in-place resize
|
||||||
|
bez alokacije full-resolution kopije. convert() se poziva tek na već maloj slici.
|
||||||
|
Pixel-count guard preskači slike koje bi i uz draft() prekoračile RAM.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
image_field.open('rb')
|
image_field.open('rb')
|
||||||
with Image.open(image_field) as src:
|
with Image.open(image_field) as img:
|
||||||
src.draft('RGB', (max_width, max_width * 4))
|
w, h = img.size # čita samo header, bez dekodiranja piksela
|
||||||
img = src.convert('RGB')
|
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||||
|
return None
|
||||||
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
|
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
|
||||||
|
rgb = img.convert('RGB')
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
rgb.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf
|
return buf
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
Reference in New Issue
Block a user