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