Some video bitrate improvements
This commit is contained in:
@@ -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()
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user