0,05s existia como margem de segurança contra cortar a palavra em cima, mas um silêncio que essa ferramenta encontra costuma ser o respiro natural antes de uma frase nova, não sujeira de edição — e 0,05s raspava esse respiro quase todo. Caso real (projeto Mastopexia): a pausa antes de "Com" tinha 0,567s no áudio original; com padding 0,05 sobrou só ~0,1s no total (0,05 de cada lado), colando o clipe seguinte a 5ms da palavra em vez de deixar uma pausa perceptível. 0,2s alinha com a convenção já documentada para folga em corte de fronteira de frase (editar-por-voz/06-texto-corte-marcador.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
697 lines
30 KiB
Python
697 lines
30 KiB
Python
"""QC e detecção — tool schemas and handlers.
|
|
|
|
Extracted from server.py; see Engine/docs/03_SERVER_TOOLS.md for the tool catalog.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Sequence
|
|
|
|
from mcp.types import TextContent, Tool
|
|
|
|
from fcpxml.media_intel import (
|
|
detect_beats,
|
|
detect_silence,
|
|
map_silence_to_timeline,
|
|
media_src_to_path,
|
|
)
|
|
from fcpxml.model_manager import load_silence_config
|
|
from fcpxml.models import FlashFrameSeverity, TimeValue
|
|
from fcpxml.writer import FCPXMLModifier
|
|
from server_tools._shared import (
|
|
AUDIO_MEDIA_EXTENSIONS,
|
|
MAX_MEDIA_FILE_SIZE,
|
|
_detect_duplicate_groups,
|
|
_detect_flash_frames,
|
|
_detect_gaps,
|
|
_fmt_suggestions,
|
|
_format_clip_table,
|
|
_markdown_table,
|
|
_require_timeline,
|
|
_setup_modifier,
|
|
_text_result,
|
|
_validate_filepath,
|
|
_validate_output_path,
|
|
format_duration,
|
|
format_timecode,
|
|
)
|
|
|
|
TOOLS = [
|
|
Tool(
|
|
name="find_short_cuts",
|
|
description="Find clips shorter than threshold (flash frame detection)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"threshold_seconds": {"type": "number", "default": 0.5}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="find_long_clips",
|
|
description="Find clips longer than threshold",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"threshold_seconds": {"type": "number", "default": 10.0}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="analyze_pacing",
|
|
description="Analyze edit pacing with suggestions for improvements",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"filepath": {"type": "string"}},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="detect_flash_frames",
|
|
description="Find ultra-short clips (flash frames) that are likely errors, with severity categorization",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"critical_threshold_frames": {"type": "integer", "default": 2, "description": "Frames below this = critical (default: 2)"},
|
|
"warning_threshold_frames": {"type": "integer", "default": 6, "description": "Frames below this = warning (default: 6)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="detect_duplicates",
|
|
description="Find clips using the same source media (potential duplicates)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"mode": {"type": "string", "enum": ["same_source", "overlapping_ranges", "identical"], "default": "same_source", "description": "Detection mode"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="detect_gaps",
|
|
description="Find unintentional gaps in the timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"min_gap_frames": {"type": "integer", "default": 1, "description": "Minimum gap size to detect (default: 1 frame)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="validate_timeline",
|
|
description="Comprehensive timeline health check for flash frames, gaps, duplicates, and issues",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"checks": {"type": "array", "items": {"type": "string", "enum": ["all", "flash_frames", "gaps", "duplicates", "offsets"]}, "default": ["all"], "description": "Which checks to run"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="detect_media_silence",
|
|
description="Detect REAL silence by analyzing each clip's source audio with ffmpeg silencedetect, mapped into timeline time. Unlike detect_silence_candidates (XML-only heuristics), this reads the actual media files referenced by the timeline. Requires ffmpeg; clips whose media is missing or unreadable are reported, not failed.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"noise_db": {"type": "number", "description": "Silence threshold in dBFS, -120 to 0. Falls back to the saved silence settings (default -30)"},
|
|
"min_silence": {"type": "number", "description": "Minimum silence duration in seconds to report. Falls back to the saved silence settings (default 0.5)"},
|
|
"clip_name": {"type": "string", "description": "Only analyze the clip with this name"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="detect_beats",
|
|
description="Detect musical beats and tempo in an audio/video file (librosa beat tracker). Writes a beats JSON next to the media file that plugs directly into import_beat_markers + snap_to_beats for beat-synced editing. Requires the optional [intelligence] extra (librosa); degrades to an install hint without it.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"media_path": {"type": "string", "description": "Path to audio/video file (.wav, .mp3, .m4a, .aac, .aif, .flac, .mov, .mp4)"},
|
|
},
|
|
"required": ["media_path"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="remove_media_silence",
|
|
description="Detect REAL silence in each clip's source audio (ffmpeg) and CUT it out of the timeline with ripple. Clips are split around silence; the silent middles are removed and everything after shifts earlier. Non-destructive: writes a _silence_removed copy. Preview with detect_media_silence first.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"noise_db": {"type": "number", "description": "Silence threshold in dBFS, -120 to 0. Falls back to the saved silence settings (default -30)"},
|
|
"min_silence": {"type": "number", "description": "Minimum silence duration in seconds to cut. Falls back to the saved silence settings (default 0.5)"},
|
|
"padding": {"type": "number", "description": "Seconds of silence to keep on each side of a cut so edits breathe (max 5). Falls back to the saved silence settings (default 0.2)"},
|
|
"clip_name": {"type": "string", "description": "Only cut silence in the clip with this name"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _silence_removed suffix)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="detect_silence_candidates",
|
|
description="Detect potential silence/dead air using timeline heuristics (gaps, ultra-short clips, name patterns, duration anomalies)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"min_gap_seconds": {"type": "number", "default": 0.5, "description": "Minimum gap duration to flag"},
|
|
"patterns": {"type": "array", "items": {"type": "string"}, "description": "Name patterns to match (default: gap, silence, room tone)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="remove_silence_candidates",
|
|
description="Remove or mark detected silence candidates from timeline",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"mode": {"type": "string", "enum": ["delete", "mark"], "default": "mark", "description": "delete=remove clips/gaps, mark=add red markers"},
|
|
"min_gap_seconds": {"type": "number", "default": 0.5},
|
|
"min_confidence": {"type": "number", "default": 0.7, "description": "Only act on candidates above this confidence"},
|
|
"output_path": {"type": "string", "description": "Output path (default: adds _silence_cleaned suffix)"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
]
|
|
|
|
|
|
async def handle_find_short_cuts(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
threshold = arguments.get("threshold_seconds", 0.5)
|
|
short = tl.get_clips_shorter_than(threshold)
|
|
if not short:
|
|
return _text_result(f"No clips shorter than {threshold}s")
|
|
return _text_result(_format_clip_table(
|
|
short, f"# Short Clips (< {threshold}s) - {len(short)} found",
|
|
))
|
|
|
|
|
|
async def handle_find_long_clips(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
threshold = arguments.get("threshold_seconds", 10.0)
|
|
long = tl.get_clips_longer_than(threshold)
|
|
if not long:
|
|
return _text_result(f"No clips longer than {threshold}s")
|
|
return _text_result(_format_clip_table(
|
|
long, f"# Long Clips (> {threshold}s) - {len(long)} found",
|
|
))
|
|
|
|
|
|
async def handle_analyze_pacing(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
if not tl.clips:
|
|
return _text_result("No clips to analyze")
|
|
durs = [c.duration_seconds for c in tl.clips]
|
|
avg = sum(durs) / len(durs)
|
|
q_len = len(durs) // 4 or 1
|
|
segments = [durs[i:i+q_len] for i in range(0, len(durs), q_len)][:4]
|
|
seg_avgs = [sum(s)/len(s) if s else 0 for s in segments]
|
|
suggestions = []
|
|
flash = [c for c in tl.clips if c.duration_seconds < 0.2]
|
|
if flash:
|
|
suggestions.append(f" {len(flash)} potential flash frames (< 0.2s)")
|
|
long = [c for c in tl.clips if c.duration_seconds > 30]
|
|
if long:
|
|
suggestions.append(f" {len(long)} long takes (> 30s) - consider trimming")
|
|
if len(seg_avgs) >= 4 and seg_avgs[3] < seg_avgs[0] * 0.7:
|
|
suggestions.append(" Pacing accelerates toward end - good for building energy")
|
|
elif len(seg_avgs) >= 4 and seg_avgs[3] > seg_avgs[0] * 1.3:
|
|
suggestions.append(" Pacing slows toward end - consider tightening")
|
|
return _text_result(f"""# Pacing Analysis: {tl.name}
|
|
|
|
## Overall
|
|
- **Avg Cut**: {format_duration(avg)}
|
|
- **Cuts/Min**: {tl.cuts_per_minute:.1f}
|
|
|
|
## By Section
|
|
| Q1 | Q2 | Q3 | Q4 |
|
|
|----|----|----|----|
|
|
| {format_duration(seg_avgs[0]) if len(seg_avgs) > 0 else 'N/A'} | {format_duration(seg_avgs[1]) if len(seg_avgs) > 1 else 'N/A'} | {format_duration(seg_avgs[2]) if len(seg_avgs) > 2 else 'N/A'} | {format_duration(seg_avgs[3]) if len(seg_avgs) > 3 else 'N/A'} |
|
|
|
|
## Suggestions
|
|
{_fmt_suggestions(suggestions)}
|
|
""")
|
|
|
|
|
|
async def handle_detect_flash_frames(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
critical_threshold = arguments.get("critical_threshold_frames", 2)
|
|
warning_threshold = arguments.get("warning_threshold_frames", 6)
|
|
|
|
flash_frames = _detect_flash_frames(
|
|
tl, critical_threshold=critical_threshold, warning_threshold=warning_threshold,
|
|
)
|
|
|
|
if not flash_frames:
|
|
return _text_result(f"No flash frames detected (threshold: {warning_threshold} frames)")
|
|
|
|
critical = [f for f in flash_frames if f.severity == FlashFrameSeverity.CRITICAL]
|
|
warnings = [f for f in flash_frames if f.severity == FlashFrameSeverity.WARNING]
|
|
|
|
result = f"""# Flash Frame Detection
|
|
|
|
## Summary
|
|
- **Critical** (< {critical_threshold} frames): {len(critical)} found
|
|
- **Warning** (< {warning_threshold} frames): {len(warnings)} found
|
|
- **Total**: {len(flash_frames)} flash frames
|
|
|
|
## Critical Flash Frames
|
|
"""
|
|
flash_headers = ["Clip", "Timecode", "Frames", "Duration"]
|
|
if critical:
|
|
result += _markdown_table(flash_headers, [
|
|
[f.clip_name, format_timecode(f.start), f"{f.duration_frames}f", format_duration(f.duration_seconds)]
|
|
for f in critical
|
|
]) + "\n"
|
|
else:
|
|
result += "_None_\n"
|
|
|
|
result += "\n## Warning Flash Frames\n"
|
|
if warnings:
|
|
result += _markdown_table(flash_headers, [
|
|
[f.clip_name, format_timecode(f.start), f"{f.duration_frames}f", format_duration(f.duration_seconds)]
|
|
for f in warnings
|
|
]) + "\n"
|
|
else:
|
|
result += "_None_\n"
|
|
|
|
result += "\n*Use `fix_flash_frames` to automatically resolve these issues.*"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_detect_duplicates(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
mode = arguments.get("mode", "same_source")
|
|
|
|
duplicates = _detect_duplicate_groups(tl, mode=mode)
|
|
|
|
if not duplicates:
|
|
return _text_result(f"No duplicate clips found (mode: {mode})")
|
|
|
|
result = f"""# Duplicate Clip Detection
|
|
|
|
## Summary
|
|
- **Mode**: {mode}
|
|
- **Duplicate Groups**: {len(duplicates)}
|
|
- **Total Duplicate Clips**: {sum(g.count for g in duplicates)}
|
|
|
|
## Duplicate Groups
|
|
"""
|
|
for group in duplicates:
|
|
result += f"\n### {group.source_name} ({group.count} uses)\n"
|
|
result += "| Clip Name | Timeline Position | Duration |\n|-----------|-------------------|----------|\n"
|
|
for c in group.clips:
|
|
result += f"| {c['name']} | {c['timecode']} | {format_duration(c['duration'])} |\n"
|
|
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_detect_gaps(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
min_gap_frames = arguments.get("min_gap_frames", 1)
|
|
|
|
gaps = _detect_gaps(tl, min_gap_frames=min_gap_frames)
|
|
|
|
if not gaps:
|
|
return _text_result(f"No gaps detected (minimum: {min_gap_frames} frame(s))")
|
|
|
|
result = f"""# Gap Detection
|
|
|
|
## Summary
|
|
- **Gaps Found**: {len(gaps)}
|
|
- **Total Gap Duration**: {format_duration(sum(g.duration_seconds for g in gaps))}
|
|
- **Minimum Detection**: {min_gap_frames} frame(s)
|
|
|
|
## Gaps
|
|
"""
|
|
result += _markdown_table(
|
|
["Position", "Duration", "Between"],
|
|
[[gap.timecode, f"{gap.duration_frames}f ({format_duration(gap.duration_seconds)})",
|
|
f"{gap.previous_clip} -> {gap.next_clip}"] for gap in gaps],
|
|
) + "\n"
|
|
|
|
result += "\n*Use `fill_gaps` to automatically close these gaps.*"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_validate_timeline(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
checks = arguments.get("checks", ["all"])
|
|
run_all = "all" in checks
|
|
|
|
issues: list[str] = []
|
|
flash_count = 0
|
|
gap_count = 0
|
|
duplicate_count = 0
|
|
|
|
if run_all or "flash_frames" in checks:
|
|
flashes = _detect_flash_frames(tl)
|
|
flash_count = len(flashes)
|
|
for f in flashes:
|
|
severity = "error" if f.severity == FlashFrameSeverity.CRITICAL else "warning"
|
|
issues.append(
|
|
f"- [{severity.upper()}] Flash frame: {f.clip_name} "
|
|
f"({f.duration_frames}f) at {format_timecode(f.start)}"
|
|
)
|
|
|
|
if run_all or "gaps" in checks:
|
|
detected_gaps = _detect_gaps(tl)
|
|
gap_count = len(detected_gaps)
|
|
for g in detected_gaps:
|
|
issues.append(f"- [WARNING] Gap: {g.duration_frames}f at {g.timecode}")
|
|
|
|
if run_all or "duplicates" in checks:
|
|
dup_groups = _detect_duplicate_groups(tl)
|
|
for group in dup_groups:
|
|
duplicate_count += group.count
|
|
issues.append(
|
|
f"- [INFO] Duplicate source: {group.source_name} ({group.count} uses)"
|
|
)
|
|
|
|
error_weight = 10
|
|
warning_weight = 3
|
|
info_weight = 1
|
|
errors = len([i for i in issues if "[ERROR]" in i])
|
|
warnings = len([i for i in issues if "[WARNING]" in i])
|
|
infos = len([i for i in issues if "[INFO]" in i])
|
|
penalty = (errors * error_weight) + (warnings * warning_weight) + (infos * info_weight)
|
|
health_score = max(0, 100 - penalty)
|
|
|
|
result = f"""# Timeline Validation: {tl.name}
|
|
|
|
## Health Score: {health_score}%
|
|
|
|
## Summary
|
|
| Check | Count | Status |
|
|
|-------|-------|--------|
|
|
| Flash Frames | {flash_count} | {'PASS' if flash_count == 0 else 'FAIL'} |
|
|
| Gaps | {gap_count} | {'PASS' if gap_count == 0 else 'WARN'} |
|
|
| Duplicate Sources | {duplicate_count} | {'PASS' if duplicate_count == 0 else 'INFO'} |
|
|
|
|
## Issues ({len(issues)})
|
|
"""
|
|
if issues:
|
|
result += "\n".join(issues[:20])
|
|
if len(issues) > 20:
|
|
result += f"\n... and {len(issues) - 20} more issues"
|
|
else:
|
|
result += "_No issues found!_"
|
|
|
|
result += "\n\n*Use `fix_flash_frames` and `fill_gaps` to automatically resolve issues.*"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_detect_media_silence(arguments: dict) -> Sequence[TextContent]:
|
|
# Unpassed thresholds come from the persisted silence settings (the app's
|
|
# own slider), not a hardcoded constant, so detection previews exactly
|
|
# what removal would cut.
|
|
saved = load_silence_config()
|
|
noise_db = float(arguments.get("noise_db", saved["noise_db"]))
|
|
min_silence = float(arguments.get("min_silence", saved["min_silence"]))
|
|
# Same bounds detect_silence() enforces — validated here so a bad request
|
|
# fails before any media file is opened.
|
|
if not (-120.0 <= noise_db <= 0.0):
|
|
raise ValueError(f"noise_db must be between -120 and 0 dB, got {noise_db}")
|
|
if not (0 < min_silence <= 3600):
|
|
raise ValueError(f"min_silence must be between 0 and 3600 seconds, got {min_silence}")
|
|
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
modifier = FCPXMLModifier(filepath)
|
|
clip_filter = arguments.get("clip_name")
|
|
|
|
max_media_probes = 100
|
|
findings: list[tuple[str, float, float]] = []
|
|
skipped: list[tuple[str, str]] = []
|
|
probe_cache: dict[str, list | None] = {}
|
|
for el in [el for _, el in modifier._iter_spine_clips()]:
|
|
name = el.get("name", "")
|
|
if clip_filter and name != clip_filter:
|
|
continue
|
|
src = modifier.resources.get(el.get("ref", ""), {}).get("src", "")
|
|
media_path = media_src_to_path(src)
|
|
if not media_path or not Path(media_path).is_file():
|
|
skipped.append((name, "media file missing"))
|
|
continue
|
|
if media_path not in probe_cache:
|
|
if len(probe_cache) >= max_media_probes:
|
|
skipped.append((name, f"probe cap reached ({max_media_probes} media files)"))
|
|
continue
|
|
probe_cache[media_path] = detect_silence(
|
|
media_path, noise_db=noise_db, min_duration=min_silence
|
|
)
|
|
silences = probe_cache[media_path]
|
|
if silences is None:
|
|
skipped.append((name, "unanalyzable (ffmpeg missing or media unreadable)"))
|
|
continue
|
|
source_start = modifier.source_file_start(el).to_seconds()
|
|
clip_duration = modifier._parse_time(el.get("duration", "0s")).to_seconds()
|
|
timeline_offset = modifier._parse_time(el.get("offset", "0s")).to_seconds()
|
|
mapped = map_silence_to_timeline(
|
|
silences, source_start, clip_duration, timeline_offset
|
|
)
|
|
findings.extend((name, start, end) for start, end in mapped)
|
|
|
|
total_silence = sum(end - start for _, start, end in findings)
|
|
result = f"""# Media Silence Detection (real audio analysis)
|
|
|
|
## Summary
|
|
- **Threshold**: {noise_db} dB for >= {min_silence}s
|
|
- **Media Files Probed**: {len(probe_cache)}
|
|
- **Silence Spans Found**: {len(findings)} ({format_duration(total_silence)} total)
|
|
"""
|
|
if findings:
|
|
result += "\n## Silence Spans (timeline time)\n"
|
|
result += _markdown_table(
|
|
["Clip", "Start", "End", "Duration"],
|
|
[[name, f"{start:.2f}s", f"{end:.2f}s", f"{end - start:.2f}s"]
|
|
for name, start, end in findings],
|
|
) + "\n"
|
|
result += "\n*To remove: `split_clip` at each boundary, then `delete_clips` with ripple.*"
|
|
if skipped:
|
|
result += "\n## Skipped Clips\n"
|
|
result += _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
) + "\n"
|
|
if not findings and not skipped:
|
|
result += "\nNo silence detected in any clip's source audio."
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_detect_beats(arguments: dict) -> Sequence[TextContent]:
|
|
media_path = _validate_filepath(
|
|
arguments["media_path"], AUDIO_MEDIA_EXTENSIONS, max_size=MAX_MEDIA_FILE_SIZE
|
|
)
|
|
|
|
result = detect_beats(media_path)
|
|
if result is None:
|
|
return _text_result(
|
|
"Beat detection unavailable — librosa is not installed or the file "
|
|
"could not be analyzed.\n\nInstall the optional media-intelligence "
|
|
"extra:\n\n pip install 'fcp-mcp-server[intelligence]'"
|
|
)
|
|
|
|
bpm, beats = result["bpm"], result["beats"]
|
|
beats_data = {
|
|
"source": str(Path(media_path).name),
|
|
"bpm": round(bpm, 2),
|
|
"beats": [round(b, 4) for b in beats],
|
|
"downbeats": [round(b, 4) for b in beats[::4]],
|
|
}
|
|
json_path = _validate_output_path(
|
|
str(Path(media_path).with_name(Path(media_path).stem + "_beats.json")),
|
|
anchor_dir=str(Path(media_path).parent),
|
|
)
|
|
with open(json_path, "w") as f:
|
|
json.dump(beats_data, f, indent=2)
|
|
|
|
preview = beats[:16]
|
|
result_text = f"""# Beat Detection
|
|
|
|
## Summary
|
|
- **Source**: {Path(media_path).name}
|
|
- **Estimated Tempo**: {bpm:.1f} BPM
|
|
- **Beats Detected**: {len(beats)} ({format_duration(beats[-1]) if beats else '0s'} span)
|
|
- **Beats JSON**: {json_path}
|
|
|
|
## First Beats
|
|
"""
|
|
result_text += _markdown_table(
|
|
["#", "Time"],
|
|
[[str(i + 1), f"{b:.3f}s"] for i, b in enumerate(preview)],
|
|
) + "\n"
|
|
result_text += (
|
|
f"\n*Next: `import_beat_markers` with beats_path=\"{json_path}\" to place "
|
|
"markers, then `snap_to_beats` to align your cuts.*"
|
|
)
|
|
return _text_result(result_text)
|
|
|
|
|
|
async def handle_remove_media_silence(arguments: dict) -> Sequence[TextContent]:
|
|
saved = load_silence_config()
|
|
noise_db = float(arguments.get("noise_db", saved["noise_db"]))
|
|
min_silence = float(arguments.get("min_silence", saved["min_silence"]))
|
|
padding = float(arguments.get("padding", saved["padding"]))
|
|
if not (-120.0 <= noise_db <= 0.0):
|
|
raise ValueError(f"noise_db must be between -120 and 0 dB, got {noise_db}")
|
|
if not (0 < min_silence <= 3600):
|
|
raise ValueError(f"min_silence must be between 0 and 3600 seconds, got {min_silence}")
|
|
if not (0 <= padding <= 5):
|
|
raise ValueError(f"padding must be between 0 and 5 seconds, got {padding}")
|
|
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_silence_removed")
|
|
clip_filter = arguments.get("clip_name")
|
|
to_frame_timevalue = modifier.snap_seconds_to_frame
|
|
|
|
max_media_probes = 100
|
|
cuts_made: list[tuple[str, int, float]] = []
|
|
skipped: list[tuple[str, str]] = []
|
|
probe_cache: dict[str, list | None] = {}
|
|
spine_clips = [el for _, el in modifier._iter_spine_clips()]
|
|
for el in spine_clips:
|
|
name = el.get("name", "")
|
|
if clip_filter and name != clip_filter:
|
|
continue
|
|
src = modifier.resources.get(el.get("ref", ""), {}).get("src", "")
|
|
media_path = media_src_to_path(src)
|
|
if not media_path or not Path(media_path).is_file():
|
|
skipped.append((name, "media file missing"))
|
|
continue
|
|
if media_path not in probe_cache:
|
|
if len(probe_cache) >= max_media_probes:
|
|
skipped.append((name, f"probe cap reached ({max_media_probes} media files)"))
|
|
continue
|
|
probe_cache[media_path] = detect_silence(
|
|
media_path, noise_db=noise_db, min_duration=min_silence
|
|
)
|
|
silences = probe_cache[media_path]
|
|
if silences is None:
|
|
skipped.append((name, "unanalyzable (ffmpeg missing or media unreadable)"))
|
|
continue
|
|
|
|
clip_source_start = modifier.source_file_start(el).to_seconds()
|
|
clip_duration = modifier._parse_time(el.get("duration", "0s")).to_seconds()
|
|
cut_ranges = []
|
|
for sil_start, sil_end in silences:
|
|
# Source time -> clip-relative, padded so cuts breathe.
|
|
cut_start = max(sil_start, clip_source_start) - clip_source_start + padding
|
|
cut_end = min(sil_end, clip_source_start + clip_duration) - clip_source_start - padding
|
|
if cut_end > cut_start:
|
|
cut_ranges.append((to_frame_timevalue(cut_start), to_frame_timevalue(cut_end)))
|
|
if not cut_ranges:
|
|
continue
|
|
removed = modifier.cut_clip_ranges(el, cut_ranges)
|
|
if removed > TimeValue.zero():
|
|
cuts_made.append((name, len(cut_ranges), removed.to_seconds()))
|
|
|
|
if not cuts_made:
|
|
text = "# Media Silence Removal\n\nNo silence found to remove — file unchanged (nothing saved)."
|
|
if skipped:
|
|
text += "\n\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
)
|
|
return _text_result(text)
|
|
|
|
modifier.remove_trailing_gaps()
|
|
modifier.save(output_path)
|
|
total_removed = sum(seconds for _, _, seconds in cuts_made)
|
|
result = f"""# Media Silence Removal (real audio analysis)
|
|
|
|
## Summary
|
|
- **Threshold**: {noise_db} dB for >= {min_silence}s, padding {padding}s
|
|
- **Clips Cut**: {len(cuts_made)}
|
|
- **Total Removed**: {format_duration(total_removed)}
|
|
|
|
## Cuts
|
|
"""
|
|
result += _markdown_table(
|
|
["Clip", "Silence Spans Cut", "Removed"],
|
|
[[name, str(count), f"{seconds:.2f}s"] for name, count, seconds in cuts_made],
|
|
) + "\n"
|
|
if skipped:
|
|
result += "\n## Skipped Clips\n" + _markdown_table(
|
|
["Clip", "Reason"], [[name, reason] for name, reason in skipped]
|
|
) + "\n"
|
|
result += f"\nSaved to: {output_path}\n\n*Preview first next time with `detect_media_silence`. Original file untouched.*"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_detect_silence_candidates(arguments: dict) -> Sequence[TextContent]:
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
modifier = FCPXMLModifier(filepath)
|
|
candidates = modifier.detect_silence_candidates(
|
|
min_gap_seconds=arguments.get("min_gap_seconds", 0.5),
|
|
patterns=arguments.get("patterns"),
|
|
)
|
|
|
|
if not candidates:
|
|
return _text_result("No silence candidates detected.")
|
|
|
|
result = f"# Silence Candidates Detected\n\n**Found**: {len(candidates)}\n\n"
|
|
result += "| # | Timecode | Duration | Reason | Confidence | Clip |\n"
|
|
result += "|---|----------|----------|--------|------------|------|\n"
|
|
for i, c in enumerate(candidates, 1):
|
|
result += (
|
|
f"| {i} | {c['start_timecode']} | {format_duration(c['duration_seconds'])} | "
|
|
f"{c['reason']} | {c['confidence']:.0%} | {c.get('clip_name') or '-'} |\n"
|
|
)
|
|
result += (
|
|
"\n**Note**: Detection uses timeline heuristics (gaps, ultra-short clips, name patterns). "
|
|
"Review candidates before removing — some may be intentional."
|
|
)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_remove_silence_candidates(arguments: dict) -> Sequence[TextContent]:
|
|
filepath, output_path, modifier = _setup_modifier(arguments, "_silence_cleaned")
|
|
actions = modifier.remove_silence_candidates(
|
|
mode=arguments.get("mode", "mark"),
|
|
min_gap_seconds=arguments.get("min_gap_seconds", 0.5),
|
|
min_confidence=arguments.get("min_confidence", 0.7),
|
|
)
|
|
modifier.save(output_path)
|
|
|
|
if not actions:
|
|
return _text_result("No silence candidates met the confidence threshold.")
|
|
|
|
mode = arguments.get("mode", "mark")
|
|
result = f"# Silence Candidates {'Marked' if mode == 'mark' else 'Removed'}\n\n"
|
|
result += f"**Actions taken**: {len(actions)}\n\n"
|
|
for a in actions:
|
|
result += f"- **{a['action']}** {a.get('clip_name', 'gap')} ({a['reason']})\n"
|
|
result += f"\nSaved to: `{output_path}`"
|
|
return _text_result(result)
|
|
|
|
|
|
HANDLERS = {
|
|
"find_short_cuts": handle_find_short_cuts,
|
|
"find_long_clips": handle_find_long_clips,
|
|
"analyze_pacing": handle_analyze_pacing,
|
|
"detect_flash_frames": handle_detect_flash_frames,
|
|
"detect_duplicates": handle_detect_duplicates,
|
|
"detect_gaps": handle_detect_gaps,
|
|
"validate_timeline": handle_validate_timeline,
|
|
"detect_media_silence": handle_detect_media_silence,
|
|
"detect_beats": handle_detect_beats,
|
|
"remove_media_silence": handle_remove_media_silence,
|
|
"detect_silence_candidates": handle_detect_silence_candidates,
|
|
"remove_silence_candidates": handle_remove_silence_candidates,
|
|
}
|