← 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()