Some video bitrate improvements
This commit is contained in:
@@ -20,9 +20,23 @@ STATE_PATH = "twitter2bsky_state.json"
|
|||||||
SCRAPE_TWEET_LIMIT = 30
|
SCRAPE_TWEET_LIMIT = 30
|
||||||
DEDUPE_BSKY_LIMIT = 30
|
DEDUPE_BSKY_LIMIT = 30
|
||||||
TWEET_MAX_AGE_DAYS = 3
|
TWEET_MAX_AGE_DAYS = 3
|
||||||
VIDEO_MAX_DURATION_SECONDS = 179
|
|
||||||
BSKY_TEXT_MAX_LENGTH = 275
|
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_MAX_RETRIES = 5
|
||||||
BSKY_BLOB_UPLOAD_BASE_DELAY = 10
|
BSKY_BLOB_UPLOAD_BASE_DELAY = 10
|
||||||
BSKY_BLOB_UPLOAD_MAX_DELAY = 300
|
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"):
|
def upload_blob_with_retry(client, binary_data, media_label="media"):
|
||||||
"""
|
"""
|
||||||
Retry Bluesky blob upload when rate-limited.
|
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
|
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
|
is_rate_limited = "429" in error_text or "RateLimitExceeded" in error_text
|
||||||
|
|
||||||
if not is_rate_limited:
|
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
|
return None
|
||||||
|
|
||||||
backoff_delay = min(
|
backoff_delay = min(
|
||||||
@@ -201,10 +228,10 @@ def upload_blob_with_retry(client, binary_data, media_label="media"):
|
|||||||
time.sleep(wait_seconds)
|
time.sleep(wait_seconds)
|
||||||
else:
|
else:
|
||||||
logging.warning(
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -226,19 +253,52 @@ def get_blob_from_url(media_url, client, http_client):
|
|||||||
return upload_blob_with_retry(client, content, media_label=media_url)
|
return upload_blob_with_retry(client, content, media_label=media_url)
|
||||||
|
|
||||||
except Exception as e:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_blob_from_file(file_path, client):
|
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:
|
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:
|
with open(file_path, "rb") as f:
|
||||||
binary_data = f.read()
|
binary_data = f.read()
|
||||||
|
|
||||||
return upload_blob_with_retry(client, binary_data, media_label=file_path)
|
return upload_blob_with_retry(client, binary_data, media_label=file_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Could not upload local file {file_path}: {e}")
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -924,8 +984,24 @@ def extract_video_url_from_tweet_page(context, tweet_url):
|
|||||||
|
|
||||||
# --- Video Processing ---
|
# --- Video Processing ---
|
||||||
def download_and_crop_video(video_url, output_path):
|
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_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:
|
try:
|
||||||
logging.info(f"⬇️ Downloading video source with ffmpeg: {video_url}")
|
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,
|
temp_input,
|
||||||
]
|
]
|
||||||
|
|
||||||
download_result = subprocess.run(
|
download_result = subprocess.run(download_cmd, capture_output=True, text=True)
|
||||||
download_cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if download_result.returncode != 0:
|
if download_result.returncode != 0:
|
||||||
logging.error(f"❌ ffmpeg download failed:\n{download_result.stderr}")
|
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 = video_clip.subclip(0, end_time)
|
||||||
|
|
||||||
cropped_clip.write_videofile(
|
cropped_clip.write_videofile(
|
||||||
temp_output,
|
temp_trimmed,
|
||||||
codec="libx264",
|
codec="libx264",
|
||||||
audio_codec="aac",
|
audio_codec="aac",
|
||||||
|
preset="veryfast",
|
||||||
|
bitrate="1800k",
|
||||||
|
audio_bitrate="128k",
|
||||||
logger=None
|
logger=None
|
||||||
)
|
)
|
||||||
|
|
||||||
video_clip.close()
|
video_clip.close()
|
||||||
cropped_clip.close()
|
cropped_clip.close()
|
||||||
|
|
||||||
if not os.path.exists(temp_output) or os.path.getsize(temp_output) == 0:
|
if not os.path.exists(temp_trimmed) or os.path.getsize(temp_trimmed) == 0:
|
||||||
logging.error("❌ Cropped video output is missing or empty.")
|
logging.error("❌ Trimmed video output is missing or empty.")
|
||||||
return None
|
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)
|
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
|
return output_path
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"❌ Error processing video: {e}")
|
logging.error(f"❌ Error processing video: {repr(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
for path in [temp_input, temp_output]:
|
for path in [temp_input, temp_trimmed, temp_output]:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
try:
|
try:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user