CMVR-AI-ANALYSIS/app/video.py

336 lines
12 KiB
Python
Raw Normal View History

2026-08-13 16:56:32 +08:00
import base64
import copy
import json
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Any
import httpx
from app.config import settings
from app.profile_store import Profile
class VisionModelError(RuntimeError):
pass
VIDEO_RESULT_SCHEMA = {
"type": "object",
"properties": {
"passed": {"type": ["boolean", "null"]},
"conclusion": {"type": "string"},
"summary": {"type": "string"},
"events": {
"type": "array",
"items": {
"type": "object",
"properties": {
"timeRange": {"type": "string"},
"event": {"type": "string"},
"confidence": {"type": "number"},
},
"required": ["timeRange", "event", "confidence"],
},
},
"warnings": {"type": "array", "items": {"type": "string"}},
},
"required": ["passed", "conclusion", "summary", "events", "warnings"],
}
def _duration(path: Path) -> float:
process = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True,
text=True,
check=False,
)
try:
value = float(process.stdout.strip())
except ValueError as exc:
raise ValueError("Unable to read video duration") from exc
if value <= 0:
raise ValueError("Video duration must be positive")
return value
def _extract_frames(
path: Path, maximum: int, maximum_width: int
) -> tuple[list[Path], float, Path]:
duration = _duration(path)
count = max(2, min(maximum, int(duration) + 1))
fps = count / duration
directory = Path(tempfile.mkdtemp(prefix="frames-", dir=settings.jobs_dir))
output = directory / "frame-%03d.jpg"
process = subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-i",
str(path),
"-vf",
f"fps={fps:.8f},scale='min({maximum_width},iw)':-2",
"-frames:v",
str(count),
"-q:v",
"3",
str(output),
],
capture_output=True,
check=False,
)
frames = sorted(directory.glob("frame-*.jpg"))
if process.returncode != 0 or not frames:
error = process.stderr.decode("utf-8", errors="replace")[-500:]
raise ValueError(f"Unable to extract video frames: {error}")
return frames, duration, directory
def _select_frames(frames: list[Path], maximum: int) -> list[Path]:
if len(frames) <= maximum:
return frames
if maximum <= 1:
return [frames[len(frames) // 2]]
indices = [round(index * (len(frames) - 1) / (maximum - 1)) for index in range(maximum)]
return [frames[index] for index in indices]
def _encode_image(path: Path, maximum_width: int, extracted_width: int) -> str:
if maximum_width >= extracted_width:
image = path.read_bytes()
else:
process = subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-i",
str(path),
"-vf",
f"scale='min({maximum_width},iw)':-2",
"-q:v",
"3",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"pipe:1",
],
capture_output=True,
check=False,
)
if process.returncode != 0 or not process.stdout:
error = process.stderr.decode("utf-8", errors="replace")[-500:]
raise ValueError(f"Unable to resize video frame: {error}")
image = process.stdout
return base64.b64encode(image).decode("ascii")
def _parse_json(content: str) -> dict[str, Any]:
content = content.strip()
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL)
if fenced:
content = fenced.group(1)
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {"result": parsed, "structured": False}
except json.JSONDecodeError:
return {"summary": content, "structured": False}
def _fallback_reason(result: dict[str, Any], fallback_on_unknown: bool) -> str | None:
if result.get("structured") is False:
return "FAST_RESULT_NOT_STRUCTURED"
required = ("passed", "conclusion", "summary", "events", "warnings")
missing = [field for field in required if field not in result]
if missing:
return "FAST_RESULT_MISSING_FIELDS:" + ",".join(missing)
if not isinstance(result.get("conclusion"), str) or not result["conclusion"].strip():
return "FAST_RESULT_EMPTY_CONCLUSION"
if not isinstance(result.get("summary"), str) or not result["summary"].strip():
return "FAST_RESULT_EMPTY_SUMMARY"
if not isinstance(result.get("events"), list) or not isinstance(result.get("warnings"), list):
return "FAST_RESULT_INVALID_COLLECTIONS"
if fallback_on_unknown and result.get("passed") is None:
return "FAST_RESULT_INSUFFICIENT"
if result.get("passed") is not True and result.get("passed") is not False and result.get("passed") is not None:
return "FAST_RESULT_INVALID_DECISION"
return None
def _strategy(profile: Profile, name: str) -> dict[str, Any]:
defaults = {
"FAST": {
"model": profile.config.get("model", settings.ollama_model),
"maxFrames": 6,
"maxWidth": 896,
"maxOutputTokens": 512,
"numCtx": 16384,
"emptyResponseRetries": 0,
},
"ACCURATE": {
"model": profile.config.get("model", settings.ollama_model),
"maxFrames": 12,
"maxWidth": 1280,
"maxOutputTokens": 768,
"numCtx": 32768,
"emptyResponseRetries": 1,
},
}
configured = profile.config.get("strategies", {}).get(name.lower(), {})
return {**defaults[name], **configured}
async def _request_vision_model(
payload: dict[str, Any], empty_response_retries: int = 1
) -> tuple[str, dict[str, Any]]:
last_metadata: dict[str, Any] = {}
timeout = httpx.Timeout(settings.ollama_timeout_seconds)
async with httpx.AsyncClient(timeout=timeout) as client:
for attempt in range(empty_response_retries + 1):
request_payload = copy.deepcopy(payload)
if attempt:
options = request_payload.setdefault("options", {})
options["num_predict"] = max(int(options.get("num_predict", 0)), 1536)
request_payload["messages"][0]["content"] += (
"\n\nReturn the final JSON now. Do not return reasoning without a final answer."
)
response = await client.post(
f"{settings.ollama_base_url.rstrip('/')}/api/chat", json=request_payload
)
response.raise_for_status()
body = response.json()
message = body.get("message") or {}
content = str(message.get("content") or "").strip()
metadata = {
"doneReason": body.get("done_reason"),
"promptEvalCount": body.get("prompt_eval_count"),
"evalCount": body.get("eval_count"),
"totalDurationNs": body.get("total_duration"),
"thinkingLength": len(str(message.get("thinking") or "")),
}
if content:
return content, metadata
last_metadata = metadata
raise VisionModelError(
"Vision model returned an empty final response after retry "
f"({json.dumps(last_metadata, ensure_ascii=False)})"
)
async def _analyze_frames(
profile: Profile,
strategy_name: str,
frames: list[Path],
duration: float,
extracted_width: int,
prompt: str,
) -> tuple[dict[str, Any], dict[str, Any]]:
strategy = _strategy(profile, strategy_name)
selected = _select_frames(frames, max(2, int(strategy["maxFrames"])))
maximum_width = max(320, int(strategy["maxWidth"]))
images = [_encode_image(path, maximum_width, extracted_width) for path in selected]
strategy_prompt = prompt + (
f"\n\nThe video duration is {duration:.3f} seconds. "
f"The following {len(selected)} images are ordered frames sampled across the video."
)
payload = {
"model": strategy["model"],
"stream": False,
"think": bool(strategy.get("think", profile.config.get("think", False))),
"format": VIDEO_RESULT_SCHEMA,
"keep_alive": str(strategy.get("keepAlive", "30m")),
"messages": [{"role": "user", "content": strategy_prompt, "images": images}],
"options": {
"temperature": float(strategy.get("temperature", profile.config.get("temperature", 0.1))),
"num_predict": int(strategy["maxOutputTokens"]),
"num_ctx": int(strategy["numCtx"]),
},
}
content, metrics = await _request_vision_model(
payload, int(strategy.get("emptyResponseRetries", 0))
)
metadata = {
"mode": strategy_name,
"model": strategy["model"],
"sampledFrameCount": len(selected),
"maximumWidth": maximum_width,
**metrics,
}
return _parse_json(content), metadata
async def analyze_video(
profile: Profile,
media_path: Path,
extra_instruction: str = "",
analysis_mode: str = "AUTO",
) -> tuple[dict[str, Any], float, dict[str, Any]]:
requested_mode = str(analysis_mode or "AUTO").strip().upper()
if requested_mode not in {"AUTO", "FAST", "ACCURATE"}:
raise ValueError("Video analysis mode must be AUTO, FAST, or ACCURATE")
fast = _strategy(profile, "FAST")
accurate = _strategy(profile, "ACCURATE")
extraction_strategy = fast if requested_mode == "FAST" else accurate
extraction_frames = max(2, int(extraction_strategy["maxFrames"]))
extraction_width = max(320, int(extraction_strategy["maxWidth"]))
frames, duration, frame_dir = _extract_frames(
media_path, extraction_frames, extraction_width
)
try:
prompt_path = profile.directory / str(profile.config.get("promptFile", "prompt.txt"))
prompt = prompt_path.read_text(encoding="utf-8")
if extra_instruction.strip():
prompt += "\n\nAdditional inspection requirement:\n" + extra_instruction.strip()
if requested_mode in {"FAST", "ACCURATE"}:
result, metadata = await _analyze_frames(
profile, requested_mode, frames, duration, extraction_width, prompt
)
metadata.update(
{"requestedMode": requested_mode, "fallback": False, "fallbackReason": None}
)
return result, duration, metadata
fallback_reason = None
try:
result, metadata = await _analyze_frames(
profile, "FAST", frames, duration, extraction_width, prompt
)
fallback_reason = _fallback_reason(
result, bool(profile.config.get("fallbackWhenPassedUnknown", True))
)
if fallback_reason is None:
metadata.update(
{"requestedMode": "AUTO", "fallback": False, "fallbackReason": None}
)
return result, duration, metadata
except (VisionModelError, httpx.HTTPError) as exc:
fallback_reason = f"FAST_MODEL_ERROR:{type(exc).__name__}"
result, metadata = await _analyze_frames(
profile, "ACCURATE", frames, duration, extraction_width, prompt
)
metadata.update(
{"requestedMode": "AUTO", "fallback": True, "fallbackReason": fallback_reason}
)
return result, duration, metadata
finally:
for frame in frames:
frame.unlink(missing_ok=True)
frame_dir.rmdir()