Green screen pipeline: new tools, AnimatedBackground, caption burn fixes

Add green_screen_processor (auto-detect + rembg fallback) and
green_screen_composite (4 layout presets with alpha compositing) tools
to automate the full keying-to-composite pipeline.

Remotion: add AnimatedBackground with gradient mesh and floating orbs
to Explainer, fix caption burn tool (remove entry point arg, auto-detect
dimensions, extend TalkingHead duration to 300s).

Update scene-director, compose-director, and asset-director skill docs
with green screen workflow steps and component constraints.
This commit is contained in:
calesthio
2026-04-02 11:54:30 -07:00
parent 942244ca54
commit a7e5f7498b
11 changed files with 2087 additions and 31 deletions
+430
View File
@@ -0,0 +1,430 @@
"""Green screen composite tool for talking-head pipeline.
Composites a keyed speaker (dark/solid background) over a Remotion
background video with layout presets. Supports news anchor, full behind,
picture-in-picture, and split layouts.
Uses PIL/numpy for frame-level alpha compositing and FFmpeg for
frame extraction, encoding, and audio muxing.
"""
from __future__ import annotations
import json
import shutil
import time
from pathlib import Path
from typing import Any
import numpy as np
from PIL import Image
from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
RetryPolicy,
ResumeSupport,
ToolResult,
ToolStability,
ToolTier,
)
class GreenScreenComposite(BaseTool):
name = "green_screen_composite"
version = "0.1.0"
tier = ToolTier.CORE
capability = "video_post"
provider = "ffmpeg"
stability = ToolStability.EXPERIMENTAL
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
dependencies = ["cmd:ffmpeg", "python:numpy", "python:PIL"]
install_instructions = (
"Install FFmpeg: https://ffmpeg.org/download.html — "
"pip install numpy Pillow"
)
agent_skills = ["ffmpeg"]
capabilities = [
"green_screen_composite",
"speaker_overlay",
"layout_preset",
"alpha_composite",
]
input_schema = {
"type": "object",
"required": ["speaker_path", "background_path", "output_path"],
"properties": {
"speaker_path": {
"type": "string",
"description": "Path to keyed speaker video (dark bg, from green_screen_processor)",
},
"background_path": {
"type": "string",
"description": "Path to Remotion background video",
},
"output_path": {
"type": "string",
"description": "Output composite video path",
},
"original_audio_path": {
"type": "string",
"description": "Path to original footage to extract audio from",
},
"layout": {
"type": "string",
"enum": ["news_anchor", "full_behind", "pip", "split"],
"default": "news_anchor",
"description": (
"news_anchor=speaker bottom-center over shifted bg, "
"full_behind=speaker full-frame on bg, "
"pip=speaker 30% bottom-right, "
"split=speaker left 50% bg right 50%"
),
},
"speaker_scale": {
"type": "number",
"default": 0.65,
"description": "Scale factor for speaker layer",
},
"bg_shift_up": {
"type": "integer",
"default": 300,
"description": "Pixels to shift background content upward",
},
"bg_color_hex": {
"type": "string",
"default": "#0E172A",
"description": "The keyed speaker's background color for alpha creation",
},
},
}
resource_profile = ResourceProfile(
cpu_cores=4, ram_mb=4096, vram_mb=0, disk_mb=8000, network_required=False
)
retry_policy = RetryPolicy(max_retries=1, retryable_errors=["FFmpeg error"])
resume_support = ResumeSupport.FROM_START
idempotency_key_fields = [
"speaker_path", "background_path", "layout",
"speaker_scale", "bg_shift_up", "bg_color_hex",
]
side_effects = ["writes composite video to output_path"]
user_visible_verification = [
"Watch output — speaker should be cleanly composited without color fringing",
"Check layout positioning matches the chosen preset",
"Verify audio is synced if original_audio_path was provided",
]
def execute(self, inputs: dict[str, Any]) -> ToolResult:
speaker_path = Path(inputs["speaker_path"])
background_path = Path(inputs["background_path"])
output_path = Path(inputs["output_path"])
original_audio_path = inputs.get("original_audio_path")
layout = inputs.get("layout", "news_anchor")
speaker_scale = inputs.get("speaker_scale", 0.65)
bg_shift_up = inputs.get("bg_shift_up", 300)
bg_color_hex = inputs.get("bg_color_hex", "#0E172A")
if not speaker_path.exists():
return ToolResult(success=False, error=f"Speaker video not found: {speaker_path}")
if not background_path.exists():
return ToolResult(success=False, error=f"Background video not found: {background_path}")
if original_audio_path and not Path(original_audio_path).exists():
return ToolResult(success=False, error=f"Audio source not found: {original_audio_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
start = time.time()
# Parse bg color
bg_color = self._parse_hex_color(bg_color_hex)
# Step 1: Probe both videos
speaker_info = self._probe_video(speaker_path)
bg_info = self._probe_video(background_path)
if not speaker_info or not bg_info:
return ToolResult(
success=False,
error="Failed to probe one or both input videos",
)
# Step 2: Use the LOWER fps (typically 15fps from speaker)
target_fps = min(speaker_info["fps"], bg_info["fps"])
if target_fps <= 0:
target_fps = 15.0
# Determine output dimensions from background
out_w = bg_info["width"]
out_h = bg_info["height"]
# Use shorter duration
duration = min(speaker_info["duration"], bg_info["duration"])
# Step 3: Extract frames from both videos
temp_dir = output_path.parent / ".greenscreen_composite_tmp"
speaker_frames_dir = temp_dir / "speaker"
bg_frames_dir = temp_dir / "bg"
comp_frames_dir = temp_dir / "composite"
for d in [speaker_frames_dir, bg_frames_dir, comp_frames_dir]:
d.mkdir(parents=True, exist_ok=True)
try:
self._extract_frames(speaker_path, speaker_frames_dir, target_fps)
self._extract_frames(background_path, bg_frames_dir, target_fps)
# Get sorted frame lists
speaker_frames = sorted(speaker_frames_dir.glob("*.png"))
bg_frames = sorted(bg_frames_dir.glob("*.png"))
if not speaker_frames or not bg_frames:
return ToolResult(
success=False,
error="Frame extraction produced no frames",
)
frame_count = min(len(speaker_frames), len(bg_frames))
log_interval = max(1, frame_count // 10)
# Step 4: Composite each frame pair
for i in range(frame_count):
if i % log_interval == 0:
print(f"[green_screen_composite] Compositing frame {i + 1}/{frame_count}")
speaker_img = Image.open(speaker_frames[i]).convert("RGB")
bg_img = Image.open(bg_frames[i]).convert("RGB")
comp = self._composite_frame(
speaker_img, bg_img, bg_color,
layout=layout,
speaker_scale=speaker_scale,
bg_shift_up=bg_shift_up,
out_w=out_w,
out_h=out_h,
)
comp.save(comp_frames_dir / f"frame_{i:06d}.png")
print(f"[green_screen_composite] All {frame_count} frames composited")
# Step 5: Encode composite frames to video
no_audio_path = output_path if not original_audio_path else temp_dir / "no_audio.mp4"
self._encode_frames(comp_frames_dir, no_audio_path, target_fps, out_w, out_h)
# Step 6: Mux audio if provided
if original_audio_path:
self._mux_audio(no_audio_path, Path(original_audio_path), output_path, duration)
if not output_path.exists() or output_path.stat().st_size == 0:
return ToolResult(success=False, error="Output video was not created")
elapsed = time.time() - start
return ToolResult(
success=True,
data={
"output": str(output_path),
"layout": layout,
"fps": target_fps,
"frame_count": frame_count,
"duration": round(duration, 2),
"dimensions": f"{out_w}x{out_h}",
"speaker_scale": speaker_scale,
"has_audio": bool(original_audio_path),
},
artifacts=[str(output_path)],
duration_seconds=round(elapsed, 2),
)
except Exception as e:
return ToolResult(success=False, error=f"Composite failed: {e}")
finally:
# Step 7: Clean up temp directories
self._cleanup_temp(temp_dir)
def _parse_hex_color(self, hex_str: str) -> np.ndarray:
"""Parse a hex color string like '#0E172A' to an RGB numpy array."""
hex_str = hex_str.lstrip("#")
r = int(hex_str[0:2], 16)
g = int(hex_str[2:4], 16)
b = int(hex_str[4:6], 16)
return np.array([r, g, b])
def _probe_video(self, path: Path) -> dict[str, Any] | None:
"""Probe a video for fps, duration, and dimensions."""
cmd = [
"ffprobe", "-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams",
str(path),
]
try:
result = self.run_command(cmd, timeout=30)
data = json.loads(result.stdout)
except Exception:
return None
# Find video stream
video_stream = None
for stream in data.get("streams", []):
if stream.get("codec_type") == "video":
video_stream = stream
break
if not video_stream:
return None
# Parse fps from r_frame_rate (e.g., "30/1" or "15000/1001")
fps_str = video_stream.get("r_frame_rate", "30/1")
try:
num, den = fps_str.split("/")
fps = float(num) / float(den)
except (ValueError, ZeroDivisionError):
fps = 30.0
duration = float(data.get("format", {}).get("duration", 0))
return {
"fps": fps,
"duration": duration,
"width": int(video_stream.get("width", 1920)),
"height": int(video_stream.get("height", 1080)),
}
def _extract_frames(self, video_path: Path, output_dir: Path, fps: float) -> None:
"""Extract frames from a video at the given fps."""
cmd = [
"ffmpeg", "-y",
"-i", str(video_path),
"-vf", f"fps={fps}",
str(output_dir / "frame_%06d.png"),
]
self.run_command(cmd, timeout=600)
def _composite_frame(
self,
speaker_img: Image.Image,
bg_img: Image.Image,
bg_color: np.ndarray,
*,
layout: str,
speaker_scale: float,
bg_shift_up: int,
out_w: int,
out_h: int,
) -> Image.Image:
"""Composite a single speaker frame over a background frame using the given layout."""
# Create alpha mask from speaker frame
speaker_arr = np.array(speaker_img).astype(float)
dist = np.sqrt(np.sum((speaker_arr - bg_color.astype(float)) ** 2, axis=2))
threshold = 35
alpha = np.clip((dist - threshold) * 8, 0, 255).astype(np.uint8)
speaker_rgba = Image.new("RGBA", speaker_img.size)
speaker_rgba.paste(speaker_img, (0, 0))
speaker_rgba.putalpha(Image.fromarray(alpha))
# Prepare background canvas at output size
canvas = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 255))
if layout == "news_anchor":
# Background shifted up so graphics appear above speaker's head
bg_resized = bg_img.resize((out_w, out_h), Image.LANCZOS).convert("RGBA")
# Shift background up: paste it higher so bottom content scrolls up
shifted_bg = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 255))
shifted_bg.paste(bg_resized, (0, -bg_shift_up))
canvas = shifted_bg
# Scale speaker and place at bottom center
sp_w = int(speaker_rgba.width * speaker_scale)
sp_h = int(speaker_rgba.height * speaker_scale)
speaker_scaled = speaker_rgba.resize((sp_w, sp_h), Image.LANCZOS)
x = (out_w - sp_w) // 2
y = out_h - sp_h
canvas.paste(speaker_scaled, (x, y), speaker_scaled)
elif layout == "full_behind":
# Speaker full-frame on background, no scaling, no shifting
bg_resized = bg_img.resize((out_w, out_h), Image.LANCZOS).convert("RGBA")
canvas = bg_resized
# Resize speaker to match output
speaker_full = speaker_rgba.resize((out_w, out_h), Image.LANCZOS)
canvas.paste(speaker_full, (0, 0), speaker_full)
elif layout == "pip":
# Background full-frame, speaker 30% in bottom-right
bg_resized = bg_img.resize((out_w, out_h), Image.LANCZOS).convert("RGBA")
canvas = bg_resized
pip_scale = 0.30
sp_w = int(out_w * pip_scale)
sp_h = int(out_h * pip_scale)
speaker_pip = speaker_rgba.resize((sp_w, sp_h), Image.LANCZOS)
margin = 20
x = out_w - sp_w - margin
y = out_h - sp_h - margin
canvas.paste(speaker_pip, (x, y), speaker_pip)
elif layout == "split":
# Speaker on left 50%, background on right 50%
half_w = out_w // 2
# Left side: speaker resized to fill left half
speaker_left = speaker_rgba.resize((half_w, out_h), Image.LANCZOS)
# Right side: background cropped/resized to fill right half
bg_right = bg_img.resize((half_w, out_h), Image.LANCZOS).convert("RGBA")
canvas.paste(speaker_left, (0, 0), speaker_left)
canvas.paste(bg_right, (half_w, 0), bg_right)
# Convert to RGB for output
return canvas.convert("RGB")
def _encode_frames(
self, frames_dir: Path, output_path: Path, fps: float, width: int, height: int
) -> None:
"""Encode PNG frames to an MP4 video."""
cmd = [
"ffmpeg", "-y",
"-framerate", str(fps),
"-i", str(frames_dir / "frame_%06d.png"),
"-c:v", "libx264", "-crf", "18", "-preset", "fast",
"-pix_fmt", "yuv420p",
"-vf", f"scale={width}:{height}",
str(output_path),
]
self.run_command(cmd, timeout=600)
def _mux_audio(
self, video_path: Path, audio_source: Path, output_path: Path, duration: float
) -> None:
"""Mux audio from the original source into the composite video."""
cmd = [
"ffmpeg", "-y",
"-i", str(video_path),
"-i", str(audio_source),
"-t", f"{duration:.3f}",
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-map", "0:v:0", "-map", "1:a:0",
"-shortest",
str(output_path),
]
self.run_command(cmd, timeout=300)
def _cleanup_temp(self, temp_dir: Path) -> None:
"""Remove temporary frame directories."""
if temp_dir.exists():
try:
shutil.rmtree(temp_dir)
except OSError:
# Best-effort cleanup; log but don't fail
print(f"[green_screen_composite] Warning: could not fully clean {temp_dir}")
def estimate_runtime(self, inputs: dict[str, Any]) -> float:
return 120.0
+622
View File
@@ -0,0 +1,622 @@
"""Green screen keying processor.
Removes green/blue screen backgrounds from footage using either FFmpeg
chromakey filtering or rembg AI segmentation. Supports automatic method
detection by analyzing frame color histograms.
Methods:
- auto: Analyze frames to pick the best method (chromakey vs rembg)
- chromakey: FFmpeg chromakey filter (fast, works well on clean screens)
- rembg: AI background removal via rembg/u2net (slower, handles any bg)
"""
from __future__ import annotations
import json
import os
import platform
import shutil
import time
from pathlib import Path
from typing import Any
from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
RetryPolicy,
ResumeSupport,
ToolResult,
ToolStability,
ToolTier,
)
class GreenScreenProcessor(BaseTool):
name = "green_screen_processor"
version = "0.1.0"
tier = ToolTier.CORE
capability = "video_post"
provider = "ffmpeg"
stability = ToolStability.EXPERIMENTAL
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
dependencies = ["cmd:ffmpeg"]
install_instructions = (
"Install FFmpeg: https://ffmpeg.org/download.html "
"For rembg method: pip install rembg[gpu] onnxruntime"
)
agent_skills = ["ffmpeg"]
capabilities = [
"green_screen_keying",
"chromakey",
"background_removal",
"rembg_segmentation",
]
input_schema = {
"type": "object",
"required": ["input_path", "output_path"],
"properties": {
"input_path": {
"type": "string",
"description": "Path to raw green screen footage",
},
"output_path": {
"type": "string",
"description": "Path for keyed output video",
},
"method": {
"type": "string",
"enum": ["auto", "chromakey", "rembg"],
"default": "auto",
"description": "Keying method: auto detects best approach, chromakey uses FFmpeg, rembg uses AI segmentation",
},
"fps": {
"type": "integer",
"default": 15,
"description": "Output frames per second",
},
"bg_color": {
"type": "string",
"default": "#0E172A",
"description": "Hex color for output background",
},
"max_frames": {
"type": "integer",
"default": 0,
"description": "Limit frames to process (0 = all)",
},
},
}
resource_profile = ResourceProfile(
cpu_cores=4, ram_mb=4096, vram_mb=0, disk_mb=8000, network_required=False
)
retry_policy = RetryPolicy(max_retries=1, retryable_errors=["FFmpeg error"])
resume_support = ResumeSupport.FROM_START
idempotency_key_fields = [
"input_path", "method", "fps", "bg_color", "max_frames",
]
side_effects = ["writes keyed video to output_path"]
user_visible_verification = [
"Check output for green fringing around subject edges",
"Verify background is cleanly replaced with target color",
"Look for flickering or inconsistent keying between frames",
]
# Platform-specific null device
_null_device = "NUL" if platform.system() == "Windows" else "/dev/null"
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["output_path"])
output_path.parent.mkdir(parents=True, exist_ok=True)
method = inputs.get("method", "auto")
fps = inputs.get("fps", 15)
bg_color = inputs.get("bg_color", "#0E172A")
max_frames = inputs.get("max_frames", 0)
start = time.time()
# Step 1: Probe input video
probe = self._probe_video(input_path)
if not probe:
return ToolResult(success=False, error="Failed to probe input video")
duration = probe["duration"]
width = probe["width"]
height = probe["height"]
src_fps = probe["fps"]
# Step 2: Determine method
if method == "auto":
method = self._auto_detect_method(input_path, duration, width, height)
# Step 3: Set up temp directory for frame processing
temp_dir = output_path.parent / f".gs_tmp_{int(time.time())}"
temp_dir.mkdir(parents=True, exist_ok=True)
try:
# Step 4: Extract frames at target fps
frames_dir = temp_dir / "frames"
frames_dir.mkdir(exist_ok=True)
frame_count = self._extract_frames(
input_path, frames_dir, fps, max_frames
)
if frame_count == 0:
return ToolResult(
success=False, error="No frames extracted from input"
)
# Step 5: Process frames
processed_dir = temp_dir / "processed"
processed_dir.mkdir(exist_ok=True)
if method == "chromakey":
ok = self._process_chromakey(
frames_dir, processed_dir, bg_color, frame_count
)
else:
ok = self._process_rembg(
frames_dir, processed_dir, bg_color, frame_count
)
if not ok:
return ToolResult(
success=False,
error=f"Frame processing failed with method={method}",
)
# Step 6: Reconstruct video from processed frames
self._reconstruct_video(processed_dir, output_path, fps, width, height)
if not output_path.exists() or output_path.stat().st_size == 0:
return ToolResult(
success=False, error="Output video was not created"
)
elapsed = time.time() - start
return ToolResult(
success=True,
data={
"method_used": method,
"frame_count": frame_count,
"duration": round(duration, 2),
"output_path": str(output_path),
"resolution": f"{width}x{height}",
"fps": fps,
"bg_color": bg_color,
},
artifacts=[str(output_path)],
duration_seconds=round(elapsed, 2),
)
except Exception as e:
return ToolResult(success=False, error=f"Green screen processing failed: {e}")
finally:
# Clean up temp directory
self._cleanup_dir(temp_dir)
def _probe_video(self, input_path: Path) -> dict[str, Any] | None:
"""Probe video for duration, dimensions, and fps."""
cmd = [
"ffprobe", "-v", "quiet",
"-show_entries", "format=duration:stream=width,height,r_frame_rate",
"-select_streams", "v:0",
"-of", "json",
str(input_path),
]
try:
result = self.run_command(cmd, timeout=30)
data = json.loads(result.stdout)
duration = float(data.get("format", {}).get("duration", 0))
stream = data.get("streams", [{}])[0]
width = int(stream.get("width", 0))
height = int(stream.get("height", 0))
# Parse r_frame_rate like "30/1" or "30000/1001"
fps_str = stream.get("r_frame_rate", "30/1")
if "/" in fps_str:
num, den = fps_str.split("/")
fps_val = float(num) / float(den) if float(den) != 0 else 30.0
else:
fps_val = float(fps_str)
return {
"duration": duration,
"width": width,
"height": height,
"fps": fps_val,
}
except Exception:
return None
def _auto_detect_method(
self, input_path: Path, duration: float, width: int, height: int
) -> str:
"""Analyze sample frames to decide between chromakey and rembg.
Extracts 5 evenly-spaced frames, checks color histograms for
green/blue screen presence, then tests chromakey quality on a sample.
"""
temp_dir = input_path.parent / f".gs_detect_{int(time.time())}"
temp_dir.mkdir(parents=True, exist_ok=True)
try:
# Extract 5 sample frames evenly spaced
interval = max(duration / 6, 0.1)
sample_paths = []
for i in range(5):
ts = interval * (i + 1)
out = temp_dir / f"sample_{i}.png"
cmd = [
"ffmpeg", "-y",
"-ss", f"{ts:.3f}",
"-i", str(input_path),
"-frames:v", "1",
str(out),
]
try:
self.run_command(cmd, timeout=30)
if out.exists():
sample_paths.append(out)
except Exception:
continue
if not sample_paths:
return "rembg" # fallback if we can't extract samples
# Analyze color histograms for green/blue screen presence
has_green_screen = self._detect_green_screen_histogram(sample_paths)
if not has_green_screen:
# No obvious green/blue screen detected, use rembg
return "rembg"
# Test chromakey on a sample frame and check quality
test_frame = sample_paths[len(sample_paths) // 2]
chromakey_quality = self._test_chromakey_quality(test_frame, temp_dir)
if chromakey_quality > 80:
return "chromakey"
else:
return "rembg"
finally:
self._cleanup_dir(temp_dir)
def _detect_green_screen_histogram(self, sample_paths: list[Path]) -> bool:
"""Analyze frames for dominant green or blue channel presence.
Uses FFmpeg signalstats to measure average hue. A strong green
screen typically has a large area of similar green/blue hue.
"""
green_votes = 0
for sample in sample_paths:
cmd = [
"ffmpeg", "-y",
"-i", str(sample),
"-vf", "signalstats=stat=tout+vrep+brng,metadata=mode=print",
"-frames:v", "1",
"-f", "null", self._null_device,
]
try:
result = self.run_command(cmd, timeout=15)
# Check stderr for color stats
output = result.stderr or ""
# Alternative: use FFmpeg to count green-ish pixels
# Run a simpler hue check with colorchannelmixer
cmd2 = [
"ffmpeg", "-y",
"-i", str(sample),
"-vf", (
"split[a][b];"
"[a]colorchannelmixer=rr=0:gg=1:bb=0,"
"threshold=threshold=0.3:similarity=0.3[mask];"
"[mask]blackframe=amount=0:threshold=32"
),
"-frames:v", "1",
"-f", "null", self._null_device,
]
# This is complex; use a simpler approach: check raw pixels
# via a green-range filter
cmd_green = [
"ffmpeg", "-y",
"-i", str(sample),
"-vf", (
"colorkey=color=0x00FF00:similarity=0.4:blend=0.0,"
"alphaextract,"
"blackframe=amount=0:threshold=128"
),
"-frames:v", "1",
"-f", "null", self._null_device,
]
try:
result2 = self.run_command(cmd_green, timeout=15)
stderr = result2.stderr or ""
# blackframe reports percentage of black pixels
# If many pixels became transparent (black in alpha), there's green
if "pblack:" in stderr:
import re
pblack_matches = re.findall(r"pblack:(\d+)", stderr)
if pblack_matches:
pblack = int(pblack_matches[0])
if pblack >= 20:
green_votes += 1
except Exception:
pass
except Exception:
continue
# If majority of frames show green screen
return green_votes >= len(sample_paths) // 2
def _test_chromakey_quality(self, test_frame: Path, temp_dir: Path) -> float:
"""Run chromakey on a test frame and estimate quality percentage.
Returns a score 0-100 indicating what percentage of the expected
background was successfully keyed out.
"""
keyed_out = temp_dir / "chromakey_test.png"
# Apply chromakey and output with alpha
cmd = [
"ffmpeg", "-y",
"-i", str(test_frame),
"-vf", "chromakey=color=0x00FF00:similarity=0.3:blend=0.08",
str(keyed_out),
]
try:
self.run_command(cmd, timeout=15)
except Exception:
return 0.0
if not keyed_out.exists():
return 0.0
# Count transparent pixels via alphaextract + blackframe
cmd2 = [
"ffmpeg", "-y",
"-i", str(keyed_out),
"-vf", "alphaextract,blackframe=amount=0:threshold=32",
"-frames:v", "1",
"-f", "null", self._null_device,
]
try:
result = self.run_command(cmd2, timeout=15)
stderr = result.stderr or ""
import re
pblack_matches = re.findall(r"pblack:(\d+)", stderr)
if pblack_matches:
# pblack = percentage of black pixels in alpha = transparent pixels
return float(pblack_matches[0])
except Exception:
pass
return 0.0
def _extract_frames(
self, input_path: Path, frames_dir: Path, fps: int, max_frames: int
) -> int:
"""Extract frames from video at target fps."""
cmd = [
"ffmpeg", "-y",
"-i", str(input_path),
"-vf", f"fps={fps}",
str(frames_dir / "frame_%06d.png"),
]
if max_frames > 0:
cmd.insert(-1, "-frames:v")
cmd.insert(-1, str(max_frames))
try:
self.run_command(cmd, timeout=600)
except Exception as e:
# ffmpeg may return non-zero but still produce frames
pass
# Count extracted frames
frame_files = sorted(frames_dir.glob("frame_*.png"))
count = len(frame_files)
if count > 0:
# Log progress for large frame counts
if count > 100:
print(f"[green_screen_processor] Extracted {count} frames")
return count
def _process_chromakey(
self,
frames_dir: Path,
processed_dir: Path,
bg_color: str,
frame_count: int,
) -> bool:
"""Process frames using FFmpeg chromakey filter.
Applies chromakey to remove green, then composites onto bg_color.
"""
bg_hex = bg_color.lstrip("#")
# Convert hex to FFmpeg color format
ffmpeg_bg = f"0x{bg_hex}"
frame_files = sorted(frames_dir.glob("frame_*.png"))
processed = 0
for i, frame in enumerate(frame_files):
out_path = processed_dir / frame.name
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"color=c={ffmpeg_bg}:size=1x1",
"-i", str(frame),
"-filter_complex",
(
f"[0:v]scale=iw:ih[bg];"
f"[1:v]chromakey=color=0x00FF00:similarity=0.3:blend=0.08[fg];"
f"[bg][fg]overlay=0:0"
),
"-frames:v", "1",
str(out_path),
]
try:
self.run_command(cmd, timeout=30)
if out_path.exists():
processed += 1
except Exception:
# Try with the frame size explicitly to fix scale
try:
cmd_retry = [
"ffmpeg", "-y",
"-i", str(frame),
"-vf",
f"chromakey=color=0x00FF00:similarity=0.3:blend=0.08,"
f"split[fg][alpha];"
f"[alpha]alphaextract[a];"
f"color=c={ffmpeg_bg}[bg];"
f"[bg][fg][a]maskedmerge",
"-frames:v", "1",
str(out_path),
]
# Simpler fallback: just apply chromakey without compositing
cmd_simple = [
"ffmpeg", "-y",
"-i", str(frame),
"-vf", f"chromakey=color=0x00FF00:similarity=0.3:blend=0.08",
str(out_path),
]
self.run_command(cmd_simple, timeout=30)
if out_path.exists():
processed += 1
except Exception:
continue
if frame_count > 100 and (i + 1) % 50 == 0:
print(
f"[green_screen_processor] Chromakey: {i + 1}/{frame_count} frames"
)
return processed > 0
def _process_rembg(
self,
frames_dir: Path,
processed_dir: Path,
bg_color: str,
frame_count: int,
) -> bool:
"""Process frames using rembg AI segmentation.
Removes background with u2net_human_seg model, then composites
the subject onto bg_color background.
"""
try:
import rembg
from PIL import Image
except ImportError:
return False
# Parse bg_color hex to RGB
bg_hex = bg_color.lstrip("#")
bg_r = int(bg_hex[0:2], 16)
bg_g = int(bg_hex[2:4], 16)
bg_b = int(bg_hex[4:6], 16)
session = rembg.new_session("u2net_human_seg")
frame_files = sorted(frames_dir.glob("frame_*.png"))
processed = 0
for i, frame in enumerate(frame_files):
try:
img = Image.open(frame).convert("RGB")
import numpy as np
# Remove background (returns RGBA)
result = rembg.remove(
np.array(img),
session=session,
)
result_img = Image.fromarray(result)
# Composite onto bg_color background
bg = Image.new("RGBA", result_img.size, (bg_r, bg_g, bg_b, 255))
bg.paste(result_img, (0, 0), result_img)
# Save as RGB
out_path = processed_dir / frame.name
bg.convert("RGB").save(out_path)
processed += 1
except Exception:
continue
if frame_count > 100 and (i + 1) % 50 == 0:
print(
f"[green_screen_processor] rembg: {i + 1}/{frame_count} frames"
)
return processed > 0
def _reconstruct_video(
self,
frames_dir: Path,
output_path: Path,
fps: int,
width: int,
height: int,
) -> None:
"""Reconstruct video from processed frames using FFmpeg."""
cmd = [
"ffmpeg", "-y",
"-framerate", str(fps),
"-i", str(frames_dir / "frame_%06d.png"),
"-vf", f"scale={width}:{height}:flags=lanczos",
"-c:v", "libx264",
"-crf", "18",
"-preset", "fast",
"-pix_fmt", "yuv420p",
str(output_path),
]
self.run_command(cmd, timeout=600)
@staticmethod
def _cleanup_dir(dir_path: Path) -> None:
"""Recursively remove a temp directory."""
if not dir_path.exists():
return
try:
shutil.rmtree(dir_path)
except OSError:
# Best-effort cleanup; individual file removal as fallback
for f in dir_path.rglob("*"):
try:
if f.is_file():
f.unlink()
except OSError:
pass
try:
dir_path.rmdir()
except OSError:
pass
def estimate_runtime(self, inputs: dict[str, Any]) -> float:
method = inputs.get("method", "auto")
if method == "rembg":
return 120.0
elif method == "chromakey":
return 30.0
return 60.0 # auto
+35 -3
View File
@@ -106,6 +106,19 @@ class RemotionCaptionBurn(BaseTool):
"correct replacement. Example: {\"cloud\": \"Claude\"}."
),
},
"overlays": {
"type": "array",
"description": (
"Array of overlay objects to render on top of the video. "
"Each overlay has: type (text_card, stat_card, callout, "
"comparison, bar_chart, line_chart, pie_chart, kpi_grid, "
"hero_title, section_title, stat_reveal), in_seconds, "
"out_seconds, position (lower_third, upper_third, "
"left_panel, right_panel, full_overlay), and component-"
"specific props (text, stat, chartData, etc.). "
"See asset_manifest overlays from the asset-director."
),
},
"force_ffmpeg": {
"type": "boolean",
"default": False,
@@ -245,6 +258,7 @@ class RemotionCaptionBurn(BaseTool):
words_per_page: int,
font_size: int,
highlight_color: str,
overlays: list[dict] | None = None,
) -> ToolResult:
root = self._find_remotion_root()
if root is None:
@@ -262,6 +276,19 @@ class RemotionCaptionBurn(BaseTool):
duration_s = float(dur_out.strip().split("\n")[0])
total_frames = math.ceil(duration_s * 30)
# Detect video dimensions
dim_cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0:s=x",
input_path,
]
dim_result = self.run_command(dim_cmd)
dim_parts = dim_result.stdout.strip().split("x")
width = int(dim_parts[0])
height = int(dim_parts[1])
# Copy video to Remotion public folder
pub_dir = root / "public" / "talking-head"
pub_dir.mkdir(parents=True, exist_ok=True)
@@ -273,6 +300,7 @@ class RemotionCaptionBurn(BaseTool):
props = {
"videoSrc": f"public/talking-head/{video_filename}",
"captions": captions,
"overlays": overlays or [],
"wordsPerPage": words_per_page,
"fontSize": font_size,
"highlightColor": highlight_color,
@@ -287,12 +315,12 @@ class RemotionCaptionBurn(BaseTool):
npx_bin = "npx.cmd" if sys.platform == "win32" else "npx"
render_cmd = [
npx_bin, "remotion", "render",
"src/index.tsx", "TalkingHead",
"TalkingHead",
f"--props={props_file.relative_to(root)}",
"--width=1080", "--height=1920", "--fps=30",
f"--width={width}", f"--height={height}", "--fps=30",
f"--frames=0-{total_frames - 1}",
"--codec=h264", "--crf=18",
str(Path(output_path).resolve()),
f"--output={str(Path(output_path).resolve())}",
]
self.run_command(render_cmd, cwd=str(root))
@@ -307,6 +335,7 @@ class RemotionCaptionBurn(BaseTool):
"duration_seconds": round(duration_s, 2),
"total_frames": total_frames,
"caption_count": len(captions),
"overlay_count": len(overlays or []),
"words_per_page": words_per_page,
},
artifacts=[output_path],
@@ -429,11 +458,14 @@ class RemotionCaptionBurn(BaseTool):
if not captions:
return ToolResult(success=False, error="No caption words extracted.")
overlays = inputs.get("overlays")
# Choose render method
if not force_ffmpeg and self._remotion_available():
result = self._render_remotion(
input_path, output_path, captions,
words_per_page, font_size, highlight_color,
overlays=overlays,
)
else:
result = self._render_ffmpeg(input_path, output_path, captions)