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]>
213 lines
6.8 KiB
Python
213 lines
6.8 KiB
Python
"""Color grading tool wrapping FFmpeg LUT and filter chains.
|
|
|
|
Applies cinematic color grading profiles to video. Supports both
|
|
built-in profile presets and external .cube LUT files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from tools.base_tool import (
|
|
BaseTool,
|
|
Determinism,
|
|
ExecutionMode,
|
|
ResourceProfile,
|
|
ToolResult,
|
|
ToolStability,
|
|
ToolTier,
|
|
)
|
|
|
|
|
|
# Built-in grading profiles using FFmpeg colorbalance/curves/eq filters
|
|
PROFILES = {
|
|
"cinematic_warm": {
|
|
"description": "Warm cinematic look with lifted shadows and orange highlights",
|
|
"vf": (
|
|
"colorbalance=rs=0.08:gs=0.02:bs=-0.05:rh=0.06:gh=0.02:bh=-0.04,"
|
|
"curves=all='0/0.03 0.25/0.22 0.5/0.50 0.75/0.78 1/0.97',"
|
|
"eq=contrast=1.05:saturation=1.1"
|
|
),
|
|
},
|
|
"cinematic_cool": {
|
|
"description": "Cool teal-and-orange cinematic grade",
|
|
"vf": (
|
|
"colorbalance=rs=-0.02:gs=-0.03:bs=0.08:rh=0.06:gh=-0.02:bh=-0.06,"
|
|
"curves=all='0/0.02 0.25/0.20 0.5/0.48 0.75/0.78 1/0.98',"
|
|
"eq=contrast=1.08:saturation=1.05"
|
|
),
|
|
},
|
|
"moody_dark": {
|
|
"description": "Crushed blacks, desaturated midtones, dark atmosphere",
|
|
"vf": (
|
|
"curves=all='0/0.05 0.15/0.12 0.5/0.45 0.85/0.82 1/0.95',"
|
|
"eq=contrast=1.12:saturation=0.8:brightness=-0.03"
|
|
),
|
|
},
|
|
"bright_clean": {
|
|
"description": "Bright, clean look with lifted shadows and vivid color",
|
|
"vf": (
|
|
"curves=all='0/0.05 0.25/0.30 0.5/0.55 0.75/0.80 1/1.0',"
|
|
"eq=contrast=1.0:saturation=1.15:brightness=0.02"
|
|
),
|
|
},
|
|
"vintage_film": {
|
|
"description": "Faded film look with grain texture and warm tint",
|
|
"vf": (
|
|
"colorbalance=rs=0.06:gs=0.03:bs=-0.03:ms=0.03:mh=-0.02,"
|
|
"curves=all='0/0.06 0.25/0.25 0.5/0.50 0.75/0.74 1/0.94',"
|
|
"eq=saturation=0.85:contrast=0.95"
|
|
),
|
|
},
|
|
"high_contrast": {
|
|
"description": "Punchy high-contrast grade for dynamic content",
|
|
"vf": (
|
|
"curves=all='0/0 0.20/0.12 0.5/0.50 0.80/0.88 1/1',"
|
|
"eq=contrast=1.2:saturation=1.1"
|
|
),
|
|
},
|
|
"neutral": {
|
|
"description": "Minimal correction — normalize levels and light contrast",
|
|
"vf": "eq=contrast=1.02:saturation=1.02:brightness=0.01",
|
|
},
|
|
}
|
|
|
|
|
|
class ColorGrade(BaseTool):
|
|
name = "color_grade"
|
|
version = "0.1.0"
|
|
tier = ToolTier.CORE
|
|
capability = "enhancement"
|
|
provider = "ffmpeg"
|
|
stability = ToolStability.EXPERIMENTAL
|
|
execution_mode = ExecutionMode.SYNC
|
|
determinism = Determinism.DETERMINISTIC
|
|
|
|
dependencies = ["cmd:ffmpeg"]
|
|
install_instructions = "Install FFmpeg: https://ffmpeg.org/download.html"
|
|
agent_skills = ["ffmpeg"]
|
|
|
|
capabilities = [
|
|
"grade_preset",
|
|
"grade_lut",
|
|
"grade_custom",
|
|
]
|
|
|
|
input_schema = {
|
|
"type": "object",
|
|
"required": ["input_path"],
|
|
"properties": {
|
|
"input_path": {"type": "string"},
|
|
"output_path": {"type": "string"},
|
|
"profile": {
|
|
"type": "string",
|
|
"enum": list(PROFILES.keys()),
|
|
"default": "cinematic_warm",
|
|
},
|
|
"lut_path": {
|
|
"type": "string",
|
|
"description": "Path to external .cube LUT file",
|
|
},
|
|
"intensity": {
|
|
"type": "number",
|
|
"minimum": 0.0,
|
|
"maximum": 1.0,
|
|
"default": 1.0,
|
|
"description": "Blend intensity: 0 = original, 1 = full grade",
|
|
},
|
|
"custom_vf": {"type": "string"},
|
|
"codec": {"type": "string", "default": "libx264"},
|
|
"crf": {"type": "integer", "default": 20},
|
|
},
|
|
}
|
|
|
|
resource_profile = ResourceProfile(cpu_cores=2, ram_mb=1024, vram_mb=0, disk_mb=2000)
|
|
idempotency_key_fields = ["input_path", "profile", "lut_path", "intensity"]
|
|
side_effects = ["writes graded video to output_path"]
|
|
user_visible_verification = [
|
|
"Compare graded output with original for color accuracy",
|
|
"Verify skin tones look natural, not oversaturated",
|
|
]
|
|
|
|
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}")
|
|
|
|
output_path = Path(
|
|
inputs.get("output_path", str(input_path.with_stem(f"{input_path.stem}_graded")))
|
|
)
|
|
codec = inputs.get("codec", "libx264")
|
|
crf = inputs.get("crf", 20)
|
|
|
|
vf = self._build_filter(inputs)
|
|
if not vf:
|
|
return ToolResult(success=False, error="No profile, lut_path, or custom_vf specified")
|
|
|
|
start = time.time()
|
|
|
|
cmd = [
|
|
"ffmpeg", "-y",
|
|
"-i", str(input_path),
|
|
"-vf", vf,
|
|
"-c:v", codec, "-crf", str(crf),
|
|
"-c:a", "copy",
|
|
str(output_path),
|
|
]
|
|
|
|
try:
|
|
self.run_command(cmd)
|
|
except Exception as e:
|
|
return ToolResult(success=False, error=f"FFmpeg failed: {e}")
|
|
|
|
elapsed = time.time() - start
|
|
|
|
return ToolResult(
|
|
success=True,
|
|
data={
|
|
"input": str(input_path),
|
|
"output": str(output_path),
|
|
"profile": inputs.get("profile"),
|
|
"lut": inputs.get("lut_path"),
|
|
"intensity": inputs.get("intensity", 1.0),
|
|
"filter": vf,
|
|
},
|
|
artifacts=[str(output_path)],
|
|
duration_seconds=round(elapsed, 2),
|
|
)
|
|
|
|
def _build_filter(self, inputs: dict[str, Any]) -> str:
|
|
if "custom_vf" in inputs:
|
|
return inputs["custom_vf"]
|
|
|
|
lut_path = inputs.get("lut_path")
|
|
if lut_path and Path(lut_path).exists():
|
|
safe_path = str(Path(lut_path).resolve()).replace("\\", "/").replace(":", "\\:")
|
|
return f"lut3d='{safe_path}'"
|
|
|
|
profile_name = inputs.get("profile", "cinematic_warm")
|
|
profile = PROFILES.get(profile_name)
|
|
if not profile:
|
|
return ""
|
|
|
|
vf = profile["vf"]
|
|
|
|
# Apply intensity blending if < 1.0
|
|
intensity = inputs.get("intensity", 1.0)
|
|
if 0 < intensity < 1.0:
|
|
# Use split + overlay approach: blend graded with original
|
|
vf = (
|
|
f"split[original][tograde];"
|
|
f"[tograde]{vf}[graded];"
|
|
f"[original][graded]blend=all_mode=normal:all_opacity={intensity}"
|
|
)
|
|
|
|
return vf
|
|
|
|
@staticmethod
|
|
def list_profiles() -> dict[str, str]:
|
|
"""Return available profiles and their descriptions."""
|
|
return {name: p["description"] for name, p in PROFILES.items()}
|