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:
@@ -170,6 +170,37 @@ class MediaProcessor:
|
||||
return f"{best_video_only[1]}+bestaudio", None
|
||||
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(
|
||||
self, url: str, prefix: str = "video_"
|
||||
) -> tuple[Optional[Path], Optional[str], bool]:
|
||||
@@ -263,6 +294,8 @@ class MediaProcessor:
|
||||
await unregister_active_path(output_path)
|
||||
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
|
||||
if size > max_size:
|
||||
# Shouldn't happen - _select_format only hands back formats whose known size
|
||||
|
||||
Reference in New Issue
Block a user