mirror of
https://github.com/hanzoai/video.git
synced 2026-10-30 18:18:27 +00:00
Prove that adding one API key (OPENAI_API_KEY) to the zero-key foundation produces dramatically better videos for ~$0.69 each. Two compositions built: The Abyss (deep ocean visual essay) and VOID (neural interface product ad). New tools: - audio_probe: ffprobe wrapper with probe_duration() helper - composition_validator: pre-render checks (asset existence, audio-video sync) - pixabay_music: royalty-free music scraper (no API key needed) - freesound_music: Freesound API search + download Remotion upgrades: - BackgroundImageLayer: AI images behind data scenes with ken-burns + dark overlay - Gradient support: all 9 components changed from backgroundColor to background CSS - CaptionOverlay: word spacing fix, WhisperX word-level subtitles - HeroTitle: reduced overlay opacity so background images show through Process codified in agent skills: - compose-director: audio acquisition flow (present user with voice/music/subtitle options), mandatory pre-render validation, post-render self-review (extract frames + transcribe + inspect + present findings to user) - scene-director: narration duration budgeting (word budget from video duration) - remotion skill: pre-render validation section - TTS tool: now returns audio_duration_seconds in result README updated with VOID product ad video embed.
179 lines
5.3 KiB
Python
179 lines
5.3 KiB
Python
"""Lightweight audio/video file probe using ffprobe.
|
|
|
|
Returns duration, format, sample rate, channels, and codec info
|
|
for any media file ffprobe can read. No heavy dependencies — just
|
|
requires ffmpeg/ffprobe on PATH.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from tools.base_tool import (
|
|
BaseTool,
|
|
Determinism,
|
|
ExecutionMode,
|
|
ResourceProfile,
|
|
RetryPolicy,
|
|
ToolResult,
|
|
ToolRuntime,
|
|
ToolStability,
|
|
ToolStatus,
|
|
ToolTier,
|
|
)
|
|
|
|
|
|
def probe_duration(file_path: str | Path) -> float | None:
|
|
"""Quick helper: return duration in seconds, or None on failure.
|
|
|
|
Use this from other tools that just need the duration without
|
|
going through the full tool execute() flow.
|
|
"""
|
|
ffprobe = shutil.which("ffprobe")
|
|
if not ffprobe:
|
|
return None
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
ffprobe,
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
str(file_path),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
data = json.loads(result.stdout)
|
|
return float(data["format"]["duration"])
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class AudioProbe(BaseTool):
|
|
name = "audio_probe"
|
|
version = "0.1.0"
|
|
tier = ToolTier.CORE
|
|
capability = "analysis"
|
|
provider = "ffprobe"
|
|
stability = ToolStability.PRODUCTION
|
|
execution_mode = ExecutionMode.SYNC
|
|
determinism = Determinism.DETERMINISTIC
|
|
runtime = ToolRuntime.LOCAL
|
|
|
|
dependencies = ["binary:ffprobe"]
|
|
install_instructions = (
|
|
"Install ffmpeg (includes ffprobe):\n"
|
|
" Windows: winget install ffmpeg\n"
|
|
" macOS: brew install ffmpeg\n"
|
|
" Linux: sudo apt install ffmpeg"
|
|
)
|
|
|
|
capabilities = ["probe_duration", "probe_format", "probe_streams"]
|
|
best_for = [
|
|
"getting audio/video duration before composition",
|
|
"validating media file format and codec",
|
|
"pre-render checks on asset files",
|
|
]
|
|
|
|
input_schema = {
|
|
"type": "object",
|
|
"required": ["input_path"],
|
|
"properties": {
|
|
"input_path": {
|
|
"type": "string",
|
|
"description": "Path to audio or video file",
|
|
},
|
|
},
|
|
}
|
|
|
|
resource_profile = ResourceProfile(
|
|
cpu_cores=1, ram_mb=64, vram_mb=0, disk_mb=0, network_required=False
|
|
)
|
|
retry_policy = RetryPolicy(max_retries=0, retryable_errors=[])
|
|
idempotency_key_fields = ["input_path"]
|
|
side_effects = []
|
|
|
|
def get_status(self) -> ToolStatus:
|
|
if shutil.which("ffprobe"):
|
|
return ToolStatus.AVAILABLE
|
|
return ToolStatus.UNAVAILABLE
|
|
|
|
def estimate_cost(self, inputs: dict[str, Any]) -> float:
|
|
return 0.0
|
|
|
|
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"File not found: {input_path}")
|
|
|
|
ffprobe = shutil.which("ffprobe")
|
|
if not ffprobe:
|
|
return ToolResult(success=False, error="ffprobe not found on PATH")
|
|
|
|
start = time.time()
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
ffprobe,
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
str(input_path),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return ToolResult(
|
|
success=False,
|
|
error=f"ffprobe failed: {result.stderr.strip()}",
|
|
)
|
|
|
|
data = json.loads(result.stdout)
|
|
except subprocess.TimeoutExpired:
|
|
return ToolResult(success=False, error="ffprobe timed out (15s)")
|
|
except json.JSONDecodeError:
|
|
return ToolResult(success=False, error="ffprobe returned invalid JSON")
|
|
|
|
fmt = data.get("format", {})
|
|
streams = data.get("streams", [])
|
|
|
|
# Find audio stream
|
|
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
|
|
probe_data: dict[str, Any] = {
|
|
"file": str(input_path),
|
|
"duration_seconds": round(float(fmt.get("duration", 0)), 3),
|
|
"format_name": fmt.get("format_name"),
|
|
"format_long_name": fmt.get("format_long_name"),
|
|
"size_bytes": int(fmt.get("size", 0)),
|
|
"bit_rate": int(fmt.get("bit_rate", 0)),
|
|
"stream_count": len(streams),
|
|
}
|
|
|
|
if audio_stream:
|
|
probe_data["audio"] = {
|
|
"codec": audio_stream.get("codec_name"),
|
|
"sample_rate": int(audio_stream.get("sample_rate", 0)),
|
|
"channels": audio_stream.get("channels"),
|
|
"channel_layout": audio_stream.get("channel_layout"),
|
|
"bit_rate": int(audio_stream.get("bit_rate", 0)) if audio_stream.get("bit_rate") else None,
|
|
}
|
|
|
|
return ToolResult(
|
|
success=True,
|
|
data=probe_data,
|
|
duration_seconds=round(time.time() - start, 2),
|
|
)
|