fix(sync): compress oversized tweet images before Bluesky upload
This commit is contained in:
@@ -28,6 +28,12 @@ BSKY_TEXT_MAX_LENGTH = 275
|
|||||||
VIDEO_MAX_DURATION_SECONDS = 179
|
VIDEO_MAX_DURATION_SECONDS = 179
|
||||||
MAX_VIDEO_UPLOAD_SIZE_MB = 45
|
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_BYTES = 950 * 1024
|
||||||
EXTERNAL_THUMB_MAX_DIMENSION = 1200
|
EXTERNAL_THUMB_MAX_DIMENSION = 1200
|
||||||
EXTERNAL_THUMB_MIN_JPEG_QUALITY = 40
|
EXTERNAL_THUMB_MIN_JPEG_QUALITY = 40
|
||||||
@@ -773,6 +779,66 @@ def upload_blob_with_retry(client, binary_data, media_label="media"):
|
|||||||
return None
|
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):
|
def get_blob_from_url(media_url, client, http_client):
|
||||||
try:
|
try:
|
||||||
r = http_client.get(media_url, timeout=MEDIA_DOWNLOAD_TIMEOUT, follow_redirects=True)
|
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")
|
logging.warning(f"Could not fetch media {media_url}: empty response body")
|
||||||
return None
|
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:
|
except Exception as e:
|
||||||
logging.warning(f"Could not fetch media {media_url}: {repr(e)}")
|
logging.warning(f"Could not fetch media {media_url}: {repr(e)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user