mirror of
https://github.com/hanzoai/video.git
synced 2026-10-30 18:18:27 +00:00
11 production pipelines, 47 tools, 124 agent skills. Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo). Co-authored-by: Hanzo Dev <[email protected]>
263 lines
8.5 KiB
Python
263 lines
8.5 KiB
Python
"""Scene detection tool wrapping PySceneDetect.
|
|
|
|
Detects scene boundaries and shot changes in video. Falls back to
|
|
FFmpeg-based detection if PySceneDetect is not installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from tools.base_tool import (
|
|
BaseTool,
|
|
Determinism,
|
|
ExecutionMode,
|
|
ResourceProfile,
|
|
ToolResult,
|
|
ToolStability,
|
|
ToolStatus,
|
|
ToolTier,
|
|
)
|
|
|
|
|
|
class SceneDetect(BaseTool):
|
|
name = "scene_detect"
|
|
version = "0.1.0"
|
|
tier = ToolTier.CORE
|
|
capability = "analysis"
|
|
provider = "ffmpeg"
|
|
stability = ToolStability.EXPERIMENTAL
|
|
execution_mode = ExecutionMode.SYNC
|
|
determinism = Determinism.DETERMINISTIC
|
|
|
|
dependencies = ["cmd:ffmpeg"]
|
|
install_instructions = (
|
|
"FFmpeg is required. For better detection install PySceneDetect:\n"
|
|
"pip install scenedetect[opencv]"
|
|
)
|
|
agent_skills = ["ffmpeg"]
|
|
|
|
capabilities = [
|
|
"detect_scenes",
|
|
"detect_content_changes",
|
|
"detect_threshold",
|
|
]
|
|
|
|
input_schema = {
|
|
"type": "object",
|
|
"required": ["input_path"],
|
|
"properties": {
|
|
"input_path": {"type": "string"},
|
|
"method": {
|
|
"type": "string",
|
|
"enum": ["content", "threshold", "adaptive"],
|
|
"default": "content",
|
|
},
|
|
"threshold": {
|
|
"type": "number",
|
|
"description": "Detection threshold (method-dependent)",
|
|
},
|
|
"min_scene_length_seconds": {
|
|
"type": "number",
|
|
"minimum": 0.1,
|
|
"default": 1.0,
|
|
},
|
|
"output_path": {"type": "string", "description": "Path for scene list JSON"},
|
|
},
|
|
}
|
|
|
|
resource_profile = ResourceProfile(cpu_cores=2, ram_mb=1024, vram_mb=0, disk_mb=100)
|
|
idempotency_key_fields = ["input_path", "method", "threshold"]
|
|
side_effects = ["writes scene list JSON to output_path"]
|
|
user_visible_verification = [
|
|
"Spot-check detected scene boundaries against the video",
|
|
]
|
|
|
|
def _has_pyscenedetect(self) -> bool:
|
|
try:
|
|
import scenedetect # noqa: F401
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
def execute(self, inputs: dict[str, Any]) -> ToolResult:
|
|
input_path = Path(inputs["input_path"])
|
|
if not input_path.exists():
|
|
return ToolResult(success=False, error=f"Input not found: {input_path}")
|
|
|
|
start = time.time()
|
|
|
|
if self._has_pyscenedetect():
|
|
scenes = self._detect_pyscenedetect(inputs)
|
|
else:
|
|
scenes = self._detect_ffmpeg(inputs)
|
|
|
|
elapsed = time.time() - start
|
|
|
|
# Write scene list
|
|
output_path = Path(
|
|
inputs.get("output_path", str(input_path.with_suffix(".scenes.json")))
|
|
)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_text(json.dumps({"scenes": scenes}, indent=2), encoding="utf-8")
|
|
|
|
return ToolResult(
|
|
success=True,
|
|
data={
|
|
"scene_count": len(scenes),
|
|
"scenes": scenes,
|
|
"method": "pyscenedetect" if self._has_pyscenedetect() else "ffmpeg",
|
|
"output": str(output_path),
|
|
},
|
|
artifacts=[str(output_path)],
|
|
duration_seconds=round(elapsed, 2),
|
|
)
|
|
|
|
def _detect_pyscenedetect(self, inputs: dict[str, Any]) -> list[dict]:
|
|
"""Use PySceneDetect for scene detection."""
|
|
from scenedetect import open_video, SceneManager
|
|
from scenedetect.detectors import ContentDetector, ThresholdDetector, AdaptiveDetector
|
|
|
|
input_path = str(inputs["input_path"])
|
|
method = inputs.get("method", "content")
|
|
threshold = inputs.get("threshold")
|
|
min_scene_len = inputs.get("min_scene_length_seconds", 1.0)
|
|
|
|
video = open_video(input_path)
|
|
scene_manager = SceneManager()
|
|
|
|
if method == "content":
|
|
detector = ContentDetector(
|
|
threshold=threshold or 27.0,
|
|
min_scene_len=int(min_scene_len * video.frame_rate),
|
|
)
|
|
elif method == "threshold":
|
|
detector = ThresholdDetector(
|
|
threshold=threshold or 12.0,
|
|
min_scene_len=int(min_scene_len * video.frame_rate),
|
|
)
|
|
elif method == "adaptive":
|
|
detector = AdaptiveDetector(
|
|
adaptive_threshold=threshold or 3.0,
|
|
min_scene_len=int(min_scene_len * video.frame_rate),
|
|
)
|
|
else:
|
|
detector = ContentDetector()
|
|
|
|
scene_manager.add_detector(detector)
|
|
scene_manager.detect_scenes(video)
|
|
scene_list = scene_manager.get_scene_list()
|
|
|
|
scenes = []
|
|
for i, (scene_start, scene_end) in enumerate(scene_list):
|
|
scenes.append({
|
|
"index": i,
|
|
"start_seconds": round(scene_start.get_seconds(), 3),
|
|
"end_seconds": round(scene_end.get_seconds(), 3),
|
|
"duration_seconds": round(
|
|
scene_end.get_seconds() - scene_start.get_seconds(), 3
|
|
),
|
|
})
|
|
|
|
return scenes
|
|
|
|
def _detect_ffmpeg(self, inputs: dict[str, Any]) -> list[dict]:
|
|
"""Fallback: use FFmpeg scene change filter."""
|
|
input_path = str(inputs["input_path"])
|
|
threshold = inputs.get("threshold", 0.3)
|
|
min_scene_len = inputs.get("min_scene_length_seconds", 1.0)
|
|
|
|
cmd = [
|
|
"ffprobe",
|
|
"-v", "quiet",
|
|
"-show_entries", "frame=pts_time",
|
|
"-of", "json",
|
|
"-f", "lavfi",
|
|
f"movie='{input_path.replace(chr(92), '/').replace(':', chr(92)+':')}',select='gt(scene,{threshold})'",
|
|
]
|
|
|
|
try:
|
|
result = self.run_command(cmd, timeout=120)
|
|
data = json.loads(result.stdout)
|
|
except Exception:
|
|
# If ffprobe lavfi approach fails, try a simpler method
|
|
return self._detect_ffmpeg_simple(input_path, threshold, min_scene_len)
|
|
|
|
change_points = [0.0]
|
|
for frame in data.get("frames", []):
|
|
ts = float(frame.get("pts_time", 0))
|
|
if ts - change_points[-1] >= min_scene_len:
|
|
change_points.append(ts)
|
|
|
|
# Get total duration
|
|
dur_cmd = [
|
|
"ffprobe", "-v", "quiet",
|
|
"-show_entries", "format=duration",
|
|
"-of", "json", input_path,
|
|
]
|
|
dur_result = self.run_command(dur_cmd)
|
|
total_dur = float(json.loads(dur_result.stdout)["format"]["duration"])
|
|
change_points.append(total_dur)
|
|
|
|
scenes = []
|
|
for i in range(len(change_points) - 1):
|
|
start = change_points[i]
|
|
end = change_points[i + 1]
|
|
scenes.append({
|
|
"index": i,
|
|
"start_seconds": round(start, 3),
|
|
"end_seconds": round(end, 3),
|
|
"duration_seconds": round(end - start, 3),
|
|
})
|
|
|
|
return scenes
|
|
|
|
def _detect_ffmpeg_simple(
|
|
self, input_path: str, threshold: float, min_scene_len: float
|
|
) -> list[dict]:
|
|
"""Simplest fallback: split into uniform segments."""
|
|
dur_cmd = [
|
|
"ffprobe", "-v", "quiet",
|
|
"-show_entries", "format=duration",
|
|
"-of", "json", input_path,
|
|
]
|
|
dur_result = self.run_command(dur_cmd)
|
|
total_dur = float(json.loads(dur_result.stdout)["format"]["duration"])
|
|
|
|
# Use select filter to find scene changes via stdout
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-i", input_path,
|
|
"-vf", f"select='gt(scene,{threshold})',showinfo",
|
|
"-f", "null", "-",
|
|
]
|
|
try:
|
|
result = self.run_command(cmd, timeout=120)
|
|
output = result.stderr
|
|
except Exception:
|
|
output = ""
|
|
|
|
import re
|
|
change_points = [0.0]
|
|
for match in re.finditer(r"pts_time:(\d+\.?\d*)", output):
|
|
ts = float(match.group(1))
|
|
if ts - change_points[-1] >= min_scene_len:
|
|
change_points.append(ts)
|
|
change_points.append(total_dur)
|
|
|
|
scenes = []
|
|
for i in range(len(change_points) - 1):
|
|
start = change_points[i]
|
|
end = change_points[i + 1]
|
|
scenes.append({
|
|
"index": i,
|
|
"start_seconds": round(start, 3),
|
|
"end_seconds": round(end, 3),
|
|
"duration_seconds": round(end - start, 3),
|
|
})
|
|
|
|
return scenes
|