← back to Dw Image Shrink
shrink_master.py
81 lines
#!/usr/bin/env python3
"""Re-encode a product-image master to best-practice web size.
Rules (see plan): longest edge <= MAX_EDGE (default 2048), JPEG q82, strip EXIF,
sRGB. Guards: NEVER re-encode an animated GIF or video; keep true-alpha PNGs as
optimized PNG (don't JPEG them); never upscale; report a 'no_win' if the encoded
file isn't meaningfully smaller. Prints a JSON verdict on stdout.
Usage: shrink_master.py --in <path> --out <path> [--max-edge 2048] [--quality 82]
Exit 0 always; read the JSON {action, reason, in_bytes, out_bytes, w,h,new_w,new_h}.
"""
import argparse, json, os, sys
from PIL import Image, ImageOps
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--in', dest='inp', required=True)
ap.add_argument('--out', dest='out', required=True)
ap.add_argument('--max-edge', type=int, default=2048)
ap.add_argument('--quality', type=int, default=82)
ap.add_argument('--min-win', type=float, default=0.90,
help='out must be < this * in_bytes to count as a win')
a = ap.parse_args()
in_bytes = os.path.getsize(a.inp)
try:
img = Image.open(a.inp)
except Exception as e:
return out({'action': 'skip', 'reason': 'unreadable:' + str(e)[:60], 'in_bytes': in_bytes})
fmt = (img.format or '').upper()
w, h = img.size
# GUARD: animated GIF/webp -> never destroy
if getattr(img, 'is_animated', False) or getattr(img, 'n_frames', 1) > 1:
return out({'action': 'skip', 'reason': 'animated', 'in_bytes': in_bytes, 'w': w, 'h': h})
# apply EXIF orientation then drop metadata
img = ImageOps.exif_transpose(img)
w, h = img.size
# detect true alpha (not just mode)
has_alpha = img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info)
real_alpha = False
if has_alpha:
alpha = img.convert('RGBA').getchannel('A')
real_alpha = alpha.getextrema()[0] < 255
longest = max(w, h)
scale = min(1.0, a.max_edge / longest) # never upscale
new_w, new_h = (round(w * scale), round(h * scale)) if scale < 1.0 else (w, h)
if scale < 1.0:
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
try:
if real_alpha:
# keep PNG, optimize; strip metadata by re-saving without exif
img.convert('RGBA').save(a.out, format='PNG', optimize=True)
enc_fmt = 'PNG'
else:
img.convert('RGB').save(a.out, format='JPEG', quality=a.quality,
optimize=True, progressive=True)
enc_fmt = 'JPEG'
except Exception as e:
return out({'action': 'skip', 'reason': 'encode_fail:' + str(e)[:60], 'in_bytes': in_bytes, 'w': w, 'h': h})
out_bytes = os.path.getsize(a.out)
win = out_bytes < in_bytes * a.min_win
if not win:
try: os.remove(a.out)
except OSError: pass
return out({'action': 'skip', 'reason': 'no_win', 'in_bytes': in_bytes,
'out_bytes': out_bytes, 'w': w, 'h': h})
return out({'action': 'encoded', 'reason': enc_fmt, 'in_bytes': in_bytes,
'out_bytes': out_bytes, 'w': w, 'h': h, 'new_w': new_w, 'new_h': new_h})
def out(d):
print(json.dumps(d)); sys.exit(0)
if __name__ == '__main__':
main()