From 24abd8d32f0cc2237a18ec7011b6798f0d08bb8c Mon Sep 17 00:00:00 2001 From: Guillem Hernandez Sola Date: Thu, 9 Apr 2026 16:53:00 +0200 Subject: [PATCH] fix(sync): compress oversized tweet images before Bluesky upload --- twitter2bsky_daemon.py | 91 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/twitter2bsky_daemon.py b/twitter2bsky_daemon.py index 40ddc5e..4825ac1 100644 --- a/twitter2bsky_daemon.py +++ b/twitter2bsky_daemon.py @@ -28,6 +28,12 @@ BSKY_TEXT_MAX_LENGTH = 275 VIDEO_MAX_DURATION_SECONDS = 179 MAX_VIDEO_UPLOAD_SIZE_MB = 45 +# Tweet image upload safety limits +BSKY_IMAGE_MAX_BYTES = 950 * 1024 +BSKY_IMAGE_MAX_DIMENSION = 2000 +BSKY_IMAGE_MIN_JPEG_QUALITY = 45 + +# External card thumbnail limits EXTERNAL_THUMB_MAX_BYTES = 950 * 1024 EXTERNAL_THUMB_MAX_DIMENSION = 1200 EXTERNAL_THUMB_MIN_JPEG_QUALITY = 40 @@ -773,6 +779,66 @@ def upload_blob_with_retry(client, binary_data, media_label="media"): return None +def compress_post_image_to_limit(image_bytes, max_bytes=BSKY_IMAGE_MAX_BYTES): + """ + Compress/resize normal tweet images so they fit within Bluesky image blob limits. + Returns JPEG bytes or None. + """ + try: + with Image.open(io.BytesIO(image_bytes)) as img: + img = img.convert("RGB") + + width, height = img.size + max_dim = max(width, height) + + if max_dim > BSKY_IMAGE_MAX_DIMENSION: + scale = BSKY_IMAGE_MAX_DIMENSION / max_dim + new_size = (max(1, int(width * scale)), max(1, int(height * scale))) + img = img.resize(new_size, Image.LANCZOS) + logging.info(f"🖼️ Resized post image to {new_size[0]}x{new_size[1]}") + + for quality in [90, 82, 75, 68, 60, 52, BSKY_IMAGE_MIN_JPEG_QUALITY]: + out = io.BytesIO() + img.save(out, format="JPEG", quality=quality, optimize=True, progressive=True) + data = out.getvalue() + + logging.info( + f"🖼️ Post image candidate size at JPEG quality {quality}: " + f"{len(data)} bytes ({len(data) / 1024:.2f} KB)" + ) + + if len(data) <= max_bytes: + return data + + for target_dim in [1800, 1600, 1400, 1200, 1000]: + resized = img.copy() + width, height = resized.size + max_dim = max(width, height) + + if max_dim > target_dim: + scale = target_dim / max_dim + new_size = (max(1, int(width * scale)), max(1, int(height * scale))) + resized = resized.resize(new_size, Image.LANCZOS) + + for quality in [68, 60, 52, BSKY_IMAGE_MIN_JPEG_QUALITY]: + out = io.BytesIO() + resized.save(out, format="JPEG", quality=quality, optimize=True, progressive=True) + data = out.getvalue() + + logging.info( + f"🖼️ Post image resized to <= {target_dim}px at quality {quality}: " + f"{len(data)} bytes ({len(data) / 1024:.2f} KB)" + ) + + if len(data) <= max_bytes: + return data + + except Exception as e: + logging.warning(f"Could not compress post image: {repr(e)}") + + return None + + def get_blob_from_url(media_url, client, http_client): try: r = http_client.get(media_url, timeout=MEDIA_DOWNLOAD_TIMEOUT, follow_redirects=True) @@ -785,7 +851,30 @@ def get_blob_from_url(media_url, client, http_client): logging.warning(f"Could not fetch media {media_url}: empty response body") return None - return upload_blob_with_retry(client, content, media_label=media_url) + content_type = (r.headers.get("content-type") or "").lower() + upload_bytes = content + + if content_type.startswith("image/"): + original_size = len(content) + logging.info(f"🖼️ Downloaded post image {media_url} ({original_size} bytes / {original_size / 1024:.2f} KB)") + + if original_size > BSKY_IMAGE_MAX_BYTES: + logging.info( + f"🖼️ Post image exceeds safe Bluesky limit " + f"({original_size} bytes > {BSKY_IMAGE_MAX_BYTES} bytes). Compressing..." + ) + compressed = compress_post_image_to_limit(content, BSKY_IMAGE_MAX_BYTES) + if compressed: + upload_bytes = compressed + logging.info( + f"✅ Post image compressed to {len(upload_bytes)} bytes " + f"({len(upload_bytes) / 1024:.2f} KB)" + ) + else: + logging.warning(f"⚠️ Could not compress post image to safe limit: {media_url}") + return None + + return upload_blob_with_retry(client, upload_bytes, media_label=media_url) except Exception as e: logging.warning(f"Could not fetch media {media_url}: {repr(e)}")