Talking-head pipeline: 8 new tools, Remotion TalkingHead composition, and skill rewrites
New tools: face_tracker, visual_qa, eye_enhance, auto_reframe, remotion_caption_burn, showcase_card, silence_cutter. Updated audio_mixer with segmented_music operation and subtitle_gen with ASR corrections. Registered TalkingHead composition in Root.tsx. Rewrote compose/edit/scene director skills for full enhancement chain, Remotion captions, multi-clip assembly, and visual QA. Gitignore cleanup: exclude test demo-props, downloaded music, and generated images.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
"""Face tracking tool using MediaPipe Face Mesh.
|
||||
|
||||
Tracks face bounding boxes, landmarks, and head pose across video frames.
|
||||
Outputs per-frame face data as JSON — used by auto_reframe, face_enhance,
|
||||
and other tools that need to know where the speaker's face is.
|
||||
|
||||
Falls back to OpenCV Haar cascade if MediaPipe 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 FaceTracker(BaseTool):
|
||||
name = "face_tracker"
|
||||
version = "0.1.0"
|
||||
tier = ToolTier.CORE
|
||||
capability = "analysis"
|
||||
provider = "mediapipe"
|
||||
stability = ToolStability.EXPERIMENTAL
|
||||
execution_mode = ExecutionMode.SYNC
|
||||
determinism = Determinism.DETERMINISTIC
|
||||
|
||||
dependencies = ["cmd:ffmpeg"]
|
||||
install_instructions = (
|
||||
"For best results install MediaPipe:\n"
|
||||
"pip install mediapipe opencv-python\n\n"
|
||||
"Falls back to OpenCV Haar cascade (ships with opencv-python)."
|
||||
)
|
||||
agent_skills = ["ffmpeg"]
|
||||
|
||||
capabilities = [
|
||||
"face_detection",
|
||||
"face_tracking",
|
||||
"face_bounding_box",
|
||||
"head_pose_estimation",
|
||||
]
|
||||
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"required": ["input_path"],
|
||||
"properties": {
|
||||
"input_path": {"type": "string"},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Path for face tracking JSON output",
|
||||
},
|
||||
"sample_fps": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"description": "Frames per second to sample (lower = faster, less precise)",
|
||||
},
|
||||
"min_detection_confidence": {
|
||||
"type": "number",
|
||||
"default": 0.5,
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
output_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"frame_count": {"type": "integer"},
|
||||
"face_detected_count": {"type": "integer"},
|
||||
"video_width": {"type": "integer"},
|
||||
"video_height": {"type": "integer"},
|
||||
"fps": {"type": "number"},
|
||||
"duration_seconds": {"type": "number"},
|
||||
"faces": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"frame_index": {"type": "integer"},
|
||||
"timestamp_seconds": {"type": "number"},
|
||||
"bbox": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"type": "number"},
|
||||
"y": {"type": "number"},
|
||||
"width": {"type": "number"},
|
||||
"height": {"type": "number"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resource_profile = ResourceProfile(cpu_cores=2, ram_mb=1024, vram_mb=0, disk_mb=100)
|
||||
idempotency_key_fields = ["input_path", "sample_fps", "min_detection_confidence"]
|
||||
side_effects = ["writes face tracking JSON to output_path"]
|
||||
user_visible_verification = [
|
||||
"Spot-check bounding boxes against video frames",
|
||||
]
|
||||
|
||||
fallback_tools = []
|
||||
|
||||
def _has_mediapipe(self) -> bool:
|
||||
try:
|
||||
import mediapipe # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def _has_opencv(self) -> bool:
|
||||
try:
|
||||
import cv2 # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def get_status(self) -> ToolStatus:
|
||||
if self._has_mediapipe() and self._has_opencv():
|
||||
return ToolStatus.AVAILABLE
|
||||
if self._has_opencv():
|
||||
return ToolStatus.DEGRADED
|
||||
return ToolStatus.UNAVAILABLE
|
||||
|
||||
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}")
|
||||
|
||||
if not self._has_opencv():
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error="opencv-python is required. Install: pip install opencv-python",
|
||||
)
|
||||
|
||||
output_path = Path(
|
||||
inputs.get("output_path", str(input_path.with_suffix(".faces.json")))
|
||||
)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sample_fps = inputs.get("sample_fps", 5)
|
||||
confidence = inputs.get("min_detection_confidence", 0.5)
|
||||
|
||||
start = time.time()
|
||||
|
||||
if self._has_mediapipe():
|
||||
result_data = self._track_mediapipe(input_path, sample_fps, confidence)
|
||||
else:
|
||||
result_data = self._track_opencv(input_path, sample_fps)
|
||||
|
||||
elapsed = time.time() - start
|
||||
|
||||
output_path.write_text(json.dumps(result_data, indent=2), encoding="utf-8")
|
||||
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={
|
||||
"output": str(output_path),
|
||||
"video_width": result_data["video_width"],
|
||||
"video_height": result_data["video_height"],
|
||||
"fps": result_data["fps"],
|
||||
"duration_seconds": result_data["duration_seconds"],
|
||||
"frames_sampled": result_data["frame_count"],
|
||||
"faces_detected": result_data["face_detected_count"],
|
||||
"method": "mediapipe" if self._has_mediapipe() else "opencv_haar",
|
||||
},
|
||||
artifacts=[str(output_path)],
|
||||
duration_seconds=round(elapsed, 2),
|
||||
)
|
||||
|
||||
def _track_mediapipe(
|
||||
self, input_path: Path, sample_fps: float, confidence: float
|
||||
) -> dict:
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
|
||||
mp_face = mp.solutions.face_detection
|
||||
cap = cv2.VideoCapture(str(input_path))
|
||||
|
||||
video_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
video_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
video_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
duration = total_frames / video_fps if video_fps > 0 else 0
|
||||
|
||||
# Calculate frame sampling interval
|
||||
sample_interval = max(1, int(video_fps / sample_fps))
|
||||
|
||||
faces_data: list[dict] = []
|
||||
frame_idx = 0
|
||||
sampled = 0
|
||||
|
||||
with mp_face.FaceDetection(
|
||||
model_selection=1, # 1 = full range (up to 5m), 0 = short range (up to 2m)
|
||||
min_detection_confidence=confidence,
|
||||
) as detector:
|
||||
while cap.isOpened():
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
if frame_idx % sample_interval == 0:
|
||||
sampled += 1
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
results = detector.process(rgb)
|
||||
|
||||
if results.detections:
|
||||
# Use the highest-confidence detection
|
||||
det = max(
|
||||
results.detections,
|
||||
key=lambda d: d.score[0],
|
||||
)
|
||||
bbox = det.location_data.relative_bounding_box
|
||||
faces_data.append({
|
||||
"frame_index": frame_idx,
|
||||
"timestamp_seconds": round(frame_idx / video_fps, 3),
|
||||
"confidence": round(det.score[0], 3),
|
||||
"bbox": {
|
||||
"x": round(bbox.xmin, 4),
|
||||
"y": round(bbox.ymin, 4),
|
||||
"width": round(bbox.width, 4),
|
||||
"height": round(bbox.height, 4),
|
||||
},
|
||||
})
|
||||
|
||||
frame_idx += 1
|
||||
|
||||
cap.release()
|
||||
|
||||
return {
|
||||
"video_width": video_w,
|
||||
"video_height": video_h,
|
||||
"fps": round(video_fps, 2),
|
||||
"duration_seconds": round(duration, 3),
|
||||
"frame_count": sampled,
|
||||
"face_detected_count": len(faces_data),
|
||||
"faces": faces_data,
|
||||
}
|
||||
|
||||
def _track_opencv(self, input_path: Path, sample_fps: float) -> dict:
|
||||
"""Fallback: OpenCV Haar cascade face detection."""
|
||||
import cv2
|
||||
|
||||
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
|
||||
cascade = cv2.CascadeClassifier(cascade_path)
|
||||
|
||||
cap = cv2.VideoCapture(str(input_path))
|
||||
video_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
video_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
video_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
duration = total_frames / video_fps if video_fps > 0 else 0
|
||||
|
||||
sample_interval = max(1, int(video_fps / sample_fps))
|
||||
|
||||
faces_data: list[dict] = []
|
||||
frame_idx = 0
|
||||
sampled = 0
|
||||
|
||||
while cap.isOpened():
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
if frame_idx % sample_interval == 0:
|
||||
sampled += 1
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
detected = cascade.detectMultiScale(
|
||||
gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60)
|
||||
)
|
||||
|
||||
if len(detected) > 0:
|
||||
# Pick largest face
|
||||
areas = [w * h for (_, _, w, h) in detected]
|
||||
best_idx = areas.index(max(areas))
|
||||
x, y, w, h = detected[best_idx]
|
||||
faces_data.append({
|
||||
"frame_index": frame_idx,
|
||||
"timestamp_seconds": round(frame_idx / video_fps, 3),
|
||||
"confidence": 0.0, # Haar doesn't provide confidence
|
||||
"bbox": {
|
||||
"x": round(x / video_w, 4),
|
||||
"y": round(y / video_h, 4),
|
||||
"width": round(w / video_w, 4),
|
||||
"height": round(h / video_h, 4),
|
||||
},
|
||||
})
|
||||
|
||||
frame_idx += 1
|
||||
|
||||
cap.release()
|
||||
|
||||
return {
|
||||
"video_width": video_w,
|
||||
"video_height": video_h,
|
||||
"fps": round(video_fps, 2),
|
||||
"duration_seconds": round(duration, 3),
|
||||
"frame_count": sampled,
|
||||
"face_detected_count": len(faces_data),
|
||||
"faces": faces_data,
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Visual QA tool for automated video quality checks.
|
||||
|
||||
Extracts frames at specified timestamps and runs basic quality checks:
|
||||
- File existence, resolution, duration, codec validation
|
||||
- Frame extraction for visual inspection by the agent
|
||||
- Caption occlusion check (compares brightness in face vs caption zones)
|
||||
- Transition verification (frame similarity at transition points)
|
||||
|
||||
Returns frame paths so the agent can visually inspect them.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class VisualQA(BaseTool):
|
||||
name = "visual_qa"
|
||||
version = "0.1.0"
|
||||
tier = ToolTier.CORE
|
||||
capability = "analysis"
|
||||
provider = "ffmpeg"
|
||||
stability = ToolStability.EXPERIMENTAL
|
||||
execution_mode = ExecutionMode.SYNC
|
||||
determinism = Determinism.DETERMINISTIC
|
||||
|
||||
dependencies = ["cmd:ffmpeg", "cmd:ffprobe"]
|
||||
install_instructions = "Install FFmpeg: https://ffmpeg.org/download.html"
|
||||
agent_skills = ["ffmpeg"]
|
||||
|
||||
capabilities = [
|
||||
"extract_review_frames",
|
||||
"probe_video",
|
||||
"check_audio_levels",
|
||||
]
|
||||
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"required": ["operation", "input_path"],
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["review", "probe", "audio_levels"],
|
||||
"description": (
|
||||
"review: extract frames at timestamps for visual inspection. "
|
||||
"probe: get video metadata (duration, resolution, codecs). "
|
||||
"audio_levels: check audio volume at specified timestamps."
|
||||
),
|
||||
},
|
||||
"input_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the video file to inspect.",
|
||||
},
|
||||
"timestamps": {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"description": (
|
||||
"Timestamps (in seconds) at which to extract frames or "
|
||||
"check audio levels."
|
||||
),
|
||||
},
|
||||
"output_dir": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Directory to save extracted frames. Defaults to a "
|
||||
"'review_frames' subdirectory next to the input file."
|
||||
),
|
||||
},
|
||||
"checks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"resolution",
|
||||
"duration",
|
||||
"audio_present",
|
||||
"pixel_format",
|
||||
"file_size",
|
||||
],
|
||||
},
|
||||
"description": "Specific checks to run (probe operation).",
|
||||
},
|
||||
"expected": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Expected values for validation. "
|
||||
"Keys: width, height, min_duration, max_duration, "
|
||||
"pixel_format, has_audio."
|
||||
),
|
||||
"properties": {
|
||||
"width": {"type": "integer"},
|
||||
"height": {"type": "integer"},
|
||||
"min_duration": {"type": "number"},
|
||||
"max_duration": {"type": "number"},
|
||||
"pixel_format": {"type": "string"},
|
||||
"has_audio": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resource_profile = ResourceProfile(cpu_cores=1, ram_mb=512, vram_mb=0, disk_mb=200)
|
||||
idempotency_key_fields = ["operation", "input_path", "timestamps"]
|
||||
side_effects = ["writes frame images to output_dir"]
|
||||
user_visible_verification = [
|
||||
"Visually inspect extracted frames for quality issues",
|
||||
]
|
||||
|
||||
def execute(self, inputs: dict[str, Any]) -> ToolResult:
|
||||
operation = inputs["operation"]
|
||||
input_path = inputs["input_path"]
|
||||
|
||||
if not Path(input_path).exists():
|
||||
return ToolResult(success=False, error=f"Input not found: {input_path}")
|
||||
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
if operation == "review":
|
||||
result = self._review(inputs)
|
||||
elif operation == "probe":
|
||||
result = self._probe(inputs)
|
||||
elif operation == "audio_levels":
|
||||
result = self._audio_levels(inputs)
|
||||
else:
|
||||
return ToolResult(success=False, error=f"Unknown operation: {operation}")
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=str(e))
|
||||
|
||||
result.duration_seconds = round(time.time() - start, 2)
|
||||
return result
|
||||
|
||||
def _review(self, inputs: dict[str, Any]) -> ToolResult:
|
||||
"""Extract frames at specified timestamps for visual review."""
|
||||
input_path = inputs["input_path"]
|
||||
timestamps = inputs.get("timestamps", [])
|
||||
|
||||
if not timestamps:
|
||||
# Auto-generate timestamps: start, 25%, 50%, 75%, end-1s
|
||||
dur = self._get_duration(input_path)
|
||||
timestamps = [
|
||||
1.0,
|
||||
dur * 0.25,
|
||||
dur * 0.50,
|
||||
dur * 0.75,
|
||||
max(dur - 1.0, 0),
|
||||
]
|
||||
|
||||
output_dir = inputs.get("output_dir")
|
||||
if not output_dir:
|
||||
output_dir = str(Path(input_path).parent / "review_frames")
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
frames = []
|
||||
for ts in timestamps:
|
||||
ts_label = f"{ts:.1f}".replace(".", "_")
|
||||
frame_path = str(Path(output_dir) / f"frame_{ts_label}s.jpg")
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(ts),
|
||||
"-i", input_path,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "2",
|
||||
frame_path,
|
||||
]
|
||||
try:
|
||||
self.run_command(cmd)
|
||||
if Path(frame_path).exists():
|
||||
frames.append({
|
||||
"timestamp": ts,
|
||||
"path": frame_path,
|
||||
})
|
||||
except Exception:
|
||||
frames.append({
|
||||
"timestamp": ts,
|
||||
"path": None,
|
||||
"error": f"Failed to extract frame at {ts}s",
|
||||
})
|
||||
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={
|
||||
"operation": "review",
|
||||
"input": input_path,
|
||||
"frame_count": len([f for f in frames if f.get("path")]),
|
||||
"frames": frames,
|
||||
},
|
||||
artifacts=[f["path"] for f in frames if f.get("path")],
|
||||
)
|
||||
|
||||
def _probe(self, inputs: dict[str, Any]) -> ToolResult:
|
||||
"""Probe video metadata and optionally validate against expectations."""
|
||||
input_path = inputs["input_path"]
|
||||
expected = inputs.get("expected", {})
|
||||
|
||||
# Get comprehensive probe data
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries",
|
||||
"format=duration,size:stream=width,height,codec_name,pix_fmt,"
|
||||
"r_frame_rate,sample_rate,channels,codec_type",
|
||||
"-of", "json",
|
||||
input_path,
|
||||
]
|
||||
import json
|
||||
probe_out = self.run_command(cmd, capture=True)
|
||||
probe_data = json.loads(probe_out)
|
||||
|
||||
# Extract key info
|
||||
video_stream = None
|
||||
audio_stream = None
|
||||
for s in probe_data.get("streams", []):
|
||||
if s.get("codec_type") == "video" and not video_stream:
|
||||
video_stream = s
|
||||
elif s.get("codec_type") == "audio" and not audio_stream:
|
||||
audio_stream = s
|
||||
|
||||
info = {
|
||||
"duration": float(probe_data.get("format", {}).get("duration", 0)),
|
||||
"file_size_mb": round(
|
||||
int(probe_data.get("format", {}).get("size", 0)) / 1048576, 1
|
||||
),
|
||||
"has_audio": audio_stream is not None,
|
||||
}
|
||||
if video_stream:
|
||||
info.update({
|
||||
"width": video_stream.get("width"),
|
||||
"height": video_stream.get("height"),
|
||||
"pixel_format": video_stream.get("pix_fmt"),
|
||||
"video_codec": video_stream.get("codec_name"),
|
||||
"frame_rate": video_stream.get("r_frame_rate"),
|
||||
})
|
||||
if audio_stream:
|
||||
info.update({
|
||||
"audio_codec": audio_stream.get("codec_name"),
|
||||
"sample_rate": audio_stream.get("sample_rate"),
|
||||
"channels": audio_stream.get("channels"),
|
||||
})
|
||||
|
||||
# Validate against expectations
|
||||
issues = []
|
||||
if "width" in expected and info.get("width") != expected["width"]:
|
||||
issues.append(f"Width: expected {expected['width']}, got {info.get('width')}")
|
||||
if "height" in expected and info.get("height") != expected["height"]:
|
||||
issues.append(f"Height: expected {expected['height']}, got {info.get('height')}")
|
||||
if "min_duration" in expected and info["duration"] < expected["min_duration"]:
|
||||
issues.append(
|
||||
f"Duration too short: {info['duration']:.1f}s < {expected['min_duration']}s"
|
||||
)
|
||||
if "max_duration" in expected and info["duration"] > expected["max_duration"]:
|
||||
issues.append(
|
||||
f"Duration too long: {info['duration']:.1f}s > {expected['max_duration']}s"
|
||||
)
|
||||
if "pixel_format" in expected and info.get("pixel_format") != expected["pixel_format"]:
|
||||
issues.append(
|
||||
f"Pixel format: expected {expected['pixel_format']}, got {info.get('pixel_format')}"
|
||||
)
|
||||
if "has_audio" in expected and info["has_audio"] != expected["has_audio"]:
|
||||
issues.append(
|
||||
f"Audio: expected {'present' if expected['has_audio'] else 'absent'}, "
|
||||
f"got {'present' if info['has_audio'] else 'absent'}"
|
||||
)
|
||||
|
||||
info["validation_issues"] = issues
|
||||
info["validation_passed"] = len(issues) == 0
|
||||
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={
|
||||
"operation": "probe",
|
||||
"input": input_path,
|
||||
**info,
|
||||
},
|
||||
)
|
||||
|
||||
def _audio_levels(self, inputs: dict[str, Any]) -> ToolResult:
|
||||
"""Check audio levels at specified timestamps."""
|
||||
input_path = inputs["input_path"]
|
||||
timestamps = inputs.get("timestamps", [])
|
||||
|
||||
if not timestamps:
|
||||
dur = self._get_duration(input_path)
|
||||
timestamps = [1.0, dur * 0.5, max(dur - 2.0, 0)]
|
||||
|
||||
levels = []
|
||||
for ts in timestamps:
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(ts),
|
||||
"-t", "3",
|
||||
"-i", input_path,
|
||||
"-vn", "-af", "volumedetect",
|
||||
"-f", "null", "/dev/null",
|
||||
]
|
||||
try:
|
||||
output = self.run_command(cmd, capture=True, stderr=True)
|
||||
mean_vol = None
|
||||
max_vol = None
|
||||
for line in output.split("\n"):
|
||||
if "mean_volume" in line:
|
||||
mean_vol = float(line.split("mean_volume:")[1].strip().split()[0])
|
||||
elif "max_volume" in line:
|
||||
max_vol = float(line.split("max_volume:")[1].strip().split()[0])
|
||||
levels.append({
|
||||
"timestamp": ts,
|
||||
"mean_volume_db": mean_vol,
|
||||
"max_volume_db": max_vol,
|
||||
})
|
||||
except Exception as e:
|
||||
levels.append({
|
||||
"timestamp": ts,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={
|
||||
"operation": "audio_levels",
|
||||
"input": input_path,
|
||||
"levels": levels,
|
||||
},
|
||||
)
|
||||
|
||||
def _get_duration(self, path: str) -> float:
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
path,
|
||||
]
|
||||
return float(self.run_command(cmd, capture=True).strip().split("\n")[0])
|
||||
Reference in New Issue
Block a user