diff --git a/src/media_processor.py b/src/media_processor.py index b9e238d..09330b8 100644 --- a/src/media_processor.py +++ b/src/media_processor.py @@ -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