video_extract.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. """原生整段视频提炼:把整段 mp4 经 OpenRouter base64 video_url 发给 Gemini,
  2. 按时间戳分段提炼 What/Why/How。取代抽帧作为视频内容提炼主路。
  3. 每个 segment → 一张段卡 Card(kind="segment", start, end),写入 post.cards;
  4. 同时产出 ExtractedContent.cards=[{index, content}],让下游 split 照常按【卡片N】溯源。
  5. 网络分裂:下载抖音视频需非新加坡出口,调 OpenRouter 需可达 Google。下载做成可注入
  6. (downloader)或可传入本地文件(video_path),便于"下载节点 / 推理节点"分离。
  7. """
  8. from __future__ import annotations
  9. import base64
  10. import logging
  11. import os
  12. import re
  13. from typing import Any, Callable, Optional
  14. import httpx
  15. from creation_knowledge.config import Settings
  16. from creation_knowledge.jsonio import extract_json_object
  17. from creation_knowledge.models import Card, CardExtract, ExtractedContent, Post
  18. from creation_knowledge.prompts import load_prompt
  19. logger = logging.getLogger(__name__)
  20. _REFERER = {
  21. "douyin": "https://www.douyin.com/",
  22. "kuaishou": "https://www.kuaishou.com/",
  23. "bilibili": "https://www.bilibili.com/",
  24. "shipinhao": "https://channels.weixin.qq.com/",
  25. }
  26. _IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15"
  27. class VideoExtractError(RuntimeError):
  28. pass
  29. def _mmss_to_sec(value: Any) -> Optional[float]:
  30. """'MM:SS' / 'HH:MM:SS' / 数字 → 秒。"""
  31. if value is None:
  32. return None
  33. if isinstance(value, (int, float)):
  34. return float(value)
  35. parts = str(value).strip().split(":")
  36. try:
  37. nums = [float(p) for p in parts]
  38. except ValueError:
  39. return None
  40. sec = 0.0
  41. for n in nums:
  42. sec = sec * 60 + n
  43. return sec
  44. def _default_download(url: str, platform: str, timeout: float = 180.0) -> bytes:
  45. headers = {"User-Agent": _IOS_UA, "Referer": _REFERER.get(platform, "")}
  46. with httpx.stream("GET", url, headers=headers, timeout=timeout,
  47. follow_redirects=True) as r:
  48. r.raise_for_status()
  49. return b"".join(r.iter_bytes())
  50. def _seg_content(seg: dict) -> str:
  51. parts = [seg.get("title") or ""]
  52. for label, key in (("What", "what"), ("Why", "why"), ("How", "how")):
  53. v = seg.get(key)
  54. if v and str(v).strip().lower() not in ("null", "none", ""):
  55. parts.append(f"{label}:{v}")
  56. return "。".join(p for p in parts if p)
  57. def extract_video(
  58. post: Post,
  59. *,
  60. settings: Settings,
  61. http_post: Callable[..., Any] = httpx.post,
  62. video_path: Optional[str] = None,
  63. downloader: Optional[Callable[[str, str], bytes]] = None,
  64. timeout: float = 600.0,
  65. ) -> ExtractedContent:
  66. """对视频帖做原生整段提炼,就地写好 post.cards(段卡),返回 ExtractedContent。"""
  67. key = settings.openrouter_api_key
  68. if not key:
  69. raise VideoExtractError("missing OPENROUTER_API_KEY")
  70. # 1) 拿到 mp4 字节
  71. if video_path and os.path.exists(video_path):
  72. data = open(video_path, "rb").read()
  73. logger.info("video_extract 用本地文件 %s (%d bytes)", video_path, len(data))
  74. else:
  75. if not post.video_urls:
  76. raise VideoExtractError(f"post {post.id} 无 video_urls 且未提供 video_path")
  77. url = post.video_urls[0]
  78. if post.platform == "douyin" and "ratio=" in url: # 偏好较小码率控成本
  79. url = re.sub(r"ratio=[^&]+", f"ratio={settings.douyin_ratio}", url)
  80. dl = downloader or _default_download
  81. try:
  82. data = dl(url, post.platform)
  83. except Exception as exc:
  84. raise VideoExtractError(f"视频下载失败: {exc}") from exc
  85. if not data:
  86. raise VideoExtractError("视频字节为空")
  87. # 2) base64 + 3) OpenRouter 原生视频
  88. data_url = "data:video/mp4;base64," + base64.b64encode(data).decode()
  89. prompt = load_prompt("extract_video").format()
  90. body = {"model": settings.video_model, "messages": [{"role": "user", "content": [
  91. {"type": "text", "text": prompt},
  92. {"type": "video_url", "video_url": {"url": data_url}},
  93. ]}]}
  94. try:
  95. resp = http_post(
  96. f"{settings.openrouter_base_url.rstrip('/')}/chat/completions",
  97. headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
  98. json=body, timeout=timeout)
  99. resp.raise_for_status()
  100. content = resp.json()["choices"][0]["message"]["content"]
  101. except httpx.HTTPError as exc:
  102. raise VideoExtractError(f"openrouter_http_error: {exc}") from exc
  103. except (KeyError, IndexError, TypeError, ValueError) as exc:
  104. raise VideoExtractError(f"openrouter_response_invalid: {exc}") from exc
  105. obj = extract_json_object(content)
  106. segments = obj.get("segments") or []
  107. # 4) 段卡 + 每段内容
  108. cards: list[Card] = []
  109. card_extracts: list[CardExtract] = []
  110. for i, seg in enumerate(segments, start=1):
  111. if not isinstance(seg, dict):
  112. continue
  113. cards.append(Card(index=i, kind="segment",
  114. start=_mmss_to_sec(seg.get("start")),
  115. end=_mmss_to_sec(seg.get("end"))))
  116. card_extracts.append(CardExtract(index=i, content=_seg_content(seg)))
  117. post.cards = cards # 就地写入段卡,供 upsert 落库 + 前端展示
  118. return ExtractedContent(
  119. text=str(obj.get("overall") or obj.get("video_title") or ""),
  120. cards=card_extracts,
  121. is_empty=len(card_extracts) == 0,
  122. )