| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177 |
- """Native whole-video reader for creation knowledge decode."""
- from __future__ import annotations
- import base64
- import logging
- import os
- import re
- from pathlib import Path
- from typing import Any, Callable, Optional
- import httpx
- from core.config import Settings
- from core.jsonio import extract_json_object
- from core.media_download import download_media_bytes
- from core.models import Card, CardExtract, ExtractedContent, Post
- from core.prompts import load_prompt
- logger = logging.getLogger(__name__)
- class VideoExtractError(RuntimeError):
- pass
- def _mmss_to_sec(value: Any) -> Optional[float]:
- if value is None:
- return None
- if isinstance(value, (int, float)):
- return float(value)
- parts = str(value).strip().split(":")
- try:
- nums = [float(part) for part in parts]
- except ValueError:
- return None
- sec = 0.0
- for number in nums:
- sec = sec * 60 + number
- return sec
- def _default_download(url: str, platform: str, timeout: float = 180.0) -> bytes:
- return download_media_bytes(url, platform, timeout=timeout)
- def _seg_content(segment: dict[str, Any]) -> str:
- parts = [segment.get("title") or ""]
- for label, key in (("What", "what"), ("Why", "why"), ("How", "how")):
- value = segment.get(key)
- if value and str(value).strip().lower() not in {"null", "none", ""}:
- parts.append(f"{label}:{value}")
- return "。".join(part for part in parts if part)
- def extract_video(
- post: Post,
- *,
- settings: Settings,
- http_post: Callable[..., Any] = httpx.post,
- video_path: Optional[str] = None,
- downloader: Optional[Callable[[str, str], bytes]] = None,
- timeout: float = 600.0,
- save_path: Optional[Path] = None,
- public_url: Optional[str] = None,
- oss_video_url: Optional[str] = None,
- ) -> ExtractedContent:
- key = settings.openrouter_api_key
- if not key:
- raise VideoExtractError("missing OPENROUTER_API_KEY")
- if oss_video_url:
- media_url = oss_video_url
- public_url = public_url or oss_video_url
- else:
- if post.platform == "bilibili" and not (video_path and os.path.exists(video_path)):
- raise VideoExtractError("B站视频暂未接入:需取 voice_data 音轨 + ffmpeg 合并")
- if video_path and os.path.exists(video_path):
- data = open(video_path, "rb").read()
- logger.info("video_extract using local file %s (%d bytes)", video_path, len(data))
- else:
- if not post.video_urls:
- raise VideoExtractError(f"post {post.id} has no video_urls and no video_path")
- url = post.video_urls[0]
- if post.platform == "douyin" and "ratio=" in url:
- url = re.sub(r"ratio=[^&]+", f"ratio={settings.douyin_ratio}", url)
- dl = downloader or _default_download
- try:
- data = dl(url, post.platform)
- except Exception as exc:
- raise VideoExtractError(f"视频下载失败: {exc}") from exc
- if not data:
- raise VideoExtractError("视频字节为空")
- if save_path is not None:
- save_path.parent.mkdir(parents=True, exist_ok=True)
- Path(save_path).write_bytes(data)
- logger.info("video_extract saved whole video %s (%d bytes)", save_path, len(data))
- media_url = "data:video/mp4;base64," + base64.b64encode(data).decode()
- prompt = load_prompt("extract_video").format()
- body = {
- "model": settings.video_model,
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": prompt},
- {"type": "video_url", "video_url": {"url": media_url}},
- ],
- }
- ],
- }
- try:
- resp = http_post(
- f"{settings.openrouter_base_url.rstrip('/')}/chat/completions",
- headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
- json=body,
- timeout=timeout,
- )
- resp.raise_for_status()
- content = resp.json()["choices"][0]["message"]["content"]
- except httpx.HTTPError as exc:
- raise VideoExtractError(f"openrouter_http_error: {exc}") from exc
- except (KeyError, IndexError, TypeError, ValueError) as exc:
- raise VideoExtractError(f"openrouter_response_invalid: {exc}") from exc
- obj = extract_json_object(content)
- segments = obj.get("segments") or []
- cards: list[Card] = []
- card_extracts: list[CardExtract] = []
- for idx, segment in enumerate(segments, start=1):
- if not isinstance(segment, dict):
- continue
- cards.append(
- Card(
- index=idx,
- kind="segment",
- url=public_url,
- start=_mmss_to_sec(segment.get("start")),
- end=_mmss_to_sec(segment.get("end")),
- )
- )
- card_extracts.append(CardExtract(index=idx, content=_seg_content(segment)))
- post.cards = cards
- return ExtractedContent(
- text=str(obj.get("overall") or obj.get("video_title") or ""),
- cards=card_extracts,
- is_empty=len(card_extracts) == 0,
- )
- def read_video(
- post: Post,
- *,
- settings: Settings,
- http_post: Callable[..., Any] | None = None,
- video_path: str | None = None,
- downloader: Callable[[str, str], bytes] | None = None,
- save_path: Path | None = None,
- public_url: str | None = None,
- oss_video_url: str | None = None,
- ) -> ExtractedContent:
- kwargs: dict[str, Any] = {
- "settings": settings,
- "video_path": video_path,
- "downloader": downloader,
- "save_path": save_path,
- "public_url": public_url or oss_video_url,
- "oss_video_url": oss_video_url,
- }
- if http_post is not None:
- kwargs["http_post"] = http_post
- return extract_video(post, **kwargs)
- __all__ = ["VideoExtractError", "extract_video", "read_video"]
|