Claude 5e2094f2ad
feat(media): local inference sidecar for Media Studio
FastAPI service in infra/media-worker implementing every
MediaInferenceProvider operation behind HTTP API v1, plus capabilities.
Bearer auth, MEDIA_ROOT realpath enforcement, strict request schemas,
cancellation on disconnect, GPU/CPU gates with 503 overflow, input
size/duration limits, lazy SHA256-pinned model downloads, fail-closed
redaction with atomic output, offline pytest suite with fake backends,
CPU/CUDA/ROCm Dockerfile and a license review of every model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:19:47 +00:00

104 lines
3.7 KiB
Python

"""detectScenes: PySceneDetect (adaptive) for shots, histogram grouping for scenes."""
from __future__ import annotations
import threading
from typing import Any
import cv2
import numpy as np
from scenedetect import AdaptiveDetector, SceneManager, StatsManager, open_video
from .. import media_io
from ..errors import Cancelled
from ..schemas import PathRequest
from .common import Ctx, clamp01, open_input
# Neighbouring shots whose middle frames correlate at least this much share a scene.
SCENE_HIST_CORRELATION = 0.6
ADAPTIVE_THRESHOLD = 3.0
class SceneDetector:
def detect(self, path: str, cancel: threading.Event) -> list[tuple[float, float, float]]:
"""Return shots as (start_s, end_s, score in [0,1])."""
video = open_video(path, backend="opencv")
stats = StatsManager()
manager = SceneManager(stats_manager=stats)
manager.add_detector(AdaptiveDetector(adaptive_threshold=ADAPTIVE_THRESHOLD))
stop = threading.Event()
def watch() -> None:
while not stop.wait(0.2):
if cancel.is_set():
manager.stop()
return
watcher = threading.Thread(target=watch, daemon=True)
watcher.start()
try:
manager.detect_scenes(video=video)
finally:
stop.set()
if cancel.is_set():
raise Cancelled()
scenes = manager.get_scene_list(start_in_scene=True)
ratio_key = next(
(k for k in AdaptiveDetector().get_metrics() if k.startswith("adaptive_ratio")), None
)
shots: list[tuple[float, float, float]] = []
for index, (start, end) in enumerate(scenes):
score = 1.0
if index > 0 and ratio_key:
value = (
stats.get_metrics(start.frame_num, [ratio_key])[0]
if stats.metrics_exist(start.frame_num, [ratio_key])
else None
)
score = clamp01((value or ADAPTIVE_THRESHOLD) / (2 * ADAPTIVE_THRESHOLD))
shots.append((start.seconds, end.seconds, score))
return shots
def load_scene_detector(_files: dict[str, str], _settings: Any) -> SceneDetector:
return SceneDetector()
def _histogram(frame: np.ndarray) -> np.ndarray:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
hist = cv2.calcHist([hsv], [0, 1], None, [32, 16], [0, 180, 0, 256])
cv2.normalize(hist, hist)
return hist
def run(ctx: Ctx, body: PathRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
detector = ctx.registry.runtime("detectScenes")
raw = detector.detect(real, cancel)
duration_ms = int(round(info.duration_s * 1000))
if not raw:
raw = [(0.0, info.duration_s, 1.0)]
shots: list[dict[str, Any]] = []
for start_s, end_s, score in raw:
start_ms = int(round(start_s * 1000))
end_ms = max(int(round(end_s * 1000)), start_ms)
if duration_ms:
end_ms = min(end_ms, duration_ms)
shots.append({"startMs": start_ms, "endMs": end_ms, "score": float(score)})
scenes: list[dict[str, int]] = []
previous: np.ndarray | None = None
for shot in shots:
middle = (shot["startMs"] + shot["endMs"]) // 2
hist = _histogram(media_io.frame_at(real, info, middle, cancel, max_side=320))
same = (
previous is not None
and cv2.compareHist(previous, hist, cv2.HISTCMP_CORREL) >= SCENE_HIST_CORRELATION
)
if same and scenes:
scenes[-1]["endMs"] = shot["endMs"]
else:
scenes.append({"startMs": shot["startMs"], "endMs": shot["endMs"]})
previous = hist
return {"shots": shots, "scenes": scenes}