video_fetch.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. """视频获取链 (V3-M2A).
  2. 从 play_url 下载视频(带平台下载头)→ imageio-ffmpeg 压到 ~4MB 低清 →
  3. base64 data URL,供 GeminiVideoClient 投喂(OpenRouter image_url)。
  4. 真实下载/压缩只在 M7 live smoke 跑;单测全 mock。
  5. 原片不做本地持久化;长期复盘资产走 OSS,这里仅下载到内存并压缩后投喂模型。
  6. """
  7. from __future__ import annotations
  8. import base64
  9. import os
  10. import subprocess
  11. import tempfile
  12. import time
  13. from typing import Any
  14. import httpx
  15. import imageio_ffmpeg
  16. # platform_profiles 里写的是 "iOS UA"/"PC UA" 占位,这里映射成真实串 + Referer。
  17. _PLATFORM_DOWNLOAD_HEADERS = {
  18. "douyin": {
  19. "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148",
  20. "Referer": "https://www.douyin.com/",
  21. },
  22. "shipinhao": {
  23. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
  24. "Referer": "https://channels.weixin.qq.com/",
  25. },
  26. }
  27. # 已拍板压缩档:360p / 1fps / 低清,实测 ~4MB(memory/video-multimodal-analysis)。
  28. _FFMPEG_ARGS = ["-vf", "scale=360:-2,fps=1", "-crf", "33", "-c:a", "aac", "-b:a", "32k", "-ac", "1"]
  29. MAX_INLINE_BYTES = 500 * 1024 * 1024 # 本地 inline data URL 护栏;实际上游上限由 OpenRouter/Gemini 决定
  30. DOWNLOAD_TIMEOUT_SECONDS = 30 * 60.0
  31. COMPRESS_TIMEOUT_SECONDS = 20 * 60.0
  32. class VideoFetchError(RuntimeError):
  33. """下载/压缩/超限失败,由 GeminiVideoClient 捕获转 fail。"""
  34. def __init__(self, message: str, *, metrics: dict[str, Any] | None = None) -> None:
  35. super().__init__(message)
  36. self.metrics = metrics or {}
  37. def _download_headers(platform: str, override: dict[str, str] | None) -> dict[str, str]:
  38. if override is not None:
  39. return override
  40. return _PLATFORM_DOWNLOAD_HEADERS.get(platform, {})
  41. def _compress(
  42. source: bytes | str,
  43. ffmpeg_exe: str,
  44. *,
  45. timeout_seconds: float = COMPRESS_TIMEOUT_SECONDS,
  46. input_path: bool = False,
  47. ) -> bytes:
  48. # 超时保护:坏视频会让 ffmpeg 卡死,进而挂住一个判定并发线程(实测正常压缩 ~8s)。
  49. cmd = [ffmpeg_exe, "-i", str(source) if input_path else "pipe:0", *_FFMPEG_ARGS, "-f", "mp4",
  50. "-movflags", "frag_keyframe+empty_moov", "pipe:1"]
  51. try:
  52. proc = subprocess.run(
  53. cmd,
  54. input=None if input_path else source,
  55. stdout=subprocess.PIPE,
  56. stderr=subprocess.PIPE,
  57. timeout=timeout_seconds,
  58. )
  59. except subprocess.TimeoutExpired as exc:
  60. raise VideoFetchError("ffmpeg compression timeout") from exc
  61. if proc.returncode != 0 or not proc.stdout:
  62. raise VideoFetchError("ffmpeg compression failed")
  63. return proc.stdout
  64. def fetch_and_compress(
  65. play_url: str,
  66. platform: str,
  67. *,
  68. headers: dict[str, str] | None = None,
  69. http_client: Any | None = None,
  70. ffmpeg_exe: str | None = None,
  71. timeout_seconds: float = DOWNLOAD_TIMEOUT_SECONDS,
  72. compress_timeout_seconds: float = COMPRESS_TIMEOUT_SECONDS,
  73. clock: Any = time.monotonic,
  74. ) -> str:
  75. data_url, _ = fetch_and_compress_with_metrics(
  76. play_url,
  77. platform,
  78. headers=headers,
  79. http_client=http_client,
  80. ffmpeg_exe=ffmpeg_exe,
  81. timeout_seconds=timeout_seconds,
  82. compress_timeout_seconds=compress_timeout_seconds,
  83. clock=clock,
  84. )
  85. return data_url
  86. def fetch_and_compress_with_metrics(
  87. play_url: str,
  88. platform: str,
  89. *,
  90. headers: dict[str, str] | None = None,
  91. http_client: Any | None = None,
  92. ffmpeg_exe: str | None = None,
  93. timeout_seconds: float = DOWNLOAD_TIMEOUT_SECONDS,
  94. compress_timeout_seconds: float = COMPRESS_TIMEOUT_SECONDS,
  95. clock: Any = time.monotonic,
  96. ) -> tuple[str, dict[str, Any]]:
  97. metrics: dict[str, Any] = {
  98. "download_timeout_seconds": timeout_seconds,
  99. "ffmpeg_timeout_seconds": compress_timeout_seconds,
  100. }
  101. total_started_at = clock()
  102. if not play_url:
  103. raise VideoFetchError("missing play_url", metrics=metrics)
  104. client = http_client or httpx
  105. try:
  106. download_started_at = clock()
  107. video_path = _download_to_tempfile(
  108. client,
  109. play_url,
  110. platform,
  111. headers=headers,
  112. timeout_seconds=timeout_seconds,
  113. started_at=total_started_at,
  114. clock=clock,
  115. )
  116. metrics["download_duration_ms"] = _elapsed_ms(download_started_at, clock())
  117. except httpx.HTTPError as exc:
  118. metrics["total_duration_ms"] = _elapsed_ms(total_started_at, clock())
  119. raise VideoFetchError(f"download failed: {type(exc).__name__}", metrics=metrics) from exc
  120. except VideoFetchError as exc:
  121. metrics.update(exc.metrics)
  122. metrics["total_duration_ms"] = _elapsed_ms(total_started_at, clock())
  123. raise VideoFetchError(str(exc), metrics=metrics) from exc
  124. try:
  125. downloaded_bytes = os.path.getsize(video_path)
  126. metrics["downloaded_bytes"] = downloaded_bytes
  127. if downloaded_bytes <= 0:
  128. metrics["total_duration_ms"] = _elapsed_ms(total_started_at, clock())
  129. raise VideoFetchError("empty download", metrics=metrics)
  130. ffmpeg_started_at = clock()
  131. compressed = _compress(
  132. video_path,
  133. ffmpeg_exe or imageio_ffmpeg.get_ffmpeg_exe(),
  134. timeout_seconds=compress_timeout_seconds,
  135. input_path=True,
  136. )
  137. metrics["ffmpeg_duration_ms"] = _elapsed_ms(ffmpeg_started_at, clock())
  138. metrics["compressed_bytes"] = len(compressed)
  139. except VideoFetchError as exc:
  140. metrics.update(exc.metrics)
  141. metrics["total_duration_ms"] = _elapsed_ms(total_started_at, clock())
  142. raise VideoFetchError(str(exc), metrics=metrics) from exc
  143. finally:
  144. try:
  145. os.unlink(video_path)
  146. except OSError:
  147. pass
  148. if len(compressed) > MAX_INLINE_BYTES:
  149. metrics["total_duration_ms"] = _elapsed_ms(total_started_at, clock())
  150. raise VideoFetchError(f"compressed video oversize: {len(compressed)} bytes", metrics=metrics)
  151. metrics["total_duration_ms"] = _elapsed_ms(total_started_at, clock())
  152. return f"data:video/mp4;base64,{base64.b64encode(compressed).decode('ascii')}", metrics
  153. def _download_to_tempfile(
  154. client: Any,
  155. play_url: str,
  156. platform: str,
  157. *,
  158. headers: dict[str, str] | None,
  159. timeout_seconds: float,
  160. started_at: float,
  161. clock: Any,
  162. ) -> str:
  163. download_headers = _download_headers(platform, headers)
  164. tmp = tempfile.NamedTemporaryFile(prefix="content-agent-video-", suffix=".mp4", delete=False)
  165. tmp_path = tmp.name
  166. tmp.close()
  167. ok = False
  168. if hasattr(client, "stream"):
  169. try:
  170. with client.stream(
  171. "GET",
  172. play_url,
  173. headers=download_headers,
  174. follow_redirects=True,
  175. timeout=timeout_seconds,
  176. ) as response:
  177. response.raise_for_status()
  178. with open(tmp_path, "wb") as file:
  179. for chunk in response.iter_bytes():
  180. if clock() - started_at > timeout_seconds:
  181. raise VideoFetchError("download timeout")
  182. file.write(chunk)
  183. ok = True
  184. return tmp_path
  185. finally:
  186. if not ok:
  187. _unlink_quietly(tmp_path)
  188. try:
  189. response = client.get(
  190. play_url,
  191. headers=download_headers,
  192. follow_redirects=True,
  193. timeout=timeout_seconds,
  194. )
  195. response.raise_for_status()
  196. if clock() - started_at > timeout_seconds:
  197. raise VideoFetchError("download timeout")
  198. with open(tmp_path, "wb") as file:
  199. file.write(response.content)
  200. ok = True
  201. return tmp_path
  202. finally:
  203. if not ok:
  204. _unlink_quietly(tmp_path)
  205. def _unlink_quietly(path: str) -> None:
  206. try:
  207. os.unlink(path)
  208. except OSError:
  209. pass
  210. def _elapsed_ms(start: float, end: float) -> int:
  211. return max(0, int((end - start) * 1000))