Fix black-screen-with-audio videos: faststart remux after download

Root cause of a real report (post #343474, video-36860851_456244054):
_select_format's video-only+bestaudio path merges VK's separate DASH video
and audio streams via ffmpeg, which leaves the moov atom at the end of the
file by default (confirmed: mdat at offset 44, moov near EOF). Streaming
players - Telegram's mobile clients in particular - can play the audio
track immediately but can't render video without seeking to the index
first, showing black video with working audio until the whole file has
downloaded.

Adds a fast, lossless -c copy -movflags +faststart remux after every video
download (cheap - just repositions the atom, no re-encode) so the moov atom
is always at the front regardless of which yt-dlp code path produced the
file. Falls back to the original file if the remux fails for any reason,
rather than dropping the video. Verified on the actual failing video:
moov now at offset 36 instead of past 2MB in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 18:05:52 +05:00
parent bbbb9bc3a2
commit f8842f8c07
+33
View File
@@ -170,6 +170,37 @@ class MediaProcessor:
return f"{best_video_only[1]}+bestaudio", None return f"{best_video_only[1]}+bestaudio", None
return None, "no format under the size limit with a known size" return None, "no format under the size limit with a known size"
async def _apply_faststart(self, path: Path) -> Path:
"""Moves the moov atom to the front with a fast, lossless remux (-c copy - no
re-encode, just repositions the index). Needed because muxing a separate
video+audio stream (see _select_format's "+bestaudio" case) leaves the moov atom
at the end by default: confirmed on a real post where the result played audio
with a black video, because streaming players (notably Telegram's mobile clients)
can't render video without seeking to the index first. Falls back to the original
file - rather than dropping the video - if ffmpeg fails for any reason."""
fixed_path = path.with_name(f"{path.stem}_fs.mp4")
await register_active_path(fixed_path)
proc = None
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-i", str(path), "-c", "copy", "-movflags", "+faststart",
str(fixed_path),
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
except OSError as exc:
logger.warning("ffmpeg faststart remux failed to start: {}", exc)
if not proc or proc.returncode != 0 or not fixed_path.exists() or fixed_path.stat().st_size == 0:
logger.warning("ffmpeg faststart remux failed for {}, sending as-is", path)
fixed_path.unlink(missing_ok=True)
await unregister_active_path(fixed_path)
return path
path.unlink(missing_ok=True)
await unregister_active_path(path)
return fixed_path
async def _download_video_ytdlp_attempt( async def _download_video_ytdlp_attempt(
self, url: str, prefix: str = "video_" self, url: str, prefix: str = "video_"
) -> tuple[Optional[Path], Optional[str], bool]: ) -> tuple[Optional[Path], Optional[str], bool]:
@@ -263,6 +294,8 @@ class MediaProcessor:
await unregister_active_path(output_path) await unregister_active_path(output_path)
return None, f"yt-dlp failed: {err_msg[:200]}", is_permanent return None, f"yt-dlp failed: {err_msg[:200]}", is_permanent
output_path = await self._apply_faststart(output_path)
size = output_path.stat().st_size size = output_path.stat().st_size
if size > max_size: if size > max_size:
# Shouldn't happen - _select_format only hands back formats whose known size # Shouldn't happen - _select_format only hands back formats whose known size