|
|
@@ -9,7 +9,10 @@ base64 data URL,供 GeminiVideoClient 投喂(OpenRouter image_url)。
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import base64
|
|
|
+import os
|
|
|
import subprocess
|
|
|
+import tempfile
|
|
|
+import time
|
|
|
from typing import Any
|
|
|
|
|
|
import httpx
|
|
|
@@ -29,7 +32,8 @@ _PLATFORM_DOWNLOAD_HEADERS = {
|
|
|
# 已拍板压缩档:360p / 1fps / 低清,实测 ~4MB(memory/video-multimodal-analysis)。
|
|
|
_FFMPEG_ARGS = ["-vf", "scale=360:-2,fps=1", "-crf", "33", "-c:a", "aac", "-b:a", "32k", "-ac", "1"]
|
|
|
MAX_INLINE_BYTES = 30 * 1024 * 1024 # OpenRouter inline base64 平台硬上限
|
|
|
-COMPRESS_TIMEOUT_SECONDS = 120.0 # 实测 64MB/720p 压缩 ~8s,120s 足够余量
|
|
|
+DOWNLOAD_TIMEOUT_SECONDS = 30 * 60.0
|
|
|
+COMPRESS_TIMEOUT_SECONDS = 20 * 60.0
|
|
|
|
|
|
|
|
|
class VideoFetchError(RuntimeError):
|
|
|
@@ -42,16 +46,23 @@ def _download_headers(platform: str, override: dict[str, str] | None) -> dict[st
|
|
|
return _PLATFORM_DOWNLOAD_HEADERS.get(platform, {})
|
|
|
|
|
|
|
|
|
-def _compress(raw: bytes, ffmpeg_exe: str) -> bytes:
|
|
|
+def _compress(
|
|
|
+ source: bytes | str,
|
|
|
+ ffmpeg_exe: str,
|
|
|
+ *,
|
|
|
+ timeout_seconds: float = COMPRESS_TIMEOUT_SECONDS,
|
|
|
+ input_path: bool = False,
|
|
|
+) -> bytes:
|
|
|
# 超时保护:坏视频会让 ffmpeg 卡死,进而挂住一个判定并发线程(实测正常压缩 ~8s)。
|
|
|
+ cmd = [ffmpeg_exe, "-i", str(source) if input_path else "pipe:0", *_FFMPEG_ARGS, "-f", "mp4",
|
|
|
+ "-movflags", "frag_keyframe+empty_moov", "pipe:1"]
|
|
|
try:
|
|
|
proc = subprocess.run(
|
|
|
- [ffmpeg_exe, "-i", "pipe:0", *_FFMPEG_ARGS, "-f", "mp4",
|
|
|
- "-movflags", "frag_keyframe+empty_moov", "pipe:1"],
|
|
|
- input=raw,
|
|
|
+ cmd,
|
|
|
+ input=None if input_path else source,
|
|
|
stdout=subprocess.PIPE,
|
|
|
stderr=subprocess.PIPE,
|
|
|
- timeout=COMPRESS_TIMEOUT_SECONDS,
|
|
|
+ timeout=timeout_seconds,
|
|
|
)
|
|
|
except subprocess.TimeoutExpired as exc:
|
|
|
raise VideoFetchError("ffmpeg compression timeout") from exc
|
|
|
@@ -67,26 +78,101 @@ def fetch_and_compress(
|
|
|
headers: dict[str, str] | None = None,
|
|
|
http_client: Any | None = None,
|
|
|
ffmpeg_exe: str | None = None,
|
|
|
- timeout_seconds: float = 90.0,
|
|
|
+ timeout_seconds: float = DOWNLOAD_TIMEOUT_SECONDS,
|
|
|
+ compress_timeout_seconds: float = COMPRESS_TIMEOUT_SECONDS,
|
|
|
+ clock: Any = time.monotonic,
|
|
|
) -> str:
|
|
|
if not play_url:
|
|
|
raise VideoFetchError("missing play_url")
|
|
|
client = http_client or httpx
|
|
|
+ start = clock()
|
|
|
try:
|
|
|
- response = client.get(
|
|
|
+ video_path = _download_to_tempfile(
|
|
|
+ client,
|
|
|
play_url,
|
|
|
- headers=_download_headers(platform, headers),
|
|
|
- follow_redirects=True,
|
|
|
- timeout=timeout_seconds,
|
|
|
+ platform,
|
|
|
+ headers=headers,
|
|
|
+ timeout_seconds=timeout_seconds,
|
|
|
+ started_at=start,
|
|
|
+ clock=clock,
|
|
|
)
|
|
|
- response.raise_for_status()
|
|
|
- raw = response.content
|
|
|
except httpx.HTTPError as exc:
|
|
|
raise VideoFetchError(f"download failed: {type(exc).__name__}") from exc
|
|
|
- if not raw:
|
|
|
- raise VideoFetchError("empty download")
|
|
|
-
|
|
|
- compressed = _compress(raw, ffmpeg_exe or imageio_ffmpeg.get_ffmpeg_exe())
|
|
|
+ try:
|
|
|
+ if os.path.getsize(video_path) <= 0:
|
|
|
+ raise VideoFetchError("empty download")
|
|
|
+ compressed = _compress(
|
|
|
+ video_path,
|
|
|
+ ffmpeg_exe or imageio_ffmpeg.get_ffmpeg_exe(),
|
|
|
+ timeout_seconds=compress_timeout_seconds,
|
|
|
+ input_path=True,
|
|
|
+ )
|
|
|
+ finally:
|
|
|
+ try:
|
|
|
+ os.unlink(video_path)
|
|
|
+ except OSError:
|
|
|
+ pass
|
|
|
if len(compressed) > MAX_INLINE_BYTES:
|
|
|
raise VideoFetchError(f"compressed video oversize: {len(compressed)} bytes")
|
|
|
return f"data:video/mp4;base64,{base64.b64encode(compressed).decode('ascii')}"
|
|
|
+
|
|
|
+
|
|
|
+def _download_to_tempfile(
|
|
|
+ client: Any,
|
|
|
+ play_url: str,
|
|
|
+ platform: str,
|
|
|
+ *,
|
|
|
+ headers: dict[str, str] | None,
|
|
|
+ timeout_seconds: float,
|
|
|
+ started_at: float,
|
|
|
+ clock: Any,
|
|
|
+) -> str:
|
|
|
+ download_headers = _download_headers(platform, headers)
|
|
|
+ tmp = tempfile.NamedTemporaryFile(prefix="content-agent-video-", suffix=".mp4", delete=False)
|
|
|
+ tmp_path = tmp.name
|
|
|
+ tmp.close()
|
|
|
+ ok = False
|
|
|
+ if hasattr(client, "stream"):
|
|
|
+ try:
|
|
|
+ with client.stream(
|
|
|
+ "GET",
|
|
|
+ play_url,
|
|
|
+ headers=download_headers,
|
|
|
+ follow_redirects=True,
|
|
|
+ timeout=timeout_seconds,
|
|
|
+ ) as response:
|
|
|
+ response.raise_for_status()
|
|
|
+ with open(tmp_path, "wb") as file:
|
|
|
+ for chunk in response.iter_bytes():
|
|
|
+ if clock() - started_at > timeout_seconds:
|
|
|
+ raise VideoFetchError("download timeout")
|
|
|
+ file.write(chunk)
|
|
|
+ ok = True
|
|
|
+ return tmp_path
|
|
|
+ finally:
|
|
|
+ if not ok:
|
|
|
+ _unlink_quietly(tmp_path)
|
|
|
+ try:
|
|
|
+ response = client.get(
|
|
|
+ play_url,
|
|
|
+ headers=download_headers,
|
|
|
+ follow_redirects=True,
|
|
|
+ timeout=timeout_seconds,
|
|
|
+ )
|
|
|
+ response.raise_for_status()
|
|
|
+ if clock() - started_at > timeout_seconds:
|
|
|
+ raise VideoFetchError("download timeout")
|
|
|
+ with open(tmp_path, "wb") as file:
|
|
|
+ file.write(response.content)
|
|
|
+ ok = True
|
|
|
+ return tmp_path
|
|
|
+ finally:
|
|
|
+ if not ok:
|
|
|
+ _unlink_quietly(tmp_path)
|
|
|
+
|
|
|
+
|
|
|
+def _unlink_quietly(path: str) -> None:
|
|
|
+ try:
|
|
|
+ os.unlink(path)
|
|
|
+ except OSError:
|
|
|
+ pass
|