Add reference video input analysis workflow

This commit is contained in:
calesthio
2026-04-04 10:01:11 -07:00
parent 87df43d39f
commit b0917d2d84
25 changed files with 2640 additions and 5 deletions
+69 -1
View File
@@ -44,6 +44,7 @@ class FrameSampler(BaseTool):
"extract_frames_interval",
"extract_frames_count",
"extract_frames_timestamps",
"extract_frames_scene_guided",
]
input_schema = {
@@ -53,7 +54,7 @@ class FrameSampler(BaseTool):
"input_path": {"type": "string"},
"strategy": {
"type": "string",
"enum": ["interval", "count", "timestamps"],
"enum": ["interval", "count", "timestamps", "scene_guided"],
},
"interval_seconds": {
"type": "number",
@@ -70,6 +71,23 @@ class FrameSampler(BaseTool):
"items": {"type": "number"},
"description": "Specific timestamps in seconds (for timestamps strategy)",
},
"scene_boundaries": {
"type": "array",
"items": {
"type": "object",
"properties": {
"start_seconds": {"type": "number"},
"end_seconds": {"type": "number"},
},
},
"description": "Scene boundary list (for scene_guided strategy)",
},
"max_frames": {
"type": "integer",
"minimum": 1,
"default": 20,
"description": "Max frames to extract (for scene_guided strategy)",
},
"output_dir": {"type": "string"},
"format": {"type": "string", "enum": ["png", "jpg"], "default": "jpg"},
"quality": {"type": "integer", "minimum": 1, "maximum": 31, "default": 2},
@@ -101,6 +119,8 @@ class FrameSampler(BaseTool):
frames = self._extract_count(input_path, output_dir, fmt, quality, inputs)
elif strategy == "timestamps":
frames = self._extract_timestamps(input_path, output_dir, fmt, quality, inputs)
elif strategy == "scene_guided":
frames = self._extract_scene_guided(input_path, output_dir, fmt, quality, inputs)
else:
return ToolResult(success=False, error=f"Unknown strategy: {strategy}")
except Exception as e:
@@ -208,6 +228,54 @@ class FrameSampler(BaseTool):
return frames
def _extract_scene_guided(
self,
input_path: Path,
output_dir: Path,
fmt: str,
quality: int,
inputs: dict,
) -> list[dict]:
"""Extract keyframes guided by scene boundaries.
Extracts the first frame of each scene plus a midpoint frame for scenes
longer than 3 seconds. This captures all visual transitions with a
bounded, predictable number of frames — much better than uniform FPS.
"""
scene_boundaries = inputs.get("scene_boundaries", [])
max_frames = inputs.get("max_frames", 20)
if not scene_boundaries:
# No scene data — fall back to count-based
return self._extract_count(input_path, output_dir, fmt, quality, {
"count": min(max_frames, 15),
})
# Compute timestamps: first frame + midpoint for long scenes
timestamps = []
for scene in scene_boundaries:
start = scene.get("start_seconds", 0)
end = scene.get("end_seconds", 0)
duration = end - start
# First frame of scene (offset slightly to avoid black frames)
timestamps.append(start + 0.1)
# Midpoint for scenes > 3 seconds
if duration > 3.0:
timestamps.append(start + duration / 2)
# Deduplicate, sort, limit
timestamps = sorted(set(round(t, 3) for t in timestamps))
if len(timestamps) > max_frames:
step = len(timestamps) / max_frames
timestamps = [timestamps[int(i * step)] for i in range(max_frames)]
# Extract via timestamps strategy
return self._extract_timestamps(
input_path, output_dir, fmt, quality, {"timestamps": timestamps}
)
def _get_duration(self, input_path: Path) -> float:
"""Get video duration in seconds via ffprobe."""
cmd = [
+216
View File
@@ -0,0 +1,216 @@
"""YouTube transcript fetcher tool wrapping youtube-transcript-api.
Extracts transcripts/captions from YouTube videos without downloading the video.
Instant, free, no API key needed. Falls back to yt-dlp subtitle download.
"""
from __future__ import annotations
import re
import time
from typing import Any
from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
ToolResult,
ToolStability,
ToolStatus,
ToolTier,
ToolRuntime,
)
class TranscriptFetcher(BaseTool):
name = "transcript_fetcher"
version = "0.1.0"
tier = ToolTier.ANALYZE
capability = "analysis"
provider = "youtube-transcript-api"
stability = ToolStability.PRODUCTION
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
runtime = ToolRuntime.LOCAL
dependencies = ["python:youtube_transcript_api"]
install_instructions = (
"Install youtube-transcript-api: pip install youtube-transcript-api"
)
agent_skills = []
capabilities = [
"fetch_transcript",
"list_transcripts",
]
best_for = [
"fast YouTube transcript extraction",
"caption-based analysis without video download",
"getting timestamped text from YouTube videos",
]
not_good_for = [
"non-YouTube platforms (Instagram, TikTok)",
"videos without any captions",
"speaker diarization (use transcriber tool instead)",
]
input_schema = {
"type": "object",
"required": ["url_or_video_id"],
"properties": {
"url_or_video_id": {
"type": "string",
"description": "YouTube URL or video ID",
},
"languages": {
"type": "array",
"items": {"type": "string"},
"default": ["en"],
"description": "Preferred languages in priority order",
},
"include_auto_generated": {
"type": "boolean",
"default": True,
"description": "Whether to include auto-generated captions",
},
},
}
output_schema = {
"type": "object",
"properties": {
"transcript": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"start": {"type": "number"},
"duration": {"type": "number"},
},
},
},
"full_text": {"type": "string"},
"language": {"type": "string"},
"is_auto_generated": {"type": "boolean"},
"word_count": {"type": "integer"},
"source": {"type": "string"},
},
}
resource_profile = ResourceProfile(
cpu_cores=1, ram_mb=256, vram_mb=0, disk_mb=10,
network_required=True,
)
idempotency_key_fields = ["url_or_video_id", "languages"]
side_effects = []
fallback = "transcriber"
user_visible_verification = [
"Spot-check transcript accuracy against video audio",
]
def _extract_video_id(self, url_or_id: str) -> str:
"""Extract YouTube video ID from URL or return as-is if already an ID."""
# Already a bare ID (11 chars, alphanumeric + - _)
if re.match(r"^[A-Za-z0-9_-]{11}$", url_or_id):
return url_or_id
# Standard YouTube URLs
patterns = [
r"(?:youtube\.com/watch\?.*v=|youtu\.be/|youtube\.com/embed/|youtube\.com/shorts/)([A-Za-z0-9_-]{11})",
]
for pattern in patterns:
match = re.search(pattern, url_or_id)
if match:
return match.group(1)
# If nothing matched, try using the whole string as ID
return url_or_id.strip()
def execute(self, inputs: dict[str, Any]) -> ToolResult:
video_id = self._extract_video_id(inputs["url_or_video_id"])
languages = inputs.get("languages", ["en"])
include_auto = inputs.get("include_auto_generated", True)
start = time.time()
try:
from youtube_transcript_api import YouTubeTranscriptApi
ytt = YouTubeTranscriptApi()
# Fetch transcript using the instance-based API (v1.0+)
transcript_result = ytt.fetch(video_id, languages=languages)
# Build segments and full text from snippets
segments = []
full_text_parts = []
for snippet in transcript_result.snippets:
segments.append({
"text": snippet.text,
"start": round(snippet.start, 3),
"duration": round(snippet.duration, 3),
})
full_text_parts.append(snippet.text)
full_text = " ".join(full_text_parts)
word_count = len(full_text.split())
# Get auto-generated status and language from the result
is_auto = getattr(transcript_result, "is_generated", False)
detected_lang = getattr(transcript_result, "language", languages[0])
# If language is an object, get the code
if hasattr(detected_lang, "code"):
detected_lang = detected_lang.code
elif not isinstance(detected_lang, str):
detected_lang = languages[0]
elapsed = time.time() - start
return ToolResult(
success=True,
data={
"transcript": segments,
"full_text": full_text,
"language": detected_lang,
"is_auto_generated": is_auto,
"word_count": word_count,
"source": "youtube_captions",
"video_id": video_id,
"segment_count": len(segments),
},
duration_seconds=round(elapsed, 2),
)
except ImportError:
return ToolResult(
success=False,
error="youtube-transcript-api not installed. Run: pip install youtube-transcript-api",
)
except Exception as e:
elapsed = time.time() - start
error_str = str(e)
# Provide helpful error messages
if "Could not retrieve" in error_str or "TranscriptsDisabled" in error_str:
return ToolResult(
success=False,
error=(
f"No captions available for video {video_id}. "
"This video may not have captions enabled. "
"Fallback: download the video and use the transcriber tool "
"with Whisper for local transcription."
),
data={"video_id": video_id, "fallback_suggested": "transcriber"},
duration_seconds=round(elapsed, 2),
)
return ToolResult(
success=False,
error=f"Transcript fetch failed: {error_str}",
data={"video_id": video_id},
duration_seconds=round(elapsed, 2),
)
+678
View File
@@ -0,0 +1,678 @@
"""Video analyzer tool — comprehensive reference video analysis.
Orchestrates multiple analysis tools to produce a VideoAnalysisBrief from a
video URL or local file. Runs entirely locally with zero API keys: yt-dlp for
download, youtube-transcript-api for captions, PySceneDetect/FFmpeg for scene
detection, FFmpeg for frame extraction, and faster-whisper for transcription.
The agent's own vision model analyzes extracted keyframes — this tool provides
the structured data; the agent provides the visual interpretation.
"""
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,
ToolRuntime,
)
class VideoAnalyzer(BaseTool):
name = "video_analyzer"
version = "0.1.0"
tier = ToolTier.ANALYZE
capability = "analysis"
provider = "multi"
stability = ToolStability.BETA
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
runtime = ToolRuntime.LOCAL
dependencies = ["cmd:ffmpeg"]
install_instructions = (
"Core: FFmpeg is required (https://ffmpeg.org/download.html)\n"
"For URL downloads: pip install yt-dlp\n"
"For YouTube transcripts: pip install youtube-transcript-api\n"
"For local transcription: pip install faster-whisper\n"
"For scene detection: pip install scenedetect[opencv]\n"
"All dependencies are free and local — no API keys needed."
)
agent_skills = ["video-understand", "ffmpeg"]
capabilities = [
"analyze_reference_video",
"extract_structure",
"extract_style",
"extract_transcript",
]
best_for = [
"comprehensive video analysis",
"reference video understanding",
"style extraction from example video",
"understanding video structure and pacing",
]
not_good_for = [
"editing or modifying video",
"generating new video content",
]
input_schema = {
"type": "object",
"required": ["source"],
"properties": {
"source": {
"type": "string",
"description": "Video file path or URL (YouTube, Shorts, Instagram, TikTok)",
},
"analysis_depth": {
"type": "string",
"enum": ["transcript_only", "standard", "deep"],
"default": "standard",
"description": (
"transcript_only: transcript + metadata only. "
"standard: + scene detection + keyframes + audio energy. "
"deep: + intra-scene sampling + detailed style extraction."
),
},
"max_keyframes": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 50,
"description": "Maximum keyframes to extract",
},
"output_dir": {
"type": "string",
"description": "Directory for analysis outputs (default: auto-generated)",
},
},
}
output_schema = {
"type": "object",
"description": "VideoAnalysisBrief artifact — see schemas/artifacts/video_analysis_brief.schema.json",
}
resource_profile = ResourceProfile(
cpu_cores=2, ram_mb=2048, vram_mb=0, disk_mb=3000,
network_required=False, # Only needed for URL sources
)
idempotency_key_fields = ["source", "analysis_depth"]
side_effects = [
"downloads video to output_dir (if URL)",
"writes keyframe images to output_dir/keyframes/",
"writes analysis JSON to output_dir/video_analysis_brief.json",
]
fallback_tools = []
user_visible_verification = [
"Review keyframe images for representative coverage",
"Check transcript accuracy against video",
"Verify scene boundaries look correct",
]
def _is_url(self, source: str) -> bool:
"""Check if source is a URL vs local file."""
return source.startswith(("http://", "https://", "www."))
def _detect_platform(self, source: str) -> str:
"""Detect platform from URL."""
if not self._is_url(source):
return "local_file"
s = source.lower()
if "youtube.com/shorts" in s:
return "shorts"
if "youtube.com" in s or "youtu.be" in s:
return "youtube"
if "instagram.com" in s:
return "instagram"
if "tiktok.com" in s:
return "tiktok"
return "other_url"
def _is_youtube(self, platform: str) -> bool:
return platform in ("youtube", "shorts")
def execute(self, inputs: dict[str, Any]) -> ToolResult:
source = inputs["source"]
depth = inputs.get("analysis_depth", "standard")
max_keyframes = inputs.get("max_keyframes", 20)
# Setup output directory
if inputs.get("output_dir"):
output_dir = Path(inputs["output_dir"])
else:
output_dir = Path("projects/_analysis") / f"analysis_{int(time.time())}"
output_dir.mkdir(parents=True, exist_ok=True)
platform = self._detect_platform(source)
is_url = self._is_url(source)
start = time.time()
# Initialize brief structure
brief = {
"version": "1.0",
"source": {
"type": platform,
"duration_seconds": 0,
},
"content_analysis": {
"summary": "",
"topics": [],
"target_audience": "general",
},
"structure_analysis": {
"total_scenes": 0,
"scenes": [],
"pacing_profile": {},
},
}
if is_url:
brief["source"]["url"] = source
else:
brief["source"]["local_path"] = source
# Track what succeeded and what failed
steps_completed = []
steps_failed = []
# ─── STEP 1: Get metadata + download (if URL) ───
video_path = None
audio_path = None
metadata = {}
if is_url:
try:
from tools.analysis.video_downloader import VideoDownloader
downloader = VideoDownloader()
if depth == "transcript_only" and self._is_youtube(platform):
# Only get metadata, skip video download
dl_result = downloader.execute({
"url": source,
"output_dir": str(output_dir),
"format": "metadata_only",
})
else:
dl_result = downloader.execute({
"url": source,
"output_dir": str(output_dir),
"format": "video",
"max_resolution": "720p",
})
if dl_result.success:
metadata = dl_result.data.get("metadata", {})
video_path = dl_result.data.get("video_path")
audio_path = dl_result.data.get("audio_path")
brief["source"]["title"] = metadata.get("title", "")
brief["source"]["duration_seconds"] = metadata.get("duration", 0)
brief["source"]["resolution"] = metadata.get("resolution", "")
brief["source"]["platform_metadata"] = {
"uploader": metadata.get("uploader", ""),
"upload_date": metadata.get("upload_date", ""),
"view_count": metadata.get("view_count", 0),
"like_count": metadata.get("like_count", 0),
"description": metadata.get("description", ""),
}
steps_completed.append("metadata")
if video_path:
steps_completed.append("download")
else:
steps_failed.append(f"download: {dl_result.error}")
except Exception as e:
steps_failed.append(f"download: {e}")
else:
# Local file
local_path = Path(source)
if not local_path.exists():
return ToolResult(
success=False,
error=f"Local file not found: {source}",
)
video_path = str(local_path)
# Get duration via ffprobe
try:
duration = self._get_duration(local_path)
brief["source"]["duration_seconds"] = duration
brief["source"]["title"] = local_path.stem
steps_completed.append("metadata")
except Exception as e:
steps_failed.append(f"metadata: {e}")
# ─── STEP 2: Get transcript ───
transcript_data = None
# Try youtube-transcript-api first (instant, for YouTube)
if self._is_youtube(platform):
try:
from youtube_transcript_api import YouTubeTranscriptApi
from tools.analysis.transcript_fetcher import TranscriptFetcher
fetcher = TranscriptFetcher()
# Auto-detect available languages instead of hardcoding "en"
languages_to_try = ["en"]
try:
ytt = YouTubeTranscriptApi()
available = ytt.list(fetcher._extract_video_id(source))
# Build priority list: manual first, then auto-generated
lang_codes = []
for t in available:
code = t.language_code if hasattr(t, "language_code") else str(t)
if code not in lang_codes:
lang_codes.append(code)
if lang_codes:
languages_to_try = lang_codes
except Exception:
pass # Fall through to default ["en"]
tf_result = fetcher.execute({
"url_or_video_id": source,
"languages": languages_to_try,
"include_auto_generated": True,
})
if tf_result.success:
transcript_data = tf_result.data
brief["narration_transcript"] = {
"full_text": transcript_data.get("full_text", ""),
"segments": transcript_data.get("transcript", []),
"language": transcript_data.get("language", "en"),
"word_count": transcript_data.get("word_count", 0),
}
steps_completed.append("transcript_youtube")
except Exception as e:
steps_failed.append(f"transcript_youtube: {e}")
# Fallback: If transcript failed and we don't have audio yet,
# download the video to get audio for Whisper transcription
if transcript_data is None and audio_path is None and video_path is None and is_url:
try:
from tools.analysis.video_downloader import VideoDownloader
downloader = VideoDownloader()
dl_result = downloader.execute({
"url": source,
"output_dir": str(output_dir),
"format": "video",
"max_resolution": "720p",
})
if dl_result.success:
video_path = dl_result.data.get("video_path")
audio_path = dl_result.data.get("audio_path")
if video_path:
steps_completed.append("download_for_whisper")
# Also update metadata if we didn't have it
if not metadata:
metadata = dl_result.data.get("metadata", {})
brief["source"]["title"] = metadata.get("title", "")
brief["source"]["duration_seconds"] = metadata.get("duration", 0)
except Exception as e:
steps_failed.append(f"download_for_whisper: {e}")
# Fallback: Whisper transcription on audio
if transcript_data is None and audio_path:
try:
from tools.analysis.transcriber import Transcriber
transcriber = Transcriber()
# Let Whisper auto-detect language instead of assuming English
tr_inputs = {
"input_path": audio_path,
"model_size": "base",
"output_dir": str(output_dir),
}
# Only set language if we know it from transcript attempt
detected_lang = brief.get("narration_transcript", {}).get("language")
if detected_lang and detected_lang != "en":
tr_inputs["language"] = detected_lang
# else: let Whisper auto-detect
tr_result = transcriber.execute(tr_inputs)
if tr_result.success:
segments = tr_result.data.get("segments", [])
full_text = " ".join(s.get("text", "") for s in segments)
brief["narration_transcript"] = {
"full_text": full_text,
"segments": [
{
"start": s.get("start", 0),
"end": s.get("end", 0),
"text": s.get("text", ""),
}
for s in segments
],
"language": tr_result.data.get("language", "en"),
"word_count": len(full_text.split()),
}
transcript_data = brief["narration_transcript"]
steps_completed.append("transcript_whisper")
except Exception as e:
steps_failed.append(f"transcript_whisper: {e}")
# For transcript_only depth, we're done
if depth == "transcript_only":
brief["_analysis_meta"] = {
"depth": depth,
"steps_completed": steps_completed,
"steps_failed": steps_failed,
"duration_seconds": round(time.time() - start, 2),
}
self._save_brief(brief, output_dir)
return ToolResult(
success=True,
data=brief,
artifacts=[str(output_dir / "video_analysis_brief.json")],
duration_seconds=round(time.time() - start, 2),
)
# ─── STEP 3: Scene detection (standard + deep) ───
scenes = []
if video_path:
try:
from tools.analysis.scene_detect import SceneDetect
detector = SceneDetect()
sd_result = detector.execute({
"input_path": video_path,
"method": "content",
"min_scene_length_seconds": 0.5,
"output_path": str(output_dir / "scenes.json"),
})
if sd_result.success:
scenes = sd_result.data.get("scenes", [])
steps_completed.append("scene_detect")
except Exception as e:
steps_failed.append(f"scene_detect: {e}")
# Build scene list for the brief
if scenes:
brief["structure_analysis"]["total_scenes"] = len(scenes)
brief_scenes = []
for scene in scenes:
brief_scenes.append({
"scene_index": scene.get("index", scene.get("scene_index", 0)),
"start_time": scene.get("start_seconds", 0),
"end_time": scene.get("end_seconds", 0),
"description": "", # Agent fills this via vision
"visual_type": "other", # Agent classifies via vision
"energy_level": "medium",
})
brief["structure_analysis"]["scenes"] = brief_scenes
# Compute pacing profile
durations = [
s.get("end_seconds", 0) - s.get("start_seconds", 0)
for s in scenes
]
total_duration = brief["source"]["duration_seconds"] or sum(durations)
if durations:
brief["structure_analysis"]["pacing_profile"] = {
"avg_scene_duration_seconds": round(sum(durations) / len(durations), 2),
"shortest_scene_seconds": round(min(durations), 2),
"longest_scene_seconds": round(max(durations), 2),
"cuts_per_minute": round(len(durations) / (total_duration / 60), 2) if total_duration > 0 else 0,
"pacing_style": self._classify_pacing(durations),
}
# ─── STEP 4: Keyframe extraction (scene-guided) ───
keyframes = []
keyframe_dir = output_dir / "keyframes"
if video_path and scenes:
try:
# Extract keyframes at scene boundaries + midpoints
timestamps = self._compute_keyframe_timestamps(scenes, max_keyframes, depth)
from tools.analysis.frame_sampler import FrameSampler
sampler = FrameSampler()
fs_result = sampler.execute({
"input_path": video_path,
"strategy": "timestamps",
"timestamps": timestamps,
"output_dir": str(keyframe_dir),
"format": "jpg",
"quality": 2,
})
if fs_result.success:
for frame in fs_result.data.get("frames", []):
# Map each frame to its scene
scene_idx = self._timestamp_to_scene(
frame["timestamp_seconds"], scenes
)
keyframes.append({
"timestamp": frame["timestamp_seconds"],
"scene_index": scene_idx,
"path": frame["path"],
"description": "", # Agent fills via vision
})
steps_completed.append("keyframes")
except Exception as e:
steps_failed.append(f"keyframes: {e}")
elif video_path and not scenes:
# No scene detection — fall back to count-based extraction
try:
from tools.analysis.frame_sampler import FrameSampler
sampler = FrameSampler()
fs_result = sampler.execute({
"input_path": video_path,
"strategy": "count",
"count": min(max_keyframes, 15),
"output_dir": str(keyframe_dir),
"format": "jpg",
"quality": 2,
})
if fs_result.success:
for frame in fs_result.data.get("frames", []):
keyframes.append({
"timestamp": frame["timestamp_seconds"],
"scene_index": 0,
"path": frame["path"],
"description": "",
})
steps_completed.append("keyframes_uniform")
except Exception as e:
steps_failed.append(f"keyframes_uniform: {e}")
brief["keyframes"] = keyframes
# ─── STEP 5: Audio energy analysis ───
if audio_path or video_path:
audio_source = audio_path or video_path
try:
from tools.analysis.audio_energy import AudioEnergy
energy = AudioEnergy()
ae_result = energy.execute({
"input_path": audio_source,
"video_duration_seconds": brief["source"]["duration_seconds"],
})
if ae_result.success:
# Store energy profile summary in style_profile
if "style_profile" not in brief:
brief["style_profile"] = {}
brief["style_profile"]["audio_energy_profile"] = {
"recommended_offset": ae_result.data.get("recommended_offset_seconds", 0),
"has_energy_data": True,
}
steps_completed.append("audio_energy")
except Exception as e:
steps_failed.append(f"audio_energy: {e}")
# ─── STEP 6: Build replication guidance ───
brief["replication_guidance"] = {
"suggested_pipeline": self._suggest_pipeline(brief),
"suggested_playbook": "flat-motion-graphics",
"key_elements_to_replicate": [], # Agent fills via analysis
"elements_requiring_custom_work": [],
"estimated_complexity": self._estimate_complexity(brief),
"motion_required": self._needs_motion(brief),
"creative_differentiation_seeds": [], # Agent fills
}
# ─── STEP 7: Initialize style_profile ───
if "style_profile" not in brief:
brief["style_profile"] = {}
# Narration style from transcript
if transcript_data:
duration = brief["source"]["duration_seconds"]
wc = transcript_data.get("word_count", 0) if isinstance(transcript_data, dict) else brief.get("narration_transcript", {}).get("word_count", 0)
wpm = round(wc / (duration / 60), 1) if duration > 0 else 0
brief["style_profile"]["narration_style"] = {
"has_narration": wc > 20,
"speaker_count": 1, # Agent refines via analysis
"delivery_style": "", # Agent fills
"words_per_minute": wpm,
}
# Initialize remaining style fields for agent to fill
brief["style_profile"].setdefault("color_palette", {
"primary_colors": [],
"accent_colors": [],
"overall_mood": "",
})
brief["style_profile"].setdefault("typography_observed", "")
brief["style_profile"].setdefault("transition_types", [])
brief["style_profile"].setdefault("music_style", "")
brief["style_profile"].setdefault("subtitle_style", "")
brief["style_profile"].setdefault("production_quality", "prosumer")
brief["style_profile"].setdefault("closest_playbook", "")
brief["style_profile"].setdefault("playbook_delta", "")
# ─── Finalize ───
brief["_analysis_meta"] = {
"depth": depth,
"steps_completed": steps_completed,
"steps_failed": steps_failed,
"keyframe_count": len(keyframes),
"scene_count": len(scenes),
"has_transcript": transcript_data is not None,
"duration_seconds": round(time.time() - start, 2),
}
self._save_brief(brief, output_dir)
elapsed = time.time() - start
artifacts = [str(output_dir / "video_analysis_brief.json")]
if keyframe_dir.exists():
artifacts.append(str(keyframe_dir))
return ToolResult(
success=True,
data=brief,
artifacts=artifacts,
duration_seconds=round(elapsed, 2),
)
# ─── Helpers ───
def _get_duration(self, video_path: Path) -> float:
"""Get video duration via ffprobe."""
cmd = [
"ffprobe", "-v", "quiet",
"-show_entries", "format=duration",
"-of", "json",
str(video_path),
]
result = self.run_command(cmd)
data = json.loads(result.stdout)
return float(data.get("format", {}).get("duration", 0))
def _compute_keyframe_timestamps(
self, scenes: list[dict], max_frames: int, depth: str
) -> list[float]:
"""Compute optimal keyframe timestamps from scene boundaries."""
timestamps = []
for scene in scenes:
start = scene.get("start_seconds", 0)
end = scene.get("end_seconds", 0)
duration = end - start
# First frame of each scene
timestamps.append(start + 0.1)
# Midpoint for scenes > 3 seconds
if duration > 3.0:
timestamps.append(start + duration / 2)
# For deep analysis, add more intra-scene samples
if depth == "deep" and duration > 6.0:
timestamps.append(start + duration * 0.25)
timestamps.append(start + duration * 0.75)
# Deduplicate, sort, and limit
timestamps = sorted(set(round(t, 3) for t in timestamps))
if len(timestamps) > max_frames:
# Uniform subsample to max_frames
step = len(timestamps) / max_frames
timestamps = [timestamps[int(i * step)] for i in range(max_frames)]
return timestamps
def _timestamp_to_scene(self, ts: float, scenes: list[dict]) -> int:
"""Map a timestamp to its scene index."""
for scene in scenes:
start = scene.get("start_seconds", 0)
end = scene.get("end_seconds", 0)
if start <= ts <= end:
return scene.get("index", scene.get("scene_index", 0))
return 0
def _classify_pacing(self, durations: list[float]) -> str:
"""Classify pacing style from scene durations."""
if not durations:
return "variable"
avg = sum(durations) / len(durations)
if avg > 10:
return "slow_contemplative"
if avg > 5:
return "steady_educational"
if avg > 2:
return "dynamic_social"
return "rapid_fire"
def _suggest_pipeline(self, brief: dict) -> str:
"""Suggest the best pipeline based on content analysis."""
platform = brief["source"]["type"]
pacing = brief["structure_analysis"].get("pacing_profile", {}).get("pacing_style", "")
if platform in ("shorts", "tiktok", "instagram"):
return "animation" # Short-form → animation pipeline works well
if pacing in ("slow_contemplative",):
return "cinematic"
return "animated-explainer"
def _estimate_complexity(self, brief: dict) -> str:
"""Estimate how complex it would be to recreate this style."""
scenes = brief["structure_analysis"]["total_scenes"]
duration = brief["source"]["duration_seconds"]
if duration > 300 or scenes > 30:
return "complex"
if duration > 120 or scenes > 15:
return "moderate"
return "simple"
def _needs_motion(self, brief: dict) -> bool:
"""Determine if motion (video gen or Remotion) is required."""
pacing = brief["structure_analysis"].get("pacing_profile", {}).get("pacing_style", "")
return pacing in ("dynamic_social", "rapid_fire")
def _save_brief(self, brief: dict, output_dir: Path) -> None:
"""Save the VideoAnalysisBrief to disk."""
out_path = output_dir / "video_analysis_brief.json"
# Remove non-serializable items
clean_brief = {k: v for k, v in brief.items()}
with open(out_path, "w", encoding="utf-8") as f:
json.dump(clean_brief, f, indent=2, default=str)
+355
View File
@@ -0,0 +1,355 @@
"""Video downloader tool wrapping yt-dlp.
Downloads video, audio, or subtitles from YouTube, Shorts, Instagram Reels,
TikTok, and 1000+ other sites. Designed for reference video analysis — downloads
at analysis quality (720p), not production quality.
"""
from __future__ import annotations
import json
import re
import time
from pathlib import Path
from typing import Any
from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
ToolResult,
ToolStability,
ToolStatus,
ToolTier,
ToolRuntime,
)
class VideoDownloader(BaseTool):
name = "video_downloader"
version = "0.1.0"
tier = ToolTier.SOURCE
capability = "source_ingest"
provider = "yt-dlp"
stability = ToolStability.PRODUCTION
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
runtime = ToolRuntime.LOCAL
dependencies = ["python:yt_dlp"]
install_instructions = (
"Install yt-dlp: pip install yt-dlp\n"
"For YouTube support, also install Deno (JS runtime): "
"https://deno.land/#installation\n"
"Without Deno, YouTube downloads may fail but other platforms still work."
)
agent_skills = ["video-download"]
capabilities = [
"download_video",
"download_audio",
"download_subtitles",
"extract_metadata",
]
best_for = [
"downloading reference video from URL",
"extracting audio from online video",
"downloading subtitles from YouTube",
"getting video metadata without downloading",
]
not_good_for = [
"downloading entire playlists",
"downloading DRM-protected content",
]
input_schema = {
"type": "object",
"required": ["url", "output_dir"],
"properties": {
"url": {"type": "string", "description": "Video URL to download"},
"output_dir": {"type": "string", "description": "Directory for downloaded files"},
"format": {
"type": "string",
"enum": ["video", "audio_only", "subtitles_only", "metadata_only"],
"default": "video",
"description": "What to download",
},
"max_resolution": {
"type": "string",
"enum": ["360p", "480p", "720p", "1080p"],
"default": "720p",
"description": "Maximum video resolution (for analysis, 720p is sufficient)",
},
"max_duration_seconds": {
"type": "integer",
"default": 600,
"description": "Reject videos longer than this (safety limit)",
},
},
}
output_schema = {
"type": "object",
"properties": {
"video_path": {"type": ["string", "null"]},
"audio_path": {"type": ["string", "null"]},
"subtitle_path": {"type": ["string", "null"]},
"metadata": {
"type": "object",
"properties": {
"title": {"type": "string"},
"duration": {"type": "number"},
"uploader": {"type": "string"},
"upload_date": {"type": "string"},
"description": {"type": "string"},
"view_count": {"type": "integer"},
"like_count": {"type": "integer"},
},
},
"platform": {"type": "string"},
},
}
resource_profile = ResourceProfile(
cpu_cores=1, ram_mb=512, vram_mb=0, disk_mb=2000,
network_required=True,
)
idempotency_key_fields = ["url", "format", "max_resolution"]
side_effects = ["downloads media files to output_dir"]
resume_support_value = "from_start"
user_visible_verification = [
"Check downloaded file plays correctly",
"Verify resolution matches requested max",
]
# --- Resolution mapping ---
_RES_MAP = {
"360p": 360,
"480p": 480,
"720p": 720,
"1080p": 1080,
}
def _detect_platform(self, url: str) -> str:
"""Detect platform from URL."""
url_lower = url.lower()
if "youtube.com/shorts" in url_lower or "youtu.be" in url_lower and "/shorts" in url_lower:
return "shorts"
if "youtube.com" in url_lower or "youtu.be" in url_lower:
return "youtube"
if "instagram.com" in url_lower:
return "instagram"
if "tiktok.com" in url_lower:
return "tiktok"
if "vimeo.com" in url_lower:
return "vimeo"
if "twitter.com" in url_lower or "x.com" in url_lower:
return "twitter"
return "other_url"
def _extract_metadata(self, url: str) -> dict:
"""Extract metadata without downloading."""
import yt_dlp
ydl_opts = {
"quiet": True,
"no_warnings": True,
"skip_download": True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if info is None:
return {"error": "No info extracted", "title": "", "duration": 0}
return {
"title": info.get("title", ""),
"duration": info.get("duration", 0),
"uploader": info.get("uploader", info.get("channel", "")),
"upload_date": info.get("upload_date", ""),
"description": (info.get("description", "") or "")[:500],
"view_count": info.get("view_count", 0),
"like_count": info.get("like_count", 0),
"resolution": f"{info.get('width', 0)}x{info.get('height', 0)}",
"fps": info.get("fps", 0),
}
except Exception as e:
return {"error": str(e), "title": "", "duration": 0}
def execute(self, inputs: dict[str, Any]) -> ToolResult:
url = inputs["url"]
output_dir = Path(inputs["output_dir"])
dl_format = inputs.get("format", "video")
max_res = inputs.get("max_resolution", "720p")
max_duration = inputs.get("max_duration_seconds", 600)
output_dir.mkdir(parents=True, exist_ok=True)
platform = self._detect_platform(url)
start = time.time()
# Step 1: Always get metadata first
metadata = self._extract_metadata(url)
# Check duration limit
duration = metadata.get("duration", 0)
if duration and duration > max_duration:
return ToolResult(
success=False,
error=(
f"Video is {duration}s, exceeds max_duration_seconds={max_duration}. "
f"Increase the limit or use a shorter video."
),
data={"metadata": metadata, "platform": platform},
)
if dl_format == "metadata_only":
return ToolResult(
success=True,
data={
"video_path": None,
"audio_path": None,
"subtitle_path": None,
"metadata": metadata,
"platform": platform,
},
duration_seconds=round(time.time() - start, 2),
)
video_path = None
audio_path = None
subtitle_path = None
try:
if dl_format == "video":
video_path, audio_path = self._download_video(
url, output_dir, max_res
)
elif dl_format == "audio_only":
audio_path = self._download_audio(url, output_dir)
elif dl_format == "subtitles_only":
subtitle_path = self._download_subtitles(url, output_dir)
except Exception as e:
elapsed = time.time() - start
return ToolResult(
success=False,
error=f"Download failed: {e}",
data={"metadata": metadata, "platform": platform},
duration_seconds=round(elapsed, 2),
)
elapsed = time.time() - start
artifacts = [p for p in [video_path, audio_path, subtitle_path] if p]
return ToolResult(
success=True,
data={
"video_path": video_path,
"audio_path": audio_path,
"subtitle_path": subtitle_path,
"metadata": metadata,
"platform": platform,
},
artifacts=artifacts,
duration_seconds=round(elapsed, 2),
)
def _download_video(
self, url: str, output_dir: Path, max_res: str
) -> tuple[str | None, str | None]:
"""Download video + extract audio track."""
import yt_dlp
height = self._RES_MAP.get(max_res, 720)
video_out = str(output_dir / "reference_video.%(ext)s")
ydl_opts = {
"format": f"bestvideo[height<={height}]+bestaudio/best[height<={height}]/best",
"merge_output_format": "mp4",
"outtmpl": video_out,
"noplaylist": True,
"quiet": True,
"no_warnings": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
# Find the downloaded video file
video_path = self._find_downloaded(output_dir, "reference_video", ["mp4", "mkv", "webm"])
# Extract audio separately for transcription
audio_path = None
if video_path:
audio_out = output_dir / "reference_audio.wav"
try:
audio_cmd = [
"ffmpeg", "-y",
"-i", video_path,
"-vn",
"-acodec", "pcm_s16le",
"-ar", "16000",
"-ac", "1",
str(audio_out),
]
self.run_command(audio_cmd, timeout=120)
if audio_out.exists():
audio_path = str(audio_out)
except Exception:
pass # Audio extraction is optional
return video_path, audio_path
def _download_audio(self, url: str, output_dir: Path) -> str | None:
"""Download audio only."""
import yt_dlp
audio_out = str(output_dir / "reference_audio.%(ext)s")
ydl_opts = {
"format": "bestaudio/best",
"postprocessors": [{
"key": "FFmpegExtractAudio",
"preferredcodec": "wav",
"preferredquality": "0",
}],
"outtmpl": audio_out,
"noplaylist": True,
"quiet": True,
"no_warnings": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
return self._find_downloaded(output_dir, "reference_audio", ["wav", "mp3", "m4a", "opus"])
def _download_subtitles(self, url: str, output_dir: Path) -> str | None:
"""Download subtitles only."""
import yt_dlp
sub_out = str(output_dir / "reference_subs.%(ext)s")
ydl_opts = {
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": ["en"],
"subtitlesformat": "srt",
"skip_download": True,
"outtmpl": sub_out,
"noplaylist": True,
"quiet": True,
"no_warnings": True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
except Exception:
pass
return self._find_downloaded(output_dir, "reference_subs", ["srt", "vtt", "ass"])
def _find_downloaded(
self, output_dir: Path, prefix: str, extensions: list[str]
) -> str | None:
"""Find a downloaded file by prefix and possible extensions."""
for ext in extensions:
candidates = list(output_dir.glob(f"{prefix}*.{ext}"))
if candidates:
return str(candidates[0])
return None