From a76715064ab6f2b048acde7283135e55ce5f6abf Mon Sep 17 00:00:00 2001 From: Guillem Hernandez Sola Date: Sun, 5 Apr 2026 15:31:32 +0000 Subject: [PATCH] Some video bitrate improvements --- twitter2bsky_daemon.py | 147 +++++++++++++++++++++++++++++++++++------ 1 file changed, 127 insertions(+), 20 deletions(-) diff --git a/twitter2bsky_daemon.py b/twitter2bsky_daemon.py index 4409764..eb563fa 100644 --- a/twitter2bsky_daemon.py +++ b/twitter2bsky_daemon.py @@ -20,9 +20,23 @@ STATE_PATH = "twitter2bsky_state.json" SCRAPE_TWEET_LIMIT = 30 DEDUPE_BSKY_LIMIT = 30 TWEET_MAX_AGE_DAYS = 3 -VIDEO_MAX_DURATION_SECONDS = 179 BSKY_TEXT_MAX_LENGTH = 275 +# Video handling notes: +# - Bluesky video support is constrained not just by duration, but also by +# practical upload limits like final file size, bitrate, resolution, and +# server-side proxy/PDS body-size caps. +# - Custom PDSes such as eurosky.social may accept images fine but fail on +# larger video blob uploads. +# - The public video limits discussed in Bluesky tooling references are useful, +# but in practice the safest approach is to: +# 1. cap duration +# 2. compress aggressively +# 3. log final file size +# 4. skip obviously too-large uploads +VIDEO_MAX_DURATION_SECONDS = 179 +MAX_VIDEO_UPLOAD_SIZE_MB = 45 + BSKY_BLOB_UPLOAD_MAX_RETRIES = 5 BSKY_BLOB_UPLOAD_BASE_DELAY = 10 BSKY_BLOB_UPLOAD_MAX_DELAY = 300 @@ -170,6 +184,11 @@ def get_rate_limit_wait_seconds(error_obj, default_delay): def upload_blob_with_retry(client, binary_data, media_label="media"): """ Retry Bluesky blob upload when rate-limited. + + Diagnostic note: + On alternate PDSes, large video uploads may fail for reasons other than + 429 rate limits. In those cases we log the exception more explicitly and + return None so the caller can degrade gracefully. """ last_exception = None @@ -184,7 +203,15 @@ def upload_blob_with_retry(client, binary_data, media_label="media"): is_rate_limited = "429" in error_text or "RateLimitExceeded" in error_text if not is_rate_limited: - logging.warning(f"Could not upload {media_label}: {e}") + logging.warning(f"Could not upload {media_label}: {repr(e)}") + + if hasattr(e, "response") and e.response is not None: + try: + logging.warning(f"Upload response status: {e.response.status_code}") + logging.warning(f"Upload response body: {e.response.text}") + except Exception: + pass + return None backoff_delay = min( @@ -201,10 +228,10 @@ def upload_blob_with_retry(client, binary_data, media_label="media"): time.sleep(wait_seconds) else: logging.warning( - f"❌ Exhausted blob upload retries for {media_label} after rate limiting: {e}" + f"❌ Exhausted blob upload retries for {media_label} after rate limiting: {repr(e)}" ) - logging.warning(f"Could not upload {media_label}: {last_exception}") + logging.warning(f"Could not upload {media_label}: {repr(last_exception)}") return None @@ -226,20 +253,53 @@ def get_blob_from_url(media_url, client, http_client): return upload_blob_with_retry(client, content, media_label=media_url) except Exception as e: - logging.warning(f"Could not fetch media {media_url}: {e}") + logging.warning(f"Could not fetch media {media_url}: {repr(e)}") return None def get_blob_from_file(file_path, client): + """ + Upload a local file as a Bluesky blob. + + Diagnostic notes: + - We log the final file size because this is often the real reason a custom + PDS rejects video uploads. + - Self-hosted or alternate services may have stricter proxy/body-size limits + than bsky.social. + """ try: + if not os.path.exists(file_path): + logging.warning(f"Could not upload local file {file_path}: file does not exist") + return None + + file_size = os.path.getsize(file_path) + file_size_mb = file_size / (1024 * 1024) + + logging.info(f"📦 Uploading local file {file_path} ({file_size_mb:.2f} MB)") + + if file_path.lower().endswith(".mp4") and file_size_mb > MAX_VIDEO_UPLOAD_SIZE_MB: + logging.warning( + f"Could not upload local file {file_path}: " + f"file too large ({file_size_mb:.2f} MB > {MAX_VIDEO_UPLOAD_SIZE_MB} MB)" + ) + return None + with open(file_path, "rb") as f: binary_data = f.read() return upload_blob_with_retry(client, binary_data, media_label=file_path) except Exception as e: - logging.warning(f"Could not upload local file {file_path}: {e}") - return None + logging.warning(f"Could not upload local file {file_path}: {repr(e)}") + + if hasattr(e, "response") and e.response is not None: + try: + logging.warning(f"Upload response status: {e.response.status_code}") + logging.warning(f"Upload response body: {e.response.text}") + except Exception: + pass + + return None def prepare_post_text(text): @@ -924,8 +984,24 @@ def extract_video_url_from_tweet_page(context, tweet_url): # --- Video Processing --- def download_and_crop_video(video_url, output_path): + """ + Download, trim, and compress video before upload. + + Practical comments based on Bluesky video limits and real-world custom PDS behavior: + - Duration alone is not enough; final file size matters a lot. + - A 90-second 1080x1920 video can still be too large for alternate services. + - We therefore: + 1. download the source + 2. trim to VIDEO_MAX_DURATION_SECONDS + 3. re-encode with tighter settings + 4. scale down to max width 720 + 5. log final file size + - This improves compatibility with services like eurosky.social that may have + stricter body-size or timeout limits than bsky.social. + """ temp_input = output_path.replace(".mp4", "_source.mp4") - temp_output = output_path.replace(".mp4", "_cropped.mp4") + temp_trimmed = output_path.replace(".mp4", "_trimmed.mp4") + temp_output = output_path.replace(".mp4", "_compressed.mp4") try: logging.info(f"⬇️ Downloading video source with ffmpeg: {video_url}") @@ -953,11 +1029,7 @@ def download_and_crop_video(video_url, output_path): temp_input, ] - download_result = subprocess.run( - download_cmd, - capture_output=True, - text=True - ) + download_result = subprocess.run(download_cmd, capture_output=True, text=True) if download_result.returncode != 0: logging.error(f"❌ ffmpeg download failed:\n{download_result.stderr}") @@ -985,29 +1057,64 @@ def download_and_crop_video(video_url, output_path): cropped_clip = video_clip.subclip(0, end_time) cropped_clip.write_videofile( - temp_output, + temp_trimmed, codec="libx264", audio_codec="aac", + preset="veryfast", + bitrate="1800k", + audio_bitrate="128k", logger=None ) video_clip.close() cropped_clip.close() - if not os.path.exists(temp_output) or os.path.getsize(temp_output) == 0: - logging.error("❌ Cropped video output is missing or empty.") + if not os.path.exists(temp_trimmed) or os.path.getsize(temp_trimmed) == 0: + logging.error("❌ Trimmed video output is missing or empty.") return None + trimmed_size_mb = os.path.getsize(temp_trimmed) / (1024 * 1024) + logging.info(f"📦 Trimmed video size before compression: {trimmed_size_mb:.2f} MB") + + compress_cmd = [ + "ffmpeg", + "-y", + "-i", temp_trimmed, + "-vf", "scale='min(720,iw)':-2", + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "30", + "-maxrate", "1800k", + "-bufsize", "3600k", + "-c:a", "aac", + "-b:a", "128k", + "-movflags", "+faststart", + temp_output, + ] + + compress_result = subprocess.run(compress_cmd, capture_output=True, text=True) + + if compress_result.returncode != 0: + logging.error(f"❌ ffmpeg compression failed:\n{compress_result.stderr}") + return None + + if not os.path.exists(temp_output) or os.path.getsize(temp_output) == 0: + logging.error("❌ Compressed video output is missing or empty.") + return None + + final_size_mb = os.path.getsize(temp_output) / (1024 * 1024) + logging.info(f"✅ Video compressed successfully: {temp_output} ({final_size_mb:.2f} MB)") + os.replace(temp_output, output_path) - logging.info(f"✅ Video cropped to {int(end_time)} seconds: {output_path}") + logging.info(f"✅ Final video ready: {output_path}") return output_path except Exception as e: - logging.error(f"❌ Error processing video: {e}") + logging.error(f"❌ Error processing video: {repr(e)}") return None finally: - for path in [temp_input, temp_output]: + for path in [temp_input, temp_trimmed, temp_output]: if os.path.exists(path): try: os.remove(path) @@ -1322,4 +1429,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main()