video.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. """Native whole-video reader for creation knowledge decode."""
  2. from __future__ import annotations
  3. import base64
  4. import logging
  5. import os
  6. import re
  7. from pathlib import Path
  8. from typing import Any, Callable, Optional
  9. import httpx
  10. from core.config import Settings
  11. from core.jsonio import extract_json_object
  12. from core.media_download import download_media_bytes
  13. from core.models import Card, CardExtract, ExtractedContent, Post
  14. from core.prompts import load_prompt
  15. logger = logging.getLogger(__name__)
  16. class VideoExtractError(RuntimeError):
  17. pass
  18. def _mmss_to_sec(value: Any) -> Optional[float]:
  19. if value is None:
  20. return None
  21. if isinstance(value, (int, float)):
  22. return float(value)
  23. parts = str(value).strip().split(":")
  24. try:
  25. nums = [float(part) for part in parts]
  26. except ValueError:
  27. return None
  28. sec = 0.0
  29. for number in nums:
  30. sec = sec * 60 + number
  31. return sec
  32. def _default_download(url: str, platform: str, timeout: float = 180.0) -> bytes:
  33. return download_media_bytes(url, platform, timeout=timeout)
  34. def _seg_content(segment: dict[str, Any]) -> str:
  35. parts = [segment.get("title") or ""]
  36. for label, key in (("What", "what"), ("Why", "why"), ("How", "how")):
  37. value = segment.get(key)
  38. if value and str(value).strip().lower() not in {"null", "none", ""}:
  39. parts.append(f"{label}:{value}")
  40. return "。".join(part for part in parts if part)
  41. def extract_video(
  42. post: Post,
  43. *,
  44. settings: Settings,
  45. http_post: Callable[..., Any] = httpx.post,
  46. video_path: Optional[str] = None,
  47. downloader: Optional[Callable[[str, str], bytes]] = None,
  48. timeout: float = 600.0,
  49. save_path: Optional[Path] = None,
  50. public_url: Optional[str] = None,
  51. oss_video_url: Optional[str] = None,
  52. ) -> ExtractedContent:
  53. key = settings.openrouter_api_key
  54. if not key:
  55. raise VideoExtractError("missing OPENROUTER_API_KEY")
  56. if oss_video_url:
  57. media_url = oss_video_url
  58. public_url = public_url or oss_video_url
  59. else:
  60. if post.platform == "bilibili" and not (video_path and os.path.exists(video_path)):
  61. raise VideoExtractError("B站视频暂未接入:需取 voice_data 音轨 + ffmpeg 合并")
  62. if video_path and os.path.exists(video_path):
  63. data = open(video_path, "rb").read()
  64. logger.info("video_extract using local file %s (%d bytes)", video_path, len(data))
  65. else:
  66. if not post.video_urls:
  67. raise VideoExtractError(f"post {post.id} has no video_urls and no video_path")
  68. url = post.video_urls[0]
  69. if post.platform == "douyin" and "ratio=" in url:
  70. url = re.sub(r"ratio=[^&]+", f"ratio={settings.douyin_ratio}", url)
  71. dl = downloader or _default_download
  72. try:
  73. data = dl(url, post.platform)
  74. except Exception as exc:
  75. raise VideoExtractError(f"视频下载失败: {exc}") from exc
  76. if not data:
  77. raise VideoExtractError("视频字节为空")
  78. if save_path is not None:
  79. save_path.parent.mkdir(parents=True, exist_ok=True)
  80. Path(save_path).write_bytes(data)
  81. logger.info("video_extract saved whole video %s (%d bytes)", save_path, len(data))
  82. media_url = "data:video/mp4;base64," + base64.b64encode(data).decode()
  83. prompt = load_prompt("extract_video").format()
  84. body = {
  85. "model": settings.video_model,
  86. "messages": [
  87. {
  88. "role": "user",
  89. "content": [
  90. {"type": "text", "text": prompt},
  91. {"type": "video_url", "video_url": {"url": media_url}},
  92. ],
  93. }
  94. ],
  95. }
  96. try:
  97. resp = http_post(
  98. f"{settings.openrouter_base_url.rstrip('/')}/chat/completions",
  99. headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
  100. json=body,
  101. timeout=timeout,
  102. )
  103. resp.raise_for_status()
  104. content = resp.json()["choices"][0]["message"]["content"]
  105. except httpx.HTTPError as exc:
  106. raise VideoExtractError(f"openrouter_http_error: {exc}") from exc
  107. except (KeyError, IndexError, TypeError, ValueError) as exc:
  108. raise VideoExtractError(f"openrouter_response_invalid: {exc}") from exc
  109. obj = extract_json_object(content)
  110. segments = obj.get("segments") or []
  111. cards: list[Card] = []
  112. card_extracts: list[CardExtract] = []
  113. for idx, segment in enumerate(segments, start=1):
  114. if not isinstance(segment, dict):
  115. continue
  116. cards.append(
  117. Card(
  118. index=idx,
  119. kind="segment",
  120. url=public_url,
  121. start=_mmss_to_sec(segment.get("start")),
  122. end=_mmss_to_sec(segment.get("end")),
  123. )
  124. )
  125. card_extracts.append(CardExtract(index=idx, content=_seg_content(segment)))
  126. post.cards = cards
  127. return ExtractedContent(
  128. text=str(obj.get("overall") or obj.get("video_title") or ""),
  129. cards=card_extracts,
  130. is_empty=len(card_extracts) == 0,
  131. )
  132. def read_video(
  133. post: Post,
  134. *,
  135. settings: Settings,
  136. http_post: Callable[..., Any] | None = None,
  137. video_path: str | None = None,
  138. downloader: Callable[[str, str], bytes] | None = None,
  139. save_path: Path | None = None,
  140. public_url: str | None = None,
  141. oss_video_url: str | None = None,
  142. ) -> ExtractedContent:
  143. kwargs: dict[str, Any] = {
  144. "settings": settings,
  145. "video_path": video_path,
  146. "downloader": downloader,
  147. "save_path": save_path,
  148. "public_url": public_url or oss_video_url,
  149. "oss_video_url": oss_video_url,
  150. }
  151. if http_post is not None:
  152. kwargs["http_post"] = http_post
  153. return extract_video(post, **kwargs)
  154. __all__ = ["VideoExtractError", "extract_video", "read_video"]