video_extract.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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 pathlib import Path
  14. from typing import Any, Callable, Optional
  15. import httpx
  16. from creation_knowledge.config import Settings
  17. from creation_knowledge.jsonio import extract_json_object
  18. from creation_knowledge.models import Card, CardExtract, ExtractedContent, Post
  19. from creation_knowledge.prompts import load_prompt
  20. logger = logging.getLogger(__name__)
  21. _REFERER = {
  22. "douyin": "https://www.douyin.com/",
  23. "kuaishou": "https://www.kuaishou.com/",
  24. "bilibili": "https://www.bilibili.com/",
  25. "shipinhao": "https://channels.weixin.qq.com/",
  26. "xiaohongshu": "https://www.xiaohongshu.com/",
  27. }
  28. _IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15"
  29. class VideoExtractError(RuntimeError):
  30. pass
  31. def _mmss_to_sec(value: Any) -> Optional[float]:
  32. """'MM:SS' / 'HH:MM:SS' / 数字 → 秒。"""
  33. if value is None:
  34. return None
  35. if isinstance(value, (int, float)):
  36. return float(value)
  37. parts = str(value).strip().split(":")
  38. try:
  39. nums = [float(p) for p in parts]
  40. except ValueError:
  41. return None
  42. sec = 0.0
  43. for n in nums:
  44. sec = sec * 60 + n
  45. return sec
  46. def _default_download(url: str, platform: str, timeout: float = 180.0) -> bytes:
  47. headers = {"User-Agent": _IOS_UA, "Referer": _REFERER.get(platform, "")}
  48. with httpx.stream("GET", url, headers=headers, timeout=timeout,
  49. follow_redirects=True) as r:
  50. r.raise_for_status()
  51. return b"".join(r.iter_bytes())
  52. def _seg_content(seg: dict) -> str:
  53. parts = [seg.get("title") or ""]
  54. for label, key in (("What", "what"), ("Why", "why"), ("How", "how")):
  55. v = seg.get(key)
  56. if v and str(v).strip().lower() not in ("null", "none", ""):
  57. parts.append(f"{label}:{v}")
  58. return "。".join(p for p in parts if p)
  59. def extract_video(
  60. post: Post,
  61. *,
  62. settings: Settings,
  63. http_post: Callable[..., Any] = httpx.post,
  64. video_path: Optional[str] = None,
  65. downloader: Optional[Callable[[str, str], bytes]] = None,
  66. timeout: float = 600.0,
  67. save_path: Optional[Path] = None,
  68. public_url: Optional[str] = None,
  69. oss_video_url: Optional[str] = None,
  70. ) -> ExtractedContent:
  71. """对视频帖做原生整段提炼,就地写好 post.cards(段卡),返回 ExtractedContent。
  72. save_path/public_url 给定时:把下载到的整段 mp4 写到 save_path(单次下载,复用
  73. 内存字节),并把每张段卡 url 设为 public_url(整段共用,前端按 start/end 加 #t 播片段)。
  74. 两者默认 None=不落盘、行为不变。
  75. oss_video_url 给定时:直接把该公网直链喂模型(已验证 Gemini 可直读 OSS http 视频链),
  76. 跳过下载/落盘/base64;段卡 url 默认用它。默认 None=走原 下载+base64 路径(fixture/兜底)。
  77. """
  78. key = settings.openrouter_api_key
  79. if not key:
  80. raise VideoExtractError("missing OPENROUTER_API_KEY")
  81. if oss_video_url:
  82. # OSS 直喂:公网直链直接进 video_url,跳过下载+落盘+base64
  83. media_url = oss_video_url
  84. public_url = public_url or oss_video_url # 段卡指向 OSS 直链
  85. else:
  86. # B站留接口位:DASH 分轨(video m4s + voice_data 音轨 m4s)需 curl 不跟随重定向下载
  87. # 两轨 + ffmpeg 合并,单 URL 下载器拿不到音频。待接入;可先传入预合并的本地 video_path。
  88. if post.platform == "bilibili" and not (video_path and os.path.exists(video_path)):
  89. raise VideoExtractError(
  90. "B站视频暂未接入:需取 voice_data 音轨 + ffmpeg 合并(见 数据接口与来源/接口台账/B站.md)")
  91. # 1) 拿到 mp4 字节
  92. if video_path and os.path.exists(video_path):
  93. data = open(video_path, "rb").read()
  94. logger.info("video_extract 用本地文件 %s (%d bytes)", video_path, len(data))
  95. else:
  96. if not post.video_urls:
  97. raise VideoExtractError(f"post {post.id} 无 video_urls 且未提供 video_path")
  98. url = post.video_urls[0]
  99. if post.platform == "douyin" and "ratio=" in url: # 偏好较小码率控成本
  100. url = re.sub(r"ratio=[^&]+", f"ratio={settings.douyin_ratio}", url)
  101. dl = downloader or _default_download
  102. try:
  103. data = dl(url, post.platform)
  104. except Exception as exc:
  105. raise VideoExtractError(f"视频下载失败: {exc}") from exc
  106. if not data:
  107. raise VideoExtractError("视频字节为空")
  108. # 1.5) 可选落盘:复用内存字节,单次下载即保存(避免二次下载/抖音链接过期)
  109. if save_path is not None:
  110. save_path.parent.mkdir(parents=True, exist_ok=True)
  111. Path(save_path).write_bytes(data)
  112. logger.info("video_extract 保存整段视频 %s (%d bytes)", save_path, len(data))
  113. # 2) base64
  114. media_url = "data:video/mp4;base64," + base64.b64encode(data).decode()
  115. # 3) OpenRouter 原生视频
  116. prompt = load_prompt("extract_video").format()
  117. body = {"model": settings.video_model, "messages": [{"role": "user", "content": [
  118. {"type": "text", "text": prompt},
  119. {"type": "video_url", "video_url": {"url": media_url}},
  120. ]}]}
  121. try:
  122. resp = http_post(
  123. f"{settings.openrouter_base_url.rstrip('/')}/chat/completions",
  124. headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
  125. json=body, timeout=timeout)
  126. resp.raise_for_status()
  127. content = resp.json()["choices"][0]["message"]["content"]
  128. except httpx.HTTPError as exc:
  129. raise VideoExtractError(f"openrouter_http_error: {exc}") from exc
  130. except (KeyError, IndexError, TypeError, ValueError) as exc:
  131. raise VideoExtractError(f"openrouter_response_invalid: {exc}") from exc
  132. obj = extract_json_object(content)
  133. segments = obj.get("segments") or []
  134. # 4) 段卡 + 每段内容
  135. cards: list[Card] = []
  136. card_extracts: list[CardExtract] = []
  137. for i, seg in enumerate(segments, start=1):
  138. if not isinstance(seg, dict):
  139. continue
  140. cards.append(Card(index=i, kind="segment",
  141. url=public_url, # 整段视频公网路径;前端按 start/end 加 #t 播片段
  142. start=_mmss_to_sec(seg.get("start")),
  143. end=_mmss_to_sec(seg.get("end"))))
  144. card_extracts.append(CardExtract(index=i, content=_seg_content(seg)))
  145. post.cards = cards # 就地写入段卡,供 upsert 落库 + 前端展示
  146. return ExtractedContent(
  147. text=str(obj.get("overall") or obj.get("video_title") or ""),
  148. cards=card_extracts,
  149. is_empty=len(card_extracts) == 0,
  150. )