Files
gart/code/server_tools/qc.py
T
João HenriqueandClaude Sonnet 5 32d78d0f8d fix(qc): padding padrão do remove_media_silence de 0,05s para 0,2s
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>
2026-08-26 17:15:44 -04:00

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,
}