qwen_video_analysis.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. """
  2. 历史千问视频解析内部实现。
  3. 当前 find_agent 明确不使用视频理解,本模块未注册到 Agent,也不属于在线能力。
  4. 保留文件仅用于历史兼容,不得因文件或数据库字段存在而推断能力已启用。
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import hashlib
  9. import importlib
  10. import json
  11. import logging
  12. import os
  13. import re
  14. import shutil
  15. import subprocess
  16. import tempfile
  17. import time
  18. from pathlib import Path
  19. from typing import Any, Optional
  20. import httpx
  21. from dotenv import load_dotenv
  22. from openai import APIStatusError, APITimeoutError, OpenAI
  23. from supply_agent.paths import find_project_root
  24. from supply_infra.config import get_infra_settings
  25. from supply_infra.oss.client import OssClient
  26. logger = logging.getLogger(__name__)
  27. # DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
  28. DASHSCOPE_BASE_URL = "https://llm-33b86fznnpci2exm.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
  29. DEFAULT_MODEL = "qwen3.7-plus"
  30. DEFAULT_PROMPT = "描述这段视频的内容"
  31. DEFAULT_FPS = 2.0
  32. DEFAULT_TIMEOUT = 300.0
  33. DEFAULT_DOWNLOAD_TIMEOUT = 120.0
  34. DEFAULT_MAX_DURATION_SECONDS = 180.0
  35. FFPROBE_TIMEOUT_SECONDS = 30.0
  36. FFMPEG_TIMEOUT_SECONDS = 300.0
  37. _DURATION_RE = re.compile(
  38. r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)",
  39. re.IGNORECASE,
  40. )
  41. _env_loaded = False
  42. class ToolTimeoutError(RuntimeError):
  43. """A bounded local tool operation exceeded its deadline."""
  44. def _ensure_env_loaded() -> None:
  45. """从项目根目录加载 .env,使 os.getenv 能读到其中的变量。"""
  46. global _env_loaded
  47. if _env_loaded:
  48. return
  49. load_dotenv(find_project_root() / ".env")
  50. _env_loaded = True
  51. def _get_client() -> OpenAI:
  52. _ensure_env_loaded()
  53. api_key = os.getenv("DASHSCOPE_API_KEY")
  54. if not api_key:
  55. raise ValueError("未设置环境变量 DASHSCOPE_API_KEY")
  56. return OpenAI(api_key=api_key, base_url=DASHSCOPE_BASE_URL)
  57. def _resolve_ffmpeg() -> str | None:
  58. ffmpeg = shutil.which("ffmpeg")
  59. if ffmpeg:
  60. return ffmpeg
  61. try:
  62. module = importlib.import_module("imageio_ffmpeg")
  63. return module.get_ffmpeg_exe()
  64. except ImportError:
  65. return None
  66. def _resolve_ffprobe() -> str | None:
  67. return shutil.which("ffprobe")
  68. def _require_ffmpeg() -> str:
  69. ffmpeg = _resolve_ffmpeg()
  70. if not ffmpeg:
  71. raise ValueError(
  72. "截断视频需要 ffmpeg。本地可执行 pip install imageio-ffmpeg,"
  73. "或 brew install ffmpeg;部署镜像已内置系统 ffmpeg"
  74. )
  75. return ffmpeg
  76. def _ensure_oss_configured() -> None:
  77. settings = get_infra_settings()
  78. if not settings.aliyun_oss_access_key_id or not settings.aliyun_oss_access_key_secret:
  79. raise ValueError(
  80. "截断视频需要配置 ALIYUN_OSS_ACCESS_KEY_ID 和 ALIYUN_OSS_ACCESS_KEY_SECRET"
  81. )
  82. def _parse_duration_text(text: str) -> float | None:
  83. match = _DURATION_RE.search(text)
  84. if not match:
  85. return None
  86. hours, minutes, seconds = match.groups()
  87. return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
  88. def _probe_duration(ffmpeg: str, target: str) -> float | None:
  89. ffprobe = _resolve_ffprobe()
  90. if ffprobe:
  91. try:
  92. result = subprocess.run(
  93. [
  94. ffprobe,
  95. "-v",
  96. "error",
  97. "-show_entries",
  98. "format=duration",
  99. "-of",
  100. "default=noprint_wrappers=1:nokey=1",
  101. target,
  102. ],
  103. capture_output=True,
  104. text=True,
  105. check=False,
  106. timeout=FFPROBE_TIMEOUT_SECONDS,
  107. )
  108. except subprocess.TimeoutExpired:
  109. logger.warning(
  110. "ffprobe timed out after %.0fs: %s",
  111. FFPROBE_TIMEOUT_SECONDS,
  112. target,
  113. )
  114. else:
  115. if result.returncode == 0:
  116. raw = result.stdout.strip()
  117. if raw:
  118. try:
  119. return float(raw)
  120. except ValueError:
  121. pass
  122. try:
  123. result = subprocess.run(
  124. [ffmpeg, "-i", target],
  125. capture_output=True,
  126. text=True,
  127. check=False,
  128. timeout=FFPROBE_TIMEOUT_SECONDS,
  129. )
  130. except subprocess.TimeoutExpired:
  131. logger.warning(
  132. "ffmpeg duration probe timed out after %.0fs: %s",
  133. FFPROBE_TIMEOUT_SECONDS,
  134. target,
  135. )
  136. return None
  137. return _parse_duration_text(result.stderr)
  138. def _truncate_video(
  139. ffmpeg: str,
  140. input_path: Path,
  141. output_path: Path,
  142. max_duration_seconds: float,
  143. ) -> None:
  144. duration_text = str(max_duration_seconds)
  145. copy_cmd = [
  146. ffmpeg,
  147. "-y",
  148. "-i",
  149. str(input_path),
  150. "-t",
  151. duration_text,
  152. "-c",
  153. "copy",
  154. "-movflags",
  155. "+faststart",
  156. str(output_path),
  157. ]
  158. try:
  159. result = subprocess.run(
  160. copy_cmd,
  161. capture_output=True,
  162. text=True,
  163. check=False,
  164. timeout=FFMPEG_TIMEOUT_SECONDS,
  165. )
  166. except subprocess.TimeoutExpired:
  167. logger.warning(
  168. "ffmpeg stream copy timed out after %.0fs",
  169. FFMPEG_TIMEOUT_SECONDS,
  170. )
  171. result = None
  172. if (
  173. result is not None
  174. and result.returncode == 0
  175. and output_path.is_file()
  176. and output_path.stat().st_size > 0
  177. ):
  178. return
  179. logger.warning(
  180. "ffmpeg stream copy failed, fallback to re-encode: %s",
  181. ((result.stderr or result.stdout)[-500:] if result is not None else "timeout"),
  182. )
  183. encode_cmd = [
  184. ffmpeg,
  185. "-y",
  186. "-i",
  187. str(input_path),
  188. "-t",
  189. duration_text,
  190. "-c:v",
  191. "libx264",
  192. "-preset",
  193. "fast",
  194. "-c:a",
  195. "aac",
  196. "-movflags",
  197. "+faststart",
  198. str(output_path),
  199. ]
  200. try:
  201. encode_result = subprocess.run(
  202. encode_cmd,
  203. capture_output=True,
  204. text=True,
  205. check=False,
  206. timeout=FFMPEG_TIMEOUT_SECONDS,
  207. )
  208. except subprocess.TimeoutExpired:
  209. raise ToolTimeoutError(
  210. f"ffmpeg 截断视频超时({FFMPEG_TIMEOUT_SECONDS:.0f}秒)"
  211. ) from None
  212. if encode_result.returncode != 0 or not output_path.is_file():
  213. detail = (encode_result.stderr or encode_result.stdout)[-500:]
  214. raise RuntimeError(f"ffmpeg 截断视频失败: {detail}")
  215. async def _download_video(url: str, dest: Path, timeout: float) -> None:
  216. async with httpx.AsyncClient(
  217. timeout=timeout,
  218. trust_env=False,
  219. follow_redirects=True,
  220. headers={"User-Agent": "curl/8.6.0", "Accept": "*/*"},
  221. ) as client:
  222. async with client.stream("GET", url) as response:
  223. response.raise_for_status()
  224. with dest.open("wb") as file_obj:
  225. async for chunk in response.aiter_bytes():
  226. file_obj.write(chunk)
  227. def _safe_unlink(path: Path) -> None:
  228. """删除临时视频文件,失败时仅记录日志。"""
  229. try:
  230. if path.is_file():
  231. path.unlink()
  232. except OSError as exc:
  233. logger.warning("failed to delete temp video file %s: %s", path, exc)
  234. def _upload_clip(local_path: Path, source_url: str, max_duration_seconds: float) -> str:
  235. url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:16]
  236. client = OssClient()
  237. object_key = client.object_key(
  238. "video_clips",
  239. f"{url_hash}_{int(max_duration_seconds)}s.mp4",
  240. )
  241. return client.upload_file(local_path, object_key)
  242. async def _prepare_analysis_url(
  243. video_url: str,
  244. max_duration_seconds: float | None,
  245. download_timeout: float,
  246. ) -> tuple[str, dict[str, Any]]:
  247. meta: dict[str, Any] = {
  248. "original_video_url": video_url,
  249. "truncated": False,
  250. }
  251. if max_duration_seconds is None:
  252. return video_url, meta
  253. ffmpeg = _require_ffmpeg()
  254. duration = await asyncio.to_thread(_probe_duration, ffmpeg, video_url)
  255. if duration is not None:
  256. meta["original_duration_seconds"] = duration
  257. if duration <= max_duration_seconds:
  258. logger.info(
  259. "video within limit, skip truncate: duration=%.1fs max=%.1fs url=%s",
  260. duration,
  261. max_duration_seconds,
  262. video_url,
  263. )
  264. return video_url, meta
  265. _ensure_oss_configured()
  266. with tempfile.TemporaryDirectory(prefix="qwen_video_") as tmpdir:
  267. source_path = Path(tmpdir) / "source.mp4"
  268. clipped_path = Path(tmpdir) / "clipped.mp4"
  269. await _download_video(video_url, source_path, download_timeout)
  270. if duration is None:
  271. duration = await asyncio.to_thread(_probe_duration, ffmpeg, str(source_path))
  272. if duration is not None:
  273. meta["original_duration_seconds"] = duration
  274. if duration is not None and duration <= max_duration_seconds:
  275. logger.info(
  276. "downloaded video within limit, skip truncate: duration=%.1fs",
  277. duration,
  278. )
  279. _safe_unlink(source_path)
  280. return video_url, meta
  281. _ensure_oss_configured()
  282. await asyncio.to_thread(
  283. _truncate_video,
  284. ffmpeg,
  285. source_path,
  286. clipped_path,
  287. max_duration_seconds,
  288. )
  289. _safe_unlink(source_path)
  290. analysis_url = await asyncio.to_thread(
  291. _upload_clip,
  292. clipped_path,
  293. video_url,
  294. max_duration_seconds,
  295. )
  296. _safe_unlink(clipped_path)
  297. meta["truncated"] = True
  298. meta["analysis_video_url"] = analysis_url
  299. meta["max_duration_seconds"] = max_duration_seconds
  300. logger.info(
  301. "video truncated: original_duration=%s max=%.1fs analysis_url=%s",
  302. meta.get("original_duration_seconds"),
  303. max_duration_seconds,
  304. analysis_url,
  305. )
  306. return analysis_url, meta
  307. def _analyze_video_sync(
  308. video_url: str,
  309. prompt: str,
  310. fps: float,
  311. model: str,
  312. timeout: float,
  313. ) -> str:
  314. client = _get_client()
  315. completion = client.chat.completions.create(
  316. model=model,
  317. messages=[
  318. {
  319. "role": "user",
  320. "content": [
  321. {
  322. "type": "video_url",
  323. "video_url": {"url": video_url},
  324. "fps": fps,
  325. },
  326. {"type": "text", "text": prompt},
  327. ],
  328. }
  329. ],
  330. timeout=timeout,
  331. )
  332. return completion.choices[0].message.content or ""
  333. def _success_result(
  334. video_url: str,
  335. prompt: str,
  336. content: str,
  337. model: str,
  338. duration_ms: int,
  339. *,
  340. extra: dict[str, Any] | None = None,
  341. ) -> str:
  342. payload: dict[str, Any] = {
  343. "title": "视频解析结果",
  344. "video_url": video_url,
  345. "prompt": prompt,
  346. "model": model,
  347. "content": content,
  348. "output": content,
  349. "duration_ms": duration_ms,
  350. }
  351. if extra:
  352. payload.update(extra)
  353. return json.dumps(payload, ensure_ascii=False)
  354. def _error_result(
  355. error: str,
  356. *,
  357. title: str = "视频解析失败",
  358. error_code: str | None = None,
  359. retryable: bool | None = None,
  360. ) -> str:
  361. payload: dict[str, Any] = {"error": error, "title": title}
  362. if error_code:
  363. payload["error_code"] = error_code
  364. if retryable is not None:
  365. payload["retryable"] = retryable
  366. return json.dumps(payload, ensure_ascii=False)
  367. def _classify_api_error(exc: Exception) -> tuple[str, str, str, bool]:
  368. """将上游 API 异常归类为 Agent 可理解的错误。"""
  369. raw = str(exc)
  370. lowered = raw.lower()
  371. if "data_inspection_failed" in lowered or "inappropriate content" in lowered:
  372. return (
  373. "content_inspection_failed",
  374. "视频内容审核未通过",
  375. "视频内容未通过模型侧安全审核,无法解析画面;请改依据标题、互动数据和画像继续判断,不要重复调用本工具。",
  376. False,
  377. )
  378. return "api_error", "视频解析失败", raw, True
  379. def verify_truncation_runtime(*, check_oss: bool = False) -> dict[str, Any]:
  380. """
  381. 校验视频截断运行时依赖是否可用。
  382. 部署后可执行:
  383. python scripts/verify_video_truncate_runtime.py
  384. """
  385. ffmpeg = _require_ffmpeg()
  386. ffprobe = _resolve_ffprobe()
  387. with tempfile.TemporaryDirectory(prefix="qwen_video_verify_") as tmpdir:
  388. source_path = Path(tmpdir) / "source.mp4"
  389. clipped_path = Path(tmpdir) / "clipped.mp4"
  390. subprocess.run(
  391. [
  392. ffmpeg,
  393. "-hide_banner",
  394. "-loglevel",
  395. "error",
  396. "-f",
  397. "lavfi",
  398. "-i",
  399. "testsrc=duration=3:size=160x120:rate=10",
  400. "-t",
  401. "3",
  402. "-c:v",
  403. "libx264",
  404. "-pix_fmt",
  405. "yuv420p",
  406. str(source_path),
  407. ],
  408. check=True,
  409. capture_output=True,
  410. text=True,
  411. timeout=FFMPEG_TIMEOUT_SECONDS,
  412. )
  413. _truncate_video(ffmpeg, source_path, clipped_path, 1.0)
  414. if not clipped_path.is_file() or clipped_path.stat().st_size <= 0:
  415. raise RuntimeError("ffmpeg 截断验证失败:输出文件为空")
  416. oss_configured = False
  417. if check_oss:
  418. _ensure_oss_configured()
  419. oss_configured = True
  420. return {
  421. "ffmpeg": ffmpeg,
  422. "ffprobe": ffprobe,
  423. "oss_configured": oss_configured,
  424. "status": "ok",
  425. }
  426. async def qwen_video_analyze(
  427. video_url: str,
  428. prompt: str = DEFAULT_PROMPT,
  429. fps: float = DEFAULT_FPS,
  430. model: str = DEFAULT_MODEL,
  431. timeout: Optional[float] = None,
  432. max_duration_seconds: Optional[float] = DEFAULT_MAX_DURATION_SECONDS,
  433. download_timeout: Optional[float] = None,
  434. ) -> str:
  435. """
  436. 历史视频解析函数,不注册为 find_agent 工具。
  437. 通过阿里云百炼平台调用 qwen3.7-plus 模型,分析视频 URL 并返回文字描述。
  438. 需要设置环境变量 DASHSCOPE_API_KEY。超长视频会先截断再解析。
  439. Args:
  440. video_url: 视频地址(需公网可访问的 mp4 等格式)
  441. prompt: 解析提示词,默认 "描述这段视频的内容"
  442. fps: 视频抽帧频率,默认 2(每秒采样 2 帧)
  443. model: 模型名称,默认 "qwen3.7-plus"
  444. timeout: 请求超时时间(秒),默认 300
  445. max_duration_seconds: 解析前最长保留秒数,默认 180(3 分钟);
  446. 传 None 表示不截断
  447. download_timeout: 下载原视频超时时间(秒),默认 120
  448. Returns:
  449. JSON 字符串,包含 content(解析文本)和 output(同 content,供 LLM 阅读)。
  450. """
  451. start_time = time.time()
  452. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  453. request_download_timeout = (
  454. download_timeout if download_timeout is not None else DEFAULT_DOWNLOAD_TIMEOUT
  455. )
  456. try:
  457. analysis_url, truncate_meta = await _prepare_analysis_url(
  458. video_url,
  459. max_duration_seconds,
  460. request_download_timeout,
  461. )
  462. content = await asyncio.to_thread(
  463. _analyze_video_sync,
  464. analysis_url,
  465. prompt,
  466. fps,
  467. model,
  468. request_timeout,
  469. )
  470. duration_ms = int((time.time() - start_time) * 1000)
  471. logger.info(
  472. "qwen_video_analyze completed: video_url=%s analysis_url=%s truncated=%s model=%s duration_ms=%d",
  473. video_url,
  474. analysis_url,
  475. truncate_meta.get("truncated"),
  476. model,
  477. duration_ms,
  478. )
  479. return _success_result(
  480. analysis_url,
  481. prompt,
  482. content,
  483. model,
  484. duration_ms,
  485. extra=truncate_meta,
  486. )
  487. except ToolTimeoutError as e:
  488. logger.warning(
  489. "qwen_video_analyze local operation timed out: video_url=%s error=%s",
  490. video_url,
  491. e,
  492. )
  493. return _error_result(
  494. str(e),
  495. error_code="tool_timeout",
  496. retryable=True,
  497. )
  498. except ValueError as e:
  499. logger.error("qwen_video_analyze config error: %s", e)
  500. return _error_result(str(e))
  501. except httpx.TimeoutException as e:
  502. logger.warning(
  503. "qwen_video_analyze download timed out: video_url=%s error=%s",
  504. video_url,
  505. e,
  506. )
  507. return _error_result(
  508. f"下载视频超时: {e}",
  509. error_code="tool_timeout",
  510. retryable=True,
  511. )
  512. except httpx.HTTPError as e:
  513. logger.error(
  514. "qwen_video_analyze download error: video_url=%s error=%s",
  515. video_url,
  516. e,
  517. exc_info=True,
  518. )
  519. return _error_result(f"下载视频失败: {e}")
  520. except APITimeoutError as e:
  521. logger.warning(
  522. "qwen_video_analyze model request timed out: video_url=%s error=%s",
  523. video_url,
  524. e,
  525. )
  526. return _error_result(
  527. f"视频模型请求超时: {e}",
  528. error_code="tool_timeout",
  529. retryable=True,
  530. )
  531. except APIStatusError as e:
  532. error_code, title, message, retryable = _classify_api_error(e)
  533. logger.warning(
  534. "qwen_video_analyze api rejected: video_url=%s code=%s status=%s",
  535. video_url,
  536. error_code,
  537. e.status_code,
  538. )
  539. return _error_result(
  540. message,
  541. title=title,
  542. error_code=error_code,
  543. retryable=retryable,
  544. )
  545. except Exception as e:
  546. logger.error(
  547. "qwen_video_analyze error: video_url=%s error=%s",
  548. video_url,
  549. e,
  550. exc_info=True,
  551. )
  552. return _error_result(str(e))
  553. async def main() -> None:
  554. test_url = os.getenv("TEST_VIDEO_URL", "https://www.douyin.com/aweme/v1/play/?video_id=v0200fg10000d4lbj67og65hqvt943u0&ratio=1080p&line=0")
  555. # test_url = os.getenv("TEST_VIDEO_URL", "http://rescdn.yishihui.com/longvideo/transcode/video/vpc/20260713/b2507233bc8fe040003d19b34c2c7d73.mp4")
  556. result_json = await qwen_video_analyze(video_url=test_url)
  557. result = json.loads(result_json)
  558. if "error" in result:
  559. print(f"解析失败: {result['error']}")
  560. else:
  561. print(result["content"])
  562. if __name__ == "__main__":
  563. asyncio.run(main())