video_fetch.py 8.5 KB

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